浏览代码

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 !
Lauri Ojansivu 5 天之前
父节点
当前提交
ae1f80a52c
共有 5 个文件被更改,包括 1701 次插入3 次删除
  1. 276 0
      api.py
  2. 384 0
      models/attachmentStorageSettings.js
  3. 20 3
      models/attachments.js
  4. 468 0
      server/attachmentApi.js
  5. 553 0
      server/routes/attachmentApi.js

+ 276 - 0
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 -----------

+ 384 - 0
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;

+ 20 - 3
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);

+ 468 - 0
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);
+      }
+    }
+  });
+}

+ 553 - 0
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);
+    }
+  });
+}