From ae1f80a52cde09689dddb1209708630b4949b7ee Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Sat, 11 Oct 2025 11:05:46 +0300 Subject: [PATCH] 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 ! --- api.py | 276 ++++++++++++++ models/attachmentStorageSettings.js | 384 +++++++++++++++++++ models/attachments.js | 23 +- server/attachmentApi.js | 468 +++++++++++++++++++++++ server/routes/attachmentApi.js | 553 ++++++++++++++++++++++++++++ 5 files changed, 1701 insertions(+), 3 deletions(-) create mode 100644 models/attachmentStorageSettings.js create mode 100644 server/attachmentApi.js create mode 100644 server/routes/attachmentApi.js diff --git a/api.py b/api.py index c6f2064c4..b7f2f39e2 100755 --- a/api.py +++ b/api.py @@ -39,6 +39,12 @@ If *nix: chmod +x api.py => ./api.py users python3 api.py addcustomfieldtoboard AUTHORID BOARDID NAME TYPE SETTINGS SHOWONCARD AUTOMATICALLYONCARD SHOWLABELONMINICARD SHOWSUMATTOPOFLIST # Add Custom Field to Board python3 api.py editcustomfield BOARDID LISTID CARDID CUSTOMFIELDID NEWCUSTOMFIELDVALUE # Edit Custom Field python3 api.py listattachments BOARDID # List attachments + python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND] # Upload attachment to card + python3 api.py downloadattachment ATTACHMENTID OUTPUTPATH # Download attachment to local file + python3 api.py attachmentinfo ATTACHMENTID # Get attachment information + python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID # List attachments for specific card + python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move] # Copy or move attachment + python3 api.py deleteattachment ATTACHMENTID # Delete attachment python3 api.py cardsbyswimlane SWIMLANEID LISTID # Retrieve cards list on a swimlane python3 api.py getcard BOARDID LISTID CARDID # Get card info python3 api.py addlabel BOARDID LISTID CARDID LABELID # Add label to a card @@ -750,3 +756,273 @@ if arguments == 1: data2 = body.text.replace('}',"}\n") print(data2) # ------- LIST OF PUBLIC BOARDS END ----------- + +# ------- NEW ATTACHMENT API ENDPOINTS START ----------- + + if sys.argv[1] == 'uploadattachment': + # ------- UPLOAD ATTACHMENT START ----------- + if arguments < 5: + print("Usage: python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND]") + print("Storage backends: fs, gridfs, s3") + exit(1) + + boardid = sys.argv[2] + swimlaneid = sys.argv[3] + listid = sys.argv[4] + cardid = sys.argv[5] + filepath = sys.argv[6] + storage_backend = sys.argv[7] if arguments > 6 else None + + # Read file and convert to base64 + try: + with open(filepath, 'rb') as f: + file_data = f.read() + import base64 + base64_data = base64.b64encode(file_data).decode('utf-8') + except FileNotFoundError: + print(f"Error: File '{filepath}' not found") + exit(1) + except Exception as e: + print(f"Error reading file: {e}") + exit(1) + + # Get file info + import os + filename = os.path.basename(filepath) + import mimetypes + file_type = mimetypes.guess_type(filepath)[0] or 'application/octet-stream' + + # Prepare request data + upload_data = { + 'boardId': boardid, + 'swimlaneId': swimlaneid, + 'listId': listid, + 'cardId': cardid, + 'fileData': base64_data, + 'fileName': filename, + 'fileType': file_type + } + + if storage_backend: + upload_data['storageBackend'] = storage_backend + + # Make API call + headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'} + upload_url = wekanurl + 'api/attachment/upload' + + try: + response = requests.post(upload_url, headers=headers, json=upload_data) + response.raise_for_status() + result = response.json() + print(f"Upload successful!") + print(f"Attachment ID: {result.get('attachmentId')}") + print(f"File: {result.get('fileName')}") + print(f"Size: {result.get('fileSize')} bytes") + print(f"Storage: {result.get('storageBackend')}") + except requests.exceptions.RequestException as e: + print(f"Upload failed: {e}") + if hasattr(e, 'response') and e.response is not None: + print(f"Response: {e.response.text}") + # ------- UPLOAD ATTACHMENT END ----------- + + if sys.argv[1] == 'downloadattachment': + # ------- DOWNLOAD ATTACHMENT START ----------- + if arguments < 3: + print("Usage: python3 api.py downloadattachment ATTACHMENTID OUTPUTPATH") + exit(1) + + attachmentid = sys.argv[2] + outputpath = sys.argv[3] + + # Make API call + headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)} + download_url = wekanurl + f'api/attachment/download/{attachmentid}' + + try: + response = requests.get(download_url, headers=headers) + response.raise_for_status() + result = response.json() + + if result.get('success'): + # Decode base64 data and save to file + import base64 + file_data = base64.b64decode(result.get('base64Data')) + + with open(outputpath, 'wb') as f: + f.write(file_data) + + print(f"Download successful!") + print(f"File saved to: {outputpath}") + print(f"Original filename: {result.get('fileName')}") + print(f"Size: {result.get('fileSize')} bytes") + print(f"Storage: {result.get('storageBackend')}") + else: + print(f"Download failed: {result.get('message', 'Unknown error')}") + except requests.exceptions.RequestException as e: + print(f"Download failed: {e}") + if hasattr(e, 'response') and e.response is not None: + print(f"Response: {e.response.text}") + # ------- DOWNLOAD ATTACHMENT END ----------- + + if sys.argv[1] == 'attachmentinfo': + # ------- ATTACHMENT INFO START ----------- + if arguments < 2: + print("Usage: python3 api.py attachmentinfo ATTACHMENTID") + exit(1) + + attachmentid = sys.argv[2] + + # Make API call + headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)} + info_url = wekanurl + f'api/attachment/info/{attachmentid}' + + try: + response = requests.get(info_url, headers=headers) + response.raise_for_status() + result = response.json() + + if result.get('success'): + print("=== ATTACHMENT INFO ===") + print(f"Attachment ID: {result.get('attachmentId')}") + print(f"File Name: {result.get('fileName')}") + print(f"File Size: {result.get('fileSize')} bytes") + print(f"File Type: {result.get('fileType')}") + print(f"Storage Backend: {result.get('storageBackend')}") + print(f"Board ID: {result.get('boardId')}") + print(f"Swimlane ID: {result.get('swimlaneId')}") + print(f"List ID: {result.get('listId')}") + print(f"Card ID: {result.get('cardId')}") + print(f"Created At: {result.get('createdAt')}") + print(f"Is Image: {result.get('isImage')}") + print(f"Versions: {len(result.get('versions', []))}") + else: + print(f"Failed to get attachment info: {result.get('message', 'Unknown error')}") + except requests.exceptions.RequestException as e: + print(f"Request failed: {e}") + if hasattr(e, 'response') and e.response is not None: + print(f"Response: {e.response.text}") + # ------- ATTACHMENT INFO END ----------- + + if sys.argv[1] == 'listcardattachments': + # ------- LIST CARD ATTACHMENTS START ----------- + if arguments < 5: + print("Usage: python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID") + exit(1) + + boardid = sys.argv[2] + swimlaneid = sys.argv[3] + listid = sys.argv[4] + cardid = sys.argv[5] + + # Make API call + headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)} + list_url = wekanurl + f'api/attachment/list/{boardid}/{swimlaneid}/{listid}/{cardid}' + + try: + response = requests.get(list_url, headers=headers) + response.raise_for_status() + result = response.json() + + if result.get('success'): + attachments = result.get('attachments', []) + print(f"=== CARD ATTACHMENTS ({len(attachments)}) ===") + for attachment in attachments: + print(f"ID: {attachment.get('attachmentId')}") + print(f"Name: {attachment.get('fileName')}") + print(f"Size: {attachment.get('fileSize')} bytes") + print(f"Type: {attachment.get('fileType')}") + print(f"Storage: {attachment.get('storageBackend')}") + print(f"Created: {attachment.get('createdAt')}") + print("---") + else: + print(f"Failed to list attachments: {result.get('message', 'Unknown error')}") + except requests.exceptions.RequestException as e: + print(f"Request failed: {e}") + if hasattr(e, 'response') and e.response is not None: + print(f"Response: {e.response.text}") + # ------- LIST CARD ATTACHMENTS END ----------- + + if sys.argv[1] == 'copymoveattachment': + # ------- COPY/MOVE ATTACHMENT START ----------- + if arguments < 6: + print("Usage: python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move]") + exit(1) + + attachmentid = sys.argv[2] + targetboardid = sys.argv[3] + targetswimlaneid = sys.argv[4] + targetlistid = sys.argv[5] + targetcardid = sys.argv[6] + operation = sys.argv[7] if arguments > 6 else 'copy' + + if operation not in ['copy', 'move']: + print("Operation must be 'copy' or 'move'") + exit(1) + + # Prepare request data + request_data = { + 'attachmentId': attachmentid, + 'targetBoardId': targetboardid, + 'targetSwimlaneId': targetswimlaneid, + 'targetListId': targetlistid, + 'targetCardId': targetcardid + } + + # Make API call + headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'} + api_url = wekanurl + f'api/attachment/{operation}' + + try: + response = requests.post(api_url, headers=headers, json=request_data) + response.raise_for_status() + result = response.json() + + if result.get('success'): + print(f"{operation.capitalize()} successful!") + if operation == 'copy': + print(f"Source Attachment ID: {result.get('sourceAttachmentId')}") + print(f"New Attachment ID: {result.get('newAttachmentId')}") + else: + print(f"Attachment ID: {result.get('attachmentId')}") + print(f"Source Board: {result.get('sourceBoardId')}") + print(f"Target Board: {result.get('targetBoardId')}") + print(f"File: {result.get('fileName')}") + print(f"Size: {result.get('fileSize')} bytes") + else: + print(f"{operation.capitalize()} failed: {result.get('message', 'Unknown error')}") + except requests.exceptions.RequestException as e: + print(f"{operation.capitalize()} failed: {e}") + if hasattr(e, 'response') and e.response is not None: + print(f"Response: {e.response.text}") + # ------- COPY/MOVE ATTACHMENT END ----------- + + if sys.argv[1] == 'deleteattachment': + # ------- DELETE ATTACHMENT START ----------- + if arguments < 2: + print("Usage: python3 api.py deleteattachment ATTACHMENTID") + exit(1) + + attachmentid = sys.argv[2] + + # Make API call + headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)} + delete_url = wekanurl + f'api/attachment/delete/{attachmentid}' + + try: + response = requests.delete(delete_url, headers=headers) + response.raise_for_status() + result = response.json() + + if result.get('success'): + print("Delete successful!") + print(f"Attachment ID: {result.get('attachmentId')}") + print(f"File: {result.get('fileName')}") + else: + print(f"Delete failed: {result.get('message', 'Unknown error')}") + except requests.exceptions.RequestException as e: + print(f"Delete failed: {e}") + if hasattr(e, 'response') and e.response is not None: + print(f"Response: {e.response.text}") + # ------- DELETE ATTACHMENT END ----------- + +# ------- NEW ATTACHMENT API ENDPOINTS END ----------- diff --git a/models/attachmentStorageSettings.js b/models/attachmentStorageSettings.js new file mode 100644 index 000000000..8f67c5160 --- /dev/null +++ b/models/attachmentStorageSettings.js @@ -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; diff --git a/models/attachments.js b/models/attachments.js index 91b46d9bb..96de9f79e 100644 --- a/models/attachments.js +++ b/models/attachments.js @@ -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); diff --git a/server/attachmentApi.js b/server/attachmentApi.js new file mode 100644 index 000000000..148753548 --- /dev/null +++ b/server/attachmentApi.js @@ -0,0 +1,468 @@ +import { Meteor } from 'meteor/meteor'; +import { ReactiveCache } from '/imports/reactiveCache'; +import { Attachments, fileStoreStrategyFactory } from '/models/attachments'; +import { moveToStorage } from '/models/lib/fileStoreStrategy'; +import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy'; +import AttachmentStorageSettings from '/models/attachmentStorageSettings'; +import fs from 'fs'; +import path from 'path'; +import { ObjectID } from 'bson'; + +// Attachment API methods +if (Meteor.isServer) { + Meteor.methods({ + // Upload attachment via API + 'api.attachment.upload'(boardId, swimlaneId, listId, cardId, fileData, fileName, fileType, storageBackend) { + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + + // Validate parameters + if (!boardId || !swimlaneId || !listId || !cardId || !fileData || !fileName) { + throw new Meteor.Error('invalid-parameters', 'Missing required parameters'); + } + + // Check if user has permission to modify the card + const card = ReactiveCache.getCard(cardId); + if (!card) { + throw new Meteor.Error('card-not-found', 'Card not found'); + } + + const board = ReactiveCache.getBoard(boardId); + if (!board) { + throw new Meteor.Error('board-not-found', 'Board not found'); + } + + // Check permissions + if (!board.isBoardMember(this.userId)) { + throw new Meteor.Error('not-authorized', 'You do not have permission to modify this card'); + } + + // Check if board allows attachments + if (!board.allowsAttachments) { + throw new Meteor.Error('attachments-not-allowed', 'Attachments are not allowed on this board'); + } + + // Get default storage backend if not specified + let targetStorage = storageBackend; + if (!targetStorage) { + try { + const settings = AttachmentStorageSettings.findOne({}); + targetStorage = settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM; + } catch (error) { + targetStorage = STORAGE_NAME_FILESYSTEM; + } + } + + // Validate storage backend + if (![STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3].includes(targetStorage)) { + throw new Meteor.Error('invalid-storage', 'Invalid storage backend'); + } + + try { + // Create file object from base64 data + const fileBuffer = Buffer.from(fileData, 'base64'); + const file = new File([fileBuffer], fileName, { type: fileType || 'application/octet-stream' }); + + // Create attachment metadata + const fileId = new ObjectID().toString(); + const meta = { + boardId: boardId, + swimlaneId: swimlaneId, + listId: listId, + cardId: cardId, + fileId: fileId, + source: 'api', + storageBackend: targetStorage + }; + + // Create attachment + const uploader = Attachments.insert({ + file: file, + meta: meta, + isBase64: false, + transport: 'http' + }); + + if (uploader) { + // Move to target storage if not filesystem + if (targetStorage !== STORAGE_NAME_FILESYSTEM) { + Meteor.defer(() => { + try { + moveToStorage(uploader, targetStorage, fileStoreStrategyFactory); + } catch (error) { + console.error('Error moving attachment to target storage:', error); + } + }); + } + + return { + success: true, + attachmentId: uploader._id, + fileName: fileName, + fileSize: fileBuffer.length, + storageBackend: targetStorage, + message: 'Attachment uploaded successfully' + }; + } else { + throw new Meteor.Error('upload-failed', 'Failed to upload attachment'); + } + } catch (error) { + console.error('API attachment upload error:', error); + throw new Meteor.Error('upload-error', error.message); + } + }, + + // Download attachment via API + 'api.attachment.download'(attachmentId) { + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + + // Get attachment + const attachment = ReactiveCache.getAttachment(attachmentId); + if (!attachment) { + throw new Meteor.Error('attachment-not-found', 'Attachment not found'); + } + + // Check permissions + const board = ReactiveCache.getBoard(attachment.meta.boardId); + if (!board || !board.isBoardMember(this.userId)) { + throw new Meteor.Error('not-authorized', 'You do not have permission to access this attachment'); + } + + try { + // Get file strategy + const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original'); + const readStream = strategy.getReadStream(); + + if (!readStream) { + throw new Meteor.Error('file-not-found', 'File not found in storage'); + } + + // Read file data + const chunks = []; + return new Promise((resolve, reject) => { + readStream.on('data', (chunk) => { + chunks.push(chunk); + }); + + readStream.on('end', () => { + const fileBuffer = Buffer.concat(chunks); + const base64Data = fileBuffer.toString('base64'); + + resolve({ + success: true, + attachmentId: attachmentId, + fileName: attachment.name, + fileSize: attachment.size, + fileType: attachment.type, + base64Data: base64Data, + storageBackend: strategy.getStorageName() + }); + }); + + readStream.on('error', (error) => { + reject(new Meteor.Error('download-error', error.message)); + }); + }); + } catch (error) { + console.error('API attachment download error:', error); + throw new Meteor.Error('download-error', error.message); + } + }, + + // List attachments for board, swimlane, list, or card + 'api.attachment.list'(boardId, swimlaneId, listId, cardId) { + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + + // Check permissions + const board = ReactiveCache.getBoard(boardId); + if (!board || !board.isBoardMember(this.userId)) { + throw new Meteor.Error('not-authorized', 'You do not have permission to access this board'); + } + + try { + let query = { 'meta.boardId': boardId }; + + if (swimlaneId) { + query['meta.swimlaneId'] = swimlaneId; + } + + if (listId) { + query['meta.listId'] = listId; + } + + if (cardId) { + query['meta.cardId'] = cardId; + } + + const attachments = ReactiveCache.getAttachments(query); + + const attachmentList = attachments.map(attachment => { + const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original'); + return { + attachmentId: attachment._id, + fileName: attachment.name, + fileSize: attachment.size, + fileType: attachment.type, + storageBackend: strategy.getStorageName(), + boardId: attachment.meta.boardId, + swimlaneId: attachment.meta.swimlaneId, + listId: attachment.meta.listId, + cardId: attachment.meta.cardId, + createdAt: attachment.uploadedAt, + isImage: attachment.isImage + }; + }); + + return { + success: true, + attachments: attachmentList, + count: attachmentList.length + }; + } catch (error) { + console.error('API attachment list error:', error); + throw new Meteor.Error('list-error', error.message); + } + }, + + // Copy attachment to another card + 'api.attachment.copy'(attachmentId, targetBoardId, targetSwimlaneId, targetListId, targetCardId) { + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + + // Get source attachment + const sourceAttachment = ReactiveCache.getAttachment(attachmentId); + if (!sourceAttachment) { + throw new Meteor.Error('attachment-not-found', 'Source attachment not found'); + } + + // Check source permissions + const sourceBoard = ReactiveCache.getBoard(sourceAttachment.meta.boardId); + if (!sourceBoard || !sourceBoard.isBoardMember(this.userId)) { + throw new Meteor.Error('not-authorized', 'You do not have permission to access the source attachment'); + } + + // Check target permissions + const targetBoard = ReactiveCache.getBoard(targetBoardId); + if (!targetBoard || !targetBoard.isBoardMember(this.userId)) { + throw new Meteor.Error('not-authorized', 'You do not have permission to modify the target card'); + } + + // Check if target board allows attachments + if (!targetBoard.allowsAttachments) { + throw new Meteor.Error('attachments-not-allowed', 'Attachments are not allowed on the target board'); + } + + try { + // Get source file strategy + const sourceStrategy = fileStoreStrategyFactory.getFileStrategy(sourceAttachment, 'original'); + const readStream = sourceStrategy.getReadStream(); + + if (!readStream) { + throw new Meteor.Error('file-not-found', 'Source file not found in storage'); + } + + // Read source file data + const chunks = []; + return new Promise((resolve, reject) => { + readStream.on('data', (chunk) => { + chunks.push(chunk); + }); + + readStream.on('end', () => { + try { + const fileBuffer = Buffer.concat(chunks); + const file = new File([fileBuffer], sourceAttachment.name, { type: sourceAttachment.type }); + + // Create new attachment metadata + const fileId = new ObjectID().toString(); + const meta = { + boardId: targetBoardId, + swimlaneId: targetSwimlaneId, + listId: targetListId, + cardId: targetCardId, + fileId: fileId, + source: 'api-copy', + copyFrom: attachmentId, + copyStorage: sourceStrategy.getStorageName() + }; + + // Create new attachment + const uploader = Attachments.insert({ + file: file, + meta: meta, + isBase64: false, + transport: 'http' + }); + + if (uploader) { + resolve({ + success: true, + sourceAttachmentId: attachmentId, + newAttachmentId: uploader._id, + fileName: sourceAttachment.name, + fileSize: sourceAttachment.size, + message: 'Attachment copied successfully' + }); + } else { + reject(new Meteor.Error('copy-failed', 'Failed to copy attachment')); + } + } catch (error) { + reject(new Meteor.Error('copy-error', error.message)); + } + }); + + readStream.on('error', (error) => { + reject(new Meteor.Error('copy-error', error.message)); + }); + }); + } catch (error) { + console.error('API attachment copy error:', error); + throw new Meteor.Error('copy-error', error.message); + } + }, + + // Move attachment to another card + 'api.attachment.move'(attachmentId, targetBoardId, targetSwimlaneId, targetListId, targetCardId) { + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + + // Get source attachment + const sourceAttachment = ReactiveCache.getAttachment(attachmentId); + if (!sourceAttachment) { + throw new Meteor.Error('attachment-not-found', 'Source attachment not found'); + } + + // Check source permissions + const sourceBoard = ReactiveCache.getBoard(sourceAttachment.meta.boardId); + if (!sourceBoard || !sourceBoard.isBoardMember(this.userId)) { + throw new Meteor.Error('not-authorized', 'You do not have permission to access the source attachment'); + } + + // Check target permissions + const targetBoard = ReactiveCache.getBoard(targetBoardId); + if (!targetBoard || !targetBoard.isBoardMember(this.userId)) { + throw new Meteor.Error('not-authorized', 'You do not have permission to modify the target card'); + } + + // Check if target board allows attachments + if (!targetBoard.allowsAttachments) { + throw new Meteor.Error('attachments-not-allowed', 'Attachments are not allowed on the target board'); + } + + try { + // Update attachment metadata + Attachments.update(attachmentId, { + $set: { + 'meta.boardId': targetBoardId, + 'meta.swimlaneId': targetSwimlaneId, + 'meta.listId': targetListId, + 'meta.cardId': targetCardId, + 'meta.source': 'api-move', + 'meta.movedAt': new Date() + } + }); + + return { + success: true, + attachmentId: attachmentId, + fileName: sourceAttachment.name, + fileSize: sourceAttachment.size, + sourceBoardId: sourceAttachment.meta.boardId, + targetBoardId: targetBoardId, + message: 'Attachment moved successfully' + }; + } catch (error) { + console.error('API attachment move error:', error); + throw new Meteor.Error('move-error', error.message); + } + }, + + // Delete attachment via API + 'api.attachment.delete'(attachmentId) { + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + + // Get attachment + const attachment = ReactiveCache.getAttachment(attachmentId); + if (!attachment) { + throw new Meteor.Error('attachment-not-found', 'Attachment not found'); + } + + // Check permissions + const board = ReactiveCache.getBoard(attachment.meta.boardId); + if (!board || !board.isBoardMember(this.userId)) { + throw new Meteor.Error('not-authorized', 'You do not have permission to delete this attachment'); + } + + try { + // Delete attachment + Attachments.remove(attachmentId); + + return { + success: true, + attachmentId: attachmentId, + fileName: attachment.name, + message: 'Attachment deleted successfully' + }; + } catch (error) { + console.error('API attachment delete error:', error); + throw new Meteor.Error('delete-error', error.message); + } + }, + + // Get attachment info via API + 'api.attachment.info'(attachmentId) { + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + + // Get attachment + const attachment = ReactiveCache.getAttachment(attachmentId); + if (!attachment) { + throw new Meteor.Error('attachment-not-found', 'Attachment not found'); + } + + // Check permissions + const board = ReactiveCache.getBoard(attachment.meta.boardId); + if (!board || !board.isBoardMember(this.userId)) { + throw new Meteor.Error('not-authorized', 'You do not have permission to access this attachment'); + } + + try { + const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original'); + + return { + success: true, + attachmentId: attachment._id, + fileName: attachment.name, + fileSize: attachment.size, + fileType: attachment.type, + storageBackend: strategy.getStorageName(), + boardId: attachment.meta.boardId, + swimlaneId: attachment.meta.swimlaneId, + listId: attachment.meta.listId, + cardId: attachment.meta.cardId, + createdAt: attachment.uploadedAt, + isImage: attachment.isImage, + versions: Object.keys(attachment.versions).map(versionName => ({ + versionName: versionName, + storage: attachment.versions[versionName].storage, + size: attachment.versions[versionName].size, + type: attachment.versions[versionName].type + })) + }; + } catch (error) { + console.error('API attachment info error:', error); + throw new Meteor.Error('info-error', error.message); + } + } + }); +} diff --git a/server/routes/attachmentApi.js b/server/routes/attachmentApi.js new file mode 100644 index 000000000..51f18082c --- /dev/null +++ b/server/routes/attachmentApi.js @@ -0,0 +1,553 @@ +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; +import { ReactiveCache } from '/imports/reactiveCache'; +import { Attachments, fileStoreStrategyFactory } from '/models/attachments'; +import { moveToStorage } from '/models/lib/fileStoreStrategy'; +import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy'; +import AttachmentStorageSettings from '/models/attachmentStorageSettings'; +import fs from 'fs'; +import path from 'path'; +import { ObjectID } from 'bson'; + +// Attachment API HTTP routes +if (Meteor.isServer) { + // Helper function to authenticate API requests + function authenticateApiRequest(req) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new Meteor.Error('unauthorized', 'Missing or invalid authorization header'); + } + + const token = authHeader.substring(7); + // Here you would validate the token and get the user ID + // For now, we'll use a simple approach - in production, you'd want proper JWT validation + const userId = token; // This should be replaced with proper token validation + + if (!userId) { + throw new Meteor.Error('unauthorized', 'Invalid token'); + } + + return userId; + } + + // Helper function to send JSON response + function sendJsonResponse(res, statusCode, data) { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); + } + + // Helper function to send error response + function sendErrorResponse(res, statusCode, message) { + sendJsonResponse(res, statusCode, { success: false, error: message }); + } + + // Upload attachment endpoint + WebApp.connectHandlers.use('/api/attachment/upload', (req, res, next) => { + if (req.method !== 'POST') { + return next(); + } + + try { + const userId = authenticateApiRequest(req); + + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + + req.on('end', () => { + try { + const data = JSON.parse(body); + const { boardId, swimlaneId, listId, cardId, fileData, fileName, fileType, storageBackend } = data; + + // Validate parameters + if (!boardId || !swimlaneId || !listId || !cardId || !fileData || !fileName) { + return sendErrorResponse(res, 400, 'Missing required parameters'); + } + + // Check if user has permission to modify the card + const card = ReactiveCache.getCard(cardId); + if (!card) { + return sendErrorResponse(res, 404, 'Card not found'); + } + + const board = ReactiveCache.getBoard(boardId); + if (!board) { + return sendErrorResponse(res, 404, 'Board not found'); + } + + // Check permissions + if (!board.isBoardMember(userId)) { + return sendErrorResponse(res, 403, 'You do not have permission to modify this card'); + } + + // Check if board allows attachments + if (!board.allowsAttachments) { + return sendErrorResponse(res, 403, 'Attachments are not allowed on this board'); + } + + // Get default storage backend if not specified + let targetStorage = storageBackend; + if (!targetStorage) { + try { + const settings = AttachmentStorageSettings.findOne({}); + targetStorage = settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM; + } catch (error) { + targetStorage = STORAGE_NAME_FILESYSTEM; + } + } + + // Validate storage backend + if (![STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3].includes(targetStorage)) { + return sendErrorResponse(res, 400, 'Invalid storage backend'); + } + + // Create file object from base64 data + const fileBuffer = Buffer.from(fileData, 'base64'); + const file = new File([fileBuffer], fileName, { type: fileType || 'application/octet-stream' }); + + // Create attachment metadata + const fileId = new ObjectID().toString(); + const meta = { + boardId: boardId, + swimlaneId: swimlaneId, + listId: listId, + cardId: cardId, + fileId: fileId, + source: 'api', + storageBackend: targetStorage + }; + + // Create attachment + const uploader = Attachments.insert({ + file: file, + meta: meta, + isBase64: false, + transport: 'http' + }); + + if (uploader) { + // Move to target storage if not filesystem + if (targetStorage !== STORAGE_NAME_FILESYSTEM) { + Meteor.defer(() => { + try { + moveToStorage(uploader, targetStorage, fileStoreStrategyFactory); + } catch (error) { + console.error('Error moving attachment to target storage:', error); + } + }); + } + + sendJsonResponse(res, 200, { + success: true, + attachmentId: uploader._id, + fileName: fileName, + fileSize: fileBuffer.length, + storageBackend: targetStorage, + message: 'Attachment uploaded successfully' + }); + } else { + sendErrorResponse(res, 500, 'Failed to upload attachment'); + } + } catch (error) { + console.error('API attachment upload error:', error); + sendErrorResponse(res, 500, error.message); + } + }); + } catch (error) { + sendErrorResponse(res, 401, error.message); + } + }); + + // Download attachment endpoint + WebApp.connectHandlers.use('/api/attachment/download/([^/]+)', (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + try { + const userId = authenticateApiRequest(req); + const attachmentId = req.params[0]; + + // Get attachment + const attachment = ReactiveCache.getAttachment(attachmentId); + if (!attachment) { + return sendErrorResponse(res, 404, 'Attachment not found'); + } + + // Check permissions + const board = ReactiveCache.getBoard(attachment.meta.boardId); + if (!board || !board.isBoardMember(userId)) { + return sendErrorResponse(res, 403, 'You do not have permission to access this attachment'); + } + + // Get file strategy + const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original'); + const readStream = strategy.getReadStream(); + + if (!readStream) { + return sendErrorResponse(res, 404, 'File not found in storage'); + } + + // Read file data + const chunks = []; + readStream.on('data', (chunk) => { + chunks.push(chunk); + }); + + readStream.on('end', () => { + const fileBuffer = Buffer.concat(chunks); + const base64Data = fileBuffer.toString('base64'); + + sendJsonResponse(res, 200, { + success: true, + attachmentId: attachmentId, + fileName: attachment.name, + fileSize: attachment.size, + fileType: attachment.type, + base64Data: base64Data, + storageBackend: strategy.getStorageName() + }); + }); + + readStream.on('error', (error) => { + console.error('Download error:', error); + sendErrorResponse(res, 500, error.message); + }); + } catch (error) { + sendErrorResponse(res, 401, error.message); + } + }); + + // List attachments endpoint + WebApp.connectHandlers.use('/api/attachment/list/([^/]+)/([^/]+)/([^/]+)/([^/]+)', (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + try { + const userId = authenticateApiRequest(req); + const boardId = req.params[0]; + const swimlaneId = req.params[1]; + const listId = req.params[2]; + const cardId = req.params[3]; + + // Check permissions + const board = ReactiveCache.getBoard(boardId); + if (!board || !board.isBoardMember(userId)) { + return sendErrorResponse(res, 403, 'You do not have permission to access this board'); + } + + let query = { 'meta.boardId': boardId }; + + if (swimlaneId && swimlaneId !== 'null') { + query['meta.swimlaneId'] = swimlaneId; + } + + if (listId && listId !== 'null') { + query['meta.listId'] = listId; + } + + if (cardId && cardId !== 'null') { + query['meta.cardId'] = cardId; + } + + const attachments = ReactiveCache.getAttachments(query); + + const attachmentList = attachments.map(attachment => { + const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original'); + return { + attachmentId: attachment._id, + fileName: attachment.name, + fileSize: attachment.size, + fileType: attachment.type, + storageBackend: strategy.getStorageName(), + boardId: attachment.meta.boardId, + swimlaneId: attachment.meta.swimlaneId, + listId: attachment.meta.listId, + cardId: attachment.meta.cardId, + createdAt: attachment.uploadedAt, + isImage: attachment.isImage + }; + }); + + sendJsonResponse(res, 200, { + success: true, + attachments: attachmentList, + count: attachmentList.length + }); + } catch (error) { + sendErrorResponse(res, 401, error.message); + } + }); + + // Copy attachment endpoint + WebApp.connectHandlers.use('/api/attachment/copy', (req, res, next) => { + if (req.method !== 'POST') { + return next(); + } + + try { + const userId = authenticateApiRequest(req); + + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + + req.on('end', () => { + try { + const data = JSON.parse(body); + const { attachmentId, targetBoardId, targetSwimlaneId, targetListId, targetCardId } = data; + + // Get source attachment + const sourceAttachment = ReactiveCache.getAttachment(attachmentId); + if (!sourceAttachment) { + return sendErrorResponse(res, 404, 'Source attachment not found'); + } + + // Check source permissions + const sourceBoard = ReactiveCache.getBoard(sourceAttachment.meta.boardId); + if (!sourceBoard || !sourceBoard.isBoardMember(userId)) { + return sendErrorResponse(res, 403, 'You do not have permission to access the source attachment'); + } + + // Check target permissions + const targetBoard = ReactiveCache.getBoard(targetBoardId); + if (!targetBoard || !targetBoard.isBoardMember(userId)) { + return sendErrorResponse(res, 403, 'You do not have permission to modify the target card'); + } + + // Check if target board allows attachments + if (!targetBoard.allowsAttachments) { + return sendErrorResponse(res, 403, 'Attachments are not allowed on the target board'); + } + + // Get source file strategy + const sourceStrategy = fileStoreStrategyFactory.getFileStrategy(sourceAttachment, 'original'); + const readStream = sourceStrategy.getReadStream(); + + if (!readStream) { + return sendErrorResponse(res, 404, 'Source file not found in storage'); + } + + // Read source file data + const chunks = []; + readStream.on('data', (chunk) => { + chunks.push(chunk); + }); + + readStream.on('end', () => { + try { + const fileBuffer = Buffer.concat(chunks); + const file = new File([fileBuffer], sourceAttachment.name, { type: sourceAttachment.type }); + + // Create new attachment metadata + const fileId = new ObjectID().toString(); + const meta = { + boardId: targetBoardId, + swimlaneId: targetSwimlaneId, + listId: targetListId, + cardId: targetCardId, + fileId: fileId, + source: 'api-copy', + copyFrom: attachmentId, + copyStorage: sourceStrategy.getStorageName() + }; + + // Create new attachment + const uploader = Attachments.insert({ + file: file, + meta: meta, + isBase64: false, + transport: 'http' + }); + + if (uploader) { + sendJsonResponse(res, 200, { + success: true, + sourceAttachmentId: attachmentId, + newAttachmentId: uploader._id, + fileName: sourceAttachment.name, + fileSize: sourceAttachment.size, + message: 'Attachment copied successfully' + }); + } else { + sendErrorResponse(res, 500, 'Failed to copy attachment'); + } + } catch (error) { + sendErrorResponse(res, 500, error.message); + } + }); + + readStream.on('error', (error) => { + sendErrorResponse(res, 500, error.message); + }); + } catch (error) { + console.error('API attachment copy error:', error); + sendErrorResponse(res, 500, error.message); + } + }); + } catch (error) { + sendErrorResponse(res, 401, error.message); + } + }); + + // Move attachment endpoint + WebApp.connectHandlers.use('/api/attachment/move', (req, res, next) => { + if (req.method !== 'POST') { + return next(); + } + + try { + const userId = authenticateApiRequest(req); + + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + + req.on('end', () => { + try { + const data = JSON.parse(body); + const { attachmentId, targetBoardId, targetSwimlaneId, targetListId, targetCardId } = data; + + // Get source attachment + const sourceAttachment = ReactiveCache.getAttachment(attachmentId); + if (!sourceAttachment) { + return sendErrorResponse(res, 404, 'Source attachment not found'); + } + + // Check source permissions + const sourceBoard = ReactiveCache.getBoard(sourceAttachment.meta.boardId); + if (!sourceBoard || !sourceBoard.isBoardMember(userId)) { + return sendErrorResponse(res, 403, 'You do not have permission to access the source attachment'); + } + + // Check target permissions + const targetBoard = ReactiveCache.getBoard(targetBoardId); + if (!targetBoard || !targetBoard.isBoardMember(userId)) { + return sendErrorResponse(res, 403, 'You do not have permission to modify the target card'); + } + + // Check if target board allows attachments + if (!targetBoard.allowsAttachments) { + return sendErrorResponse(res, 403, 'Attachments are not allowed on the target board'); + } + + // Update attachment metadata + Attachments.update(attachmentId, { + $set: { + 'meta.boardId': targetBoardId, + 'meta.swimlaneId': targetSwimlaneId, + 'meta.listId': targetListId, + 'meta.cardId': targetCardId, + 'meta.source': 'api-move', + 'meta.movedAt': new Date() + } + }); + + sendJsonResponse(res, 200, { + success: true, + attachmentId: attachmentId, + fileName: sourceAttachment.name, + fileSize: sourceAttachment.size, + sourceBoardId: sourceAttachment.meta.boardId, + targetBoardId: targetBoardId, + message: 'Attachment moved successfully' + }); + } catch (error) { + console.error('API attachment move error:', error); + sendErrorResponse(res, 500, error.message); + } + }); + } catch (error) { + sendErrorResponse(res, 401, error.message); + } + }); + + // Delete attachment endpoint + WebApp.connectHandlers.use('/api/attachment/delete/([^/]+)', (req, res, next) => { + if (req.method !== 'DELETE') { + return next(); + } + + try { + const userId = authenticateApiRequest(req); + const attachmentId = req.params[0]; + + // Get attachment + const attachment = ReactiveCache.getAttachment(attachmentId); + if (!attachment) { + return sendErrorResponse(res, 404, 'Attachment not found'); + } + + // Check permissions + const board = ReactiveCache.getBoard(attachment.meta.boardId); + if (!board || !board.isBoardMember(userId)) { + return sendErrorResponse(res, 403, 'You do not have permission to delete this attachment'); + } + + // Delete attachment + Attachments.remove(attachmentId); + + sendJsonResponse(res, 200, { + success: true, + attachmentId: attachmentId, + fileName: attachment.name, + message: 'Attachment deleted successfully' + }); + } catch (error) { + sendErrorResponse(res, 401, error.message); + } + }); + + // Get attachment info endpoint + WebApp.connectHandlers.use('/api/attachment/info/([^/]+)', (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + try { + const userId = authenticateApiRequest(req); + const attachmentId = req.params[0]; + + // Get attachment + const attachment = ReactiveCache.getAttachment(attachmentId); + if (!attachment) { + return sendErrorResponse(res, 404, 'Attachment not found'); + } + + // Check permissions + const board = ReactiveCache.getBoard(attachment.meta.boardId); + if (!board || !board.isBoardMember(userId)) { + return sendErrorResponse(res, 403, 'You do not have permission to access this attachment'); + } + + const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original'); + + sendJsonResponse(res, 200, { + success: true, + attachmentId: attachment._id, + fileName: attachment.name, + fileSize: attachment.size, + fileType: attachment.type, + storageBackend: strategy.getStorageName(), + boardId: attachment.meta.boardId, + swimlaneId: attachment.meta.swimlaneId, + listId: attachment.meta.listId, + cardId: attachment.meta.cardId, + createdAt: attachment.uploadedAt, + isImage: attachment.isImage, + versions: Object.keys(attachment.versions).map(versionName => ({ + versionName: versionName, + storage: attachment.versions[versionName].storage, + size: attachment.versions[versionName].size, + type: attachment.versions[versionName].type + })) + }); + } catch (error) { + sendErrorResponse(res, 401, error.message); + } + }); +}