Added attachments API and admin panel attachment management for file storage backends settings. Fixed drag drop upload attachments from file manager to minicard or opened card.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu 2025-10-11 11:05:46 +03:00
parent 2f95431c9b
commit ae1f80a52c
5 changed files with 1701 additions and 3 deletions

View file

@ -0,0 +1,384 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Meteor } from 'meteor/meteor';
import { SimpleSchema } from 'meteor/aldeed:simple-schema';
import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
// Attachment Storage Settings Collection
AttachmentStorageSettings = new Mongo.Collection('attachmentStorageSettings');
// Schema for attachment storage settings
AttachmentStorageSettings.attachSchema(
new SimpleSchema({
// Default storage backend for new uploads
defaultStorage: {
type: String,
allowedValues: [STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3],
defaultValue: STORAGE_NAME_FILESYSTEM,
label: 'Default Storage Backend'
},
// Storage backend configuration
storageConfig: {
type: Object,
optional: true,
label: 'Storage Configuration'
},
'storageConfig.filesystem': {
type: Object,
optional: true,
label: 'Filesystem Configuration'
},
'storageConfig.filesystem.enabled': {
type: Boolean,
defaultValue: true,
label: 'Filesystem Storage Enabled'
},
'storageConfig.filesystem.path': {
type: String,
optional: true,
label: 'Filesystem Storage Path'
},
'storageConfig.gridfs': {
type: Object,
optional: true,
label: 'GridFS Configuration'
},
'storageConfig.gridfs.enabled': {
type: Boolean,
defaultValue: true,
label: 'GridFS Storage Enabled'
},
'storageConfig.s3': {
type: Object,
optional: true,
label: 'S3 Configuration'
},
'storageConfig.s3.enabled': {
type: Boolean,
defaultValue: false,
label: 'S3 Storage Enabled'
},
'storageConfig.s3.endpoint': {
type: String,
optional: true,
label: 'S3 Endpoint'
},
'storageConfig.s3.bucket': {
type: String,
optional: true,
label: 'S3 Bucket'
},
'storageConfig.s3.region': {
type: String,
optional: true,
label: 'S3 Region'
},
'storageConfig.s3.sslEnabled': {
type: Boolean,
defaultValue: true,
label: 'S3 SSL Enabled'
},
'storageConfig.s3.port': {
type: Number,
defaultValue: 443,
label: 'S3 Port'
},
// Upload settings
uploadSettings: {
type: Object,
optional: true,
label: 'Upload Settings'
},
'uploadSettings.maxFileSize': {
type: Number,
optional: true,
label: 'Maximum File Size (bytes)'
},
'uploadSettings.allowedMimeTypes': {
type: Array,
optional: true,
label: 'Allowed MIME Types'
},
'uploadSettings.allowedMimeTypes.$': {
type: String,
label: 'MIME Type'
},
// Migration settings
migrationSettings: {
type: Object,
optional: true,
label: 'Migration Settings'
},
'migrationSettings.autoMigrate': {
type: Boolean,
defaultValue: false,
label: 'Auto Migrate to Default Storage'
},
'migrationSettings.batchSize': {
type: Number,
defaultValue: 10,
min: 1,
max: 100,
label: 'Migration Batch Size'
},
'migrationSettings.delayMs': {
type: Number,
defaultValue: 1000,
min: 100,
max: 10000,
label: 'Migration Delay (ms)'
},
'migrationSettings.cpuThreshold': {
type: Number,
defaultValue: 70,
min: 10,
max: 90,
label: 'CPU Threshold (%)'
},
// Metadata
createdAt: {
type: Date,
autoValue() {
if (this.isInsert) {
return new Date();
} else if (this.isUpsert) {
return { $setOnInsert: new Date() };
} else {
this.unset();
}
},
label: 'Created At'
},
updatedAt: {
type: Date,
autoValue() {
if (this.isUpdate || this.isUpsert) {
return new Date();
}
},
label: 'Updated At'
},
createdBy: {
type: String,
optional: true,
label: 'Created By'
},
updatedBy: {
type: String,
optional: true,
label: 'Updated By'
}
})
);
// Helper methods
AttachmentStorageSettings.helpers({
// Get default storage backend
getDefaultStorage() {
return this.defaultStorage || STORAGE_NAME_FILESYSTEM;
},
// Check if storage backend is enabled
isStorageEnabled(storageName) {
if (!this.storageConfig) return false;
switch (storageName) {
case STORAGE_NAME_FILESYSTEM:
return this.storageConfig.filesystem?.enabled !== false;
case STORAGE_NAME_GRIDFS:
return this.storageConfig.gridfs?.enabled !== false;
case STORAGE_NAME_S3:
return this.storageConfig.s3?.enabled === true;
default:
return false;
}
},
// Get storage configuration
getStorageConfig(storageName) {
if (!this.storageConfig) return null;
switch (storageName) {
case STORAGE_NAME_FILESYSTEM:
return this.storageConfig.filesystem;
case STORAGE_NAME_GRIDFS:
return this.storageConfig.gridfs;
case STORAGE_NAME_S3:
return this.storageConfig.s3;
default:
return null;
}
},
// Get upload settings
getUploadSettings() {
return this.uploadSettings || {};
},
// Get migration settings
getMigrationSettings() {
return this.migrationSettings || {};
}
});
// Server-side methods
if (Meteor.isServer) {
// Get or create default settings
Meteor.methods({
'getAttachmentStorageSettings'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
let settings = AttachmentStorageSettings.findOne({});
if (!settings) {
// Create default settings
settings = {
defaultStorage: STORAGE_NAME_FILESYSTEM,
storageConfig: {
filesystem: {
enabled: true,
path: process.env.WRITABLE_PATH ? `${process.env.WRITABLE_PATH}/attachments` : '/data/attachments'
},
gridfs: {
enabled: true
},
s3: {
enabled: false
}
},
uploadSettings: {
maxFileSize: process.env.ATTACHMENTS_UPLOAD_MAX_SIZE ? parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) : 0,
allowedMimeTypes: process.env.ATTACHMENTS_UPLOAD_MIME_TYPES ? process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',').map(t => t.trim()) : []
},
migrationSettings: {
autoMigrate: false,
batchSize: 10,
delayMs: 1000,
cpuThreshold: 70
},
createdBy: this.userId,
updatedBy: this.userId
};
AttachmentStorageSettings.insert(settings);
settings = AttachmentStorageSettings.findOne({});
}
return settings;
},
'updateAttachmentStorageSettings'(settings) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
// Validate settings
const schema = AttachmentStorageSettings.simpleSchema();
schema.validate(settings);
// Update settings
const result = AttachmentStorageSettings.upsert(
{},
{
$set: {
...settings,
updatedBy: this.userId,
updatedAt: new Date()
}
}
);
return result;
},
'getDefaultAttachmentStorage'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const settings = AttachmentStorageSettings.findOne({});
return settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM;
},
'setDefaultAttachmentStorage'(storageName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
if (![STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3].includes(storageName)) {
throw new Meteor.Error('invalid-storage', 'Invalid storage backend');
}
const result = AttachmentStorageSettings.upsert(
{},
{
$set: {
defaultStorage: storageName,
updatedBy: this.userId,
updatedAt: new Date()
}
}
);
return result;
}
});
// Publication for settings
Meteor.publish('attachmentStorageSettings', function() {
if (!this.userId) {
return this.ready();
}
const user = ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
return this.ready();
}
return AttachmentStorageSettings.find({});
});
}
export default AttachmentStorageSettings;

View file

@ -8,6 +8,7 @@ import path from 'path';
import { AttachmentStoreStrategyFilesystem, AttachmentStoreStrategyGridFs, AttachmentStoreStrategyS3 } from '/models/lib/attachmentStoreStrategy';
import FileStoreStrategyFactory, {moveToStorage, rename, STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3} from '/models/lib/fileStoreStrategy';
import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
import AttachmentStorageSettings from './attachmentStorageSettings';
let attachmentUploadExternalProgram;
let attachmentUploadMimeTypes = [];
@ -110,7 +111,18 @@ Attachments = new FilesCollection({
return true;
},
onAfterUpload(fileObj) {
// current storage is the filesystem, update object and database
// Get default storage backend from settings
let defaultStorage = STORAGE_NAME_FILESYSTEM;
try {
const settings = AttachmentStorageSettings.findOne({});
if (settings) {
defaultStorage = settings.getDefaultStorage();
}
} catch (error) {
console.warn('Could not get attachment storage settings, using default:', error);
}
// Set initial storage to filesystem (temporary)
Object.keys(fileObj.versions).forEach(versionName => {
fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
});
@ -119,8 +131,13 @@ Attachments = new FilesCollection({
Attachments.update({ _id: fileObj._id }, { $set: { "versions" : fileObj.versions } });
Attachments.update({ _id: fileObj.uploadedAtOstrio }, { $set: { "uploadedAtOstrio" : this._now } });
let storageDestination = fileObj.meta.copyStorage || STORAGE_NAME_GRIDFS;
Meteor.defer(() => Meteor.call('validateAttachmentAndMoveToStorage', fileObj._id, storageDestination));
// Use selected storage backend or copy storage if specified
let storageDestination = fileObj.meta.copyStorage || defaultStorage;
// Only migrate if the destination is different from filesystem
if (storageDestination !== STORAGE_NAME_FILESYSTEM) {
Meteor.defer(() => Meteor.call('validateAttachmentAndMoveToStorage', fileObj._id, storageDestination));
}
},
interceptDownload(http, fileObj, versionName) {
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);