Browse Source

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 4 days ago
parent
commit
ae1f80a52c
5 changed files with 1701 additions and 3 deletions
  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);
+    }
+  });
+}