mirror of
https://github.com/wekan/wekan.git
synced 2025-12-16 15:30:13 +01:00
Added attachments API and admin panel attachment management for file storage backends settings. Fixed drag drop upload attachments from file manager to minicard or opened card.
Thanks to xet7 !
This commit is contained in:
parent
2f95431c9b
commit
ae1f80a52c
5 changed files with 1701 additions and 3 deletions
276
api.py
276
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 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 editcustomfield BOARDID LISTID CARDID CUSTOMFIELDID NEWCUSTOMFIELDVALUE # Edit Custom Field
|
||||||
python3 api.py listattachments BOARDID # List attachments
|
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 cardsbyswimlane SWIMLANEID LISTID # Retrieve cards list on a swimlane
|
||||||
python3 api.py getcard BOARDID LISTID CARDID # Get card info
|
python3 api.py getcard BOARDID LISTID CARDID # Get card info
|
||||||
python3 api.py addlabel BOARDID LISTID CARDID LABELID # Add label to a card
|
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")
|
data2 = body.text.replace('}',"}\n")
|
||||||
print(data2)
|
print(data2)
|
||||||
# ------- LIST OF PUBLIC BOARDS END -----------
|
# ------- 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
models/attachmentStorageSettings.js
Normal file
384
models/attachmentStorageSettings.js
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
import { ReactiveCache } from '/imports/reactiveCache';
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { SimpleSchema } from 'meteor/aldeed:simple-schema';
|
||||||
|
import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
|
||||||
|
|
||||||
|
// Attachment Storage Settings Collection
|
||||||
|
AttachmentStorageSettings = new Mongo.Collection('attachmentStorageSettings');
|
||||||
|
|
||||||
|
// Schema for attachment storage settings
|
||||||
|
AttachmentStorageSettings.attachSchema(
|
||||||
|
new SimpleSchema({
|
||||||
|
// Default storage backend for new uploads
|
||||||
|
defaultStorage: {
|
||||||
|
type: String,
|
||||||
|
allowedValues: [STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3],
|
||||||
|
defaultValue: STORAGE_NAME_FILESYSTEM,
|
||||||
|
label: 'Default Storage Backend'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Storage backend configuration
|
||||||
|
storageConfig: {
|
||||||
|
type: Object,
|
||||||
|
optional: true,
|
||||||
|
label: 'Storage Configuration'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.filesystem': {
|
||||||
|
type: Object,
|
||||||
|
optional: true,
|
||||||
|
label: 'Filesystem Configuration'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.filesystem.enabled': {
|
||||||
|
type: Boolean,
|
||||||
|
defaultValue: true,
|
||||||
|
label: 'Filesystem Storage Enabled'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.filesystem.path': {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
label: 'Filesystem Storage Path'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.gridfs': {
|
||||||
|
type: Object,
|
||||||
|
optional: true,
|
||||||
|
label: 'GridFS Configuration'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.gridfs.enabled': {
|
||||||
|
type: Boolean,
|
||||||
|
defaultValue: true,
|
||||||
|
label: 'GridFS Storage Enabled'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.s3': {
|
||||||
|
type: Object,
|
||||||
|
optional: true,
|
||||||
|
label: 'S3 Configuration'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.s3.enabled': {
|
||||||
|
type: Boolean,
|
||||||
|
defaultValue: false,
|
||||||
|
label: 'S3 Storage Enabled'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.s3.endpoint': {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
label: 'S3 Endpoint'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.s3.bucket': {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
label: 'S3 Bucket'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.s3.region': {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
label: 'S3 Region'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.s3.sslEnabled': {
|
||||||
|
type: Boolean,
|
||||||
|
defaultValue: true,
|
||||||
|
label: 'S3 SSL Enabled'
|
||||||
|
},
|
||||||
|
|
||||||
|
'storageConfig.s3.port': {
|
||||||
|
type: Number,
|
||||||
|
defaultValue: 443,
|
||||||
|
label: 'S3 Port'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload settings
|
||||||
|
uploadSettings: {
|
||||||
|
type: Object,
|
||||||
|
optional: true,
|
||||||
|
label: 'Upload Settings'
|
||||||
|
},
|
||||||
|
|
||||||
|
'uploadSettings.maxFileSize': {
|
||||||
|
type: Number,
|
||||||
|
optional: true,
|
||||||
|
label: 'Maximum File Size (bytes)'
|
||||||
|
},
|
||||||
|
|
||||||
|
'uploadSettings.allowedMimeTypes': {
|
||||||
|
type: Array,
|
||||||
|
optional: true,
|
||||||
|
label: 'Allowed MIME Types'
|
||||||
|
},
|
||||||
|
|
||||||
|
'uploadSettings.allowedMimeTypes.$': {
|
||||||
|
type: String,
|
||||||
|
label: 'MIME Type'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Migration settings
|
||||||
|
migrationSettings: {
|
||||||
|
type: Object,
|
||||||
|
optional: true,
|
||||||
|
label: 'Migration Settings'
|
||||||
|
},
|
||||||
|
|
||||||
|
'migrationSettings.autoMigrate': {
|
||||||
|
type: Boolean,
|
||||||
|
defaultValue: false,
|
||||||
|
label: 'Auto Migrate to Default Storage'
|
||||||
|
},
|
||||||
|
|
||||||
|
'migrationSettings.batchSize': {
|
||||||
|
type: Number,
|
||||||
|
defaultValue: 10,
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
label: 'Migration Batch Size'
|
||||||
|
},
|
||||||
|
|
||||||
|
'migrationSettings.delayMs': {
|
||||||
|
type: Number,
|
||||||
|
defaultValue: 1000,
|
||||||
|
min: 100,
|
||||||
|
max: 10000,
|
||||||
|
label: 'Migration Delay (ms)'
|
||||||
|
},
|
||||||
|
|
||||||
|
'migrationSettings.cpuThreshold': {
|
||||||
|
type: Number,
|
||||||
|
defaultValue: 70,
|
||||||
|
min: 10,
|
||||||
|
max: 90,
|
||||||
|
label: 'CPU Threshold (%)'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
autoValue() {
|
||||||
|
if (this.isInsert) {
|
||||||
|
return new Date();
|
||||||
|
} else if (this.isUpsert) {
|
||||||
|
return { $setOnInsert: new Date() };
|
||||||
|
} else {
|
||||||
|
this.unset();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label: 'Created At'
|
||||||
|
},
|
||||||
|
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
autoValue() {
|
||||||
|
if (this.isUpdate || this.isUpsert) {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label: 'Updated At'
|
||||||
|
},
|
||||||
|
|
||||||
|
createdBy: {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
label: 'Created By'
|
||||||
|
},
|
||||||
|
|
||||||
|
updatedBy: {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
label: 'Updated By'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
AttachmentStorageSettings.helpers({
|
||||||
|
// Get default storage backend
|
||||||
|
getDefaultStorage() {
|
||||||
|
return this.defaultStorage || STORAGE_NAME_FILESYSTEM;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if storage backend is enabled
|
||||||
|
isStorageEnabled(storageName) {
|
||||||
|
if (!this.storageConfig) return false;
|
||||||
|
|
||||||
|
switch (storageName) {
|
||||||
|
case STORAGE_NAME_FILESYSTEM:
|
||||||
|
return this.storageConfig.filesystem?.enabled !== false;
|
||||||
|
case STORAGE_NAME_GRIDFS:
|
||||||
|
return this.storageConfig.gridfs?.enabled !== false;
|
||||||
|
case STORAGE_NAME_S3:
|
||||||
|
return this.storageConfig.s3?.enabled === true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get storage configuration
|
||||||
|
getStorageConfig(storageName) {
|
||||||
|
if (!this.storageConfig) return null;
|
||||||
|
|
||||||
|
switch (storageName) {
|
||||||
|
case STORAGE_NAME_FILESYSTEM:
|
||||||
|
return this.storageConfig.filesystem;
|
||||||
|
case STORAGE_NAME_GRIDFS:
|
||||||
|
return this.storageConfig.gridfs;
|
||||||
|
case STORAGE_NAME_S3:
|
||||||
|
return this.storageConfig.s3;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get upload settings
|
||||||
|
getUploadSettings() {
|
||||||
|
return this.uploadSettings || {};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get migration settings
|
||||||
|
getMigrationSettings() {
|
||||||
|
return this.migrationSettings || {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server-side methods
|
||||||
|
if (Meteor.isServer) {
|
||||||
|
// Get or create default settings
|
||||||
|
Meteor.methods({
|
||||||
|
'getAttachmentStorageSettings'() {
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = ReactiveCache.getUser(this.userId);
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = AttachmentStorageSettings.findOne({});
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
// Create default settings
|
||||||
|
settings = {
|
||||||
|
defaultStorage: STORAGE_NAME_FILESYSTEM,
|
||||||
|
storageConfig: {
|
||||||
|
filesystem: {
|
||||||
|
enabled: true,
|
||||||
|
path: process.env.WRITABLE_PATH ? `${process.env.WRITABLE_PATH}/attachments` : '/data/attachments'
|
||||||
|
},
|
||||||
|
gridfs: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
s3: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
uploadSettings: {
|
||||||
|
maxFileSize: process.env.ATTACHMENTS_UPLOAD_MAX_SIZE ? parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) : 0,
|
||||||
|
allowedMimeTypes: process.env.ATTACHMENTS_UPLOAD_MIME_TYPES ? process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',').map(t => t.trim()) : []
|
||||||
|
},
|
||||||
|
migrationSettings: {
|
||||||
|
autoMigrate: false,
|
||||||
|
batchSize: 10,
|
||||||
|
delayMs: 1000,
|
||||||
|
cpuThreshold: 70
|
||||||
|
},
|
||||||
|
createdBy: this.userId,
|
||||||
|
updatedBy: this.userId
|
||||||
|
};
|
||||||
|
|
||||||
|
AttachmentStorageSettings.insert(settings);
|
||||||
|
settings = AttachmentStorageSettings.findOne({});
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
},
|
||||||
|
|
||||||
|
'updateAttachmentStorageSettings'(settings) {
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = ReactiveCache.getUser(this.userId);
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate settings
|
||||||
|
const schema = AttachmentStorageSettings.simpleSchema();
|
||||||
|
schema.validate(settings);
|
||||||
|
|
||||||
|
// Update settings
|
||||||
|
const result = AttachmentStorageSettings.upsert(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
...settings,
|
||||||
|
updatedBy: this.userId,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
'getDefaultAttachmentStorage'() {
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = AttachmentStorageSettings.findOne({});
|
||||||
|
return settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM;
|
||||||
|
},
|
||||||
|
|
||||||
|
'setDefaultAttachmentStorage'(storageName) {
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = ReactiveCache.getUser(this.userId);
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (![STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3].includes(storageName)) {
|
||||||
|
throw new Meteor.Error('invalid-storage', 'Invalid storage backend');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = AttachmentStorageSettings.upsert(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
defaultStorage: storageName,
|
||||||
|
updatedBy: this.userId,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publication for settings
|
||||||
|
Meteor.publish('attachmentStorageSettings', function() {
|
||||||
|
if (!this.userId) {
|
||||||
|
return this.ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = ReactiveCache.getUser(this.userId);
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
return this.ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttachmentStorageSettings.find({});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttachmentStorageSettings;
|
||||||
|
|
@ -8,6 +8,7 @@ import path from 'path';
|
||||||
import { AttachmentStoreStrategyFilesystem, AttachmentStoreStrategyGridFs, AttachmentStoreStrategyS3 } from '/models/lib/attachmentStoreStrategy';
|
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 FileStoreStrategyFactory, {moveToStorage, rename, STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3} from '/models/lib/fileStoreStrategy';
|
||||||
import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
|
import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
|
||||||
|
import AttachmentStorageSettings from './attachmentStorageSettings';
|
||||||
|
|
||||||
let attachmentUploadExternalProgram;
|
let attachmentUploadExternalProgram;
|
||||||
let attachmentUploadMimeTypes = [];
|
let attachmentUploadMimeTypes = [];
|
||||||
|
|
@ -110,7 +111,18 @@ Attachments = new FilesCollection({
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onAfterUpload(fileObj) {
|
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 => {
|
Object.keys(fileObj.versions).forEach(versionName => {
|
||||||
fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
|
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._id }, { $set: { "versions" : fileObj.versions } });
|
||||||
Attachments.update({ _id: fileObj.uploadedAtOstrio }, { $set: { "uploadedAtOstrio" : this._now } });
|
Attachments.update({ _id: fileObj.uploadedAtOstrio }, { $set: { "uploadedAtOstrio" : this._now } });
|
||||||
|
|
||||||
let storageDestination = fileObj.meta.copyStorage || STORAGE_NAME_GRIDFS;
|
// 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));
|
Meteor.defer(() => Meteor.call('validateAttachmentAndMoveToStorage', fileObj._id, storageDestination));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
interceptDownload(http, fileObj, versionName) {
|
interceptDownload(http, fileObj, versionName) {
|
||||||
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
|
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
|
||||||
|
|
|
||||||
468
server/attachmentApi.js
Normal file
468
server/attachmentApi.js
Normal file
|
|
@ -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
server/routes/attachmentApi.js
Normal file
553
server/routes/attachmentApi.js
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue