2023-01-14 13:29:57 +01:00
import { ReactiveCache } from '/imports/reactiveCache' ;
2020-09-13 19:15:45 -05:00
import { Meteor } from 'meteor/meteor' ;
import { FilesCollection } from 'meteor/ostrio:files' ;
2022-08-19 14:30:22 +02:00
import { isFileValid } from './fileValidation' ;
2022-04-22 01:19:43 +03:00
import { createBucket } from './lib/grid/createBucket' ;
import fs from 'fs' ;
2022-01-30 15:26:11 +03:00
import path from 'path' ;
2023-01-16 15:11:31 +02:00
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' ;
2025-10-10 19:07:04 +03:00
import { getAttachmentWithBackwardCompatibility , getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility' ;
2025-10-11 11:05:46 +03:00
import AttachmentStorageSettings from './attachmentStorageSettings' ;
2020-09-13 19:15:45 -05:00
2022-08-15 21:09:46 +02:00
let attachmentUploadExternalProgram ;
2022-08-03 12:53:07 +02:00
let attachmentUploadMimeTypes = [ ] ;
let attachmentUploadSize = 0 ;
2020-09-16 14:39:30 -05:00
let attachmentBucket ;
2022-04-08 08:52:48 +02:00
let storagePath ;
2022-08-03 12:53:07 +02:00
2020-09-16 14:39:30 -05:00
if ( Meteor . isServer ) {
attachmentBucket = createBucket ( 'attachments' ) ;
2022-08-03 12:53:07 +02:00
if ( process . env . ATTACHMENTS _UPLOAD _MIME _TYPES ) {
attachmentUploadMimeTypes = process . env . ATTACHMENTS _UPLOAD _MIME _TYPES . split ( ',' ) ;
attachmentUploadMimeTypes = attachmentUploadMimeTypes . map ( value => value . trim ( ) ) ;
}
if ( process . env . ATTACHMENTS _UPLOAD _MAX _SIZE ) {
attachmentUploadSize = parseInt ( process . env . ATTACHMENTS _UPLOAD _MAX _SIZE ) ;
if ( isNaN ( attachmentUploadSize ) ) {
attachmentUploadSize = 0
}
}
2022-08-15 21:09:46 +02:00
if ( process . env . ATTACHMENTS _UPLOAD _EXTERNAL _PROGRAM ) {
attachmentUploadExternalProgram = process . env . ATTACHMENTS _UPLOAD _EXTERNAL _PROGRAM ;
if ( ! attachmentUploadExternalProgram . includes ( "{file}" ) ) {
attachmentUploadExternalProgram = undefined ;
}
}
2022-04-08 08:52:48 +02:00
storagePath = path . join ( process . env . WRITABLE _PATH , 'attachments' ) ;
2020-09-16 14:39:30 -05:00
}
2020-09-13 19:15:45 -05:00
2022-04-08 00:27:56 +02:00
export const fileStoreStrategyFactory = new FileStoreStrategyFactory ( AttachmentStoreStrategyFilesystem , storagePath , AttachmentStoreStrategyGridFs , attachmentBucket ) ;
2020-09-14 01:07:17 -05:00
2020-09-13 19:15:45 -05:00
// XXX Enforce a schema for the Attachments FilesCollection
// see: https://github.com/VeliovGroup/Meteor-Files/wiki/Schema
2020-09-16 18:40:11 -05:00
Attachments = new FilesCollection ( {
2020-09-13 19:15:45 -05:00
debug : false , // Change to `true` for debugging
collectionName : 'attachments' ,
2020-09-16 20:22:20 -05:00
allowClientCode : true ,
2022-04-01 23:09:59 +02:00
namingFunction ( opts ) {
2022-07-12 00:04:16 +02:00
let filenameWithoutExtension = ""
let fileId = "" ;
if ( opts ? . name ) {
// Client
filenameWithoutExtension = opts . name . replace ( /(.+)\..+/ , "$1" ) ;
fileId = opts . meta . fileId ;
delete opts . meta . fileId ;
} else if ( opts ? . file ? . name ) {
// Server
2022-08-18 21:12:33 +02:00
if ( opts . file . extension ) {
filenameWithoutExtension = opts . file . name . replace ( new RegExp ( opts . file . extensionWithDot + "$" ) , "" )
} else {
// file has no extension, so don't replace anything, otherwise the last character is removed (because extensionWithDot = '.')
filenameWithoutExtension = opts . file . name ;
}
2022-07-12 00:04:16 +02:00
fileId = opts . fileId ;
}
else {
// should never reach here
filenameWithoutExtension = Math . random ( ) . toString ( 36 ) . slice ( 2 ) ;
fileId = Math . random ( ) . toString ( 36 ) . slice ( 2 ) ;
}
2023-04-17 23:13:06 +03:00
// OLD:
//const ret = fileId + "-original-" + filenameWithoutExtension;
// NEW: Save file only with filename of ObjectID, not including filename.
// Fixes https://github.com/wekan/wekan/issues/4416#issuecomment-1510517168
const ret = fileId ;
2022-04-01 23:09:59 +02:00
// remove fileId from meta, it was only stored there to have this information here in the namingFunction function
return ret ;
} ,
2022-08-18 17:30:46 +02:00
sanitize ( str , max , replacement ) {
// keep the original filename
return str ;
} ,
2022-01-30 15:26:11 +03:00
storagePath ( ) {
2022-04-08 08:52:48 +02:00
const ret = fileStoreStrategyFactory . storagePath ;
2022-04-03 15:14:38 +02:00
return ret ;
2022-01-30 15:26:11 +03:00
} ,
2025-10-10 23:06:06 +03:00
onBeforeUpload ( file ) {
// Block SVG files for attachments to prevent XSS attacks
if ( file . name && file . name . toLowerCase ( ) . endsWith ( '.svg' ) ) {
if ( process . env . DEBUG === 'true' ) {
console . warn ( 'Blocked SVG file upload for attachment:' , file . name ) ;
}
return 'SVG files are not allowed for attachments due to security reasons. Please use PNG, JPG, GIF, or other safe formats.' ;
}
if ( file . type === 'image/svg+xml' ) {
if ( process . env . DEBUG === 'true' ) {
console . warn ( 'Blocked SVG MIME type upload for attachment:' , file . type ) ;
}
return 'SVG files are not allowed for attachments due to security reasons. Please use PNG, JPG, GIF, or other safe formats.' ;
}
return true ;
} ,
2022-03-25 14:08:37 +01:00
onAfterUpload ( fileObj ) {
2025-10-11 11:05:46 +03:00
// 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)
2022-03-25 14:08:37 +01:00
Object . keys ( fileObj . versions ) . forEach ( versionName => {
2022-04-04 00:08:07 +02:00
fileObj . versions [ versionName ] . storage = STORAGE _NAME _FILESYSTEM ;
2022-04-03 23:44:02 +02:00
} ) ;
2022-08-03 12:53:07 +02:00
2023-04-21 21:15:00 +03:00
this . _now = new Date ( ) ;
2022-04-03 23:44:02 +02:00
Attachments . update ( { _id : fileObj . _id } , { $set : { "versions" : fileObj . versions } } ) ;
2023-04-21 22:03:47 +03:00
Attachments . update ( { _id : fileObj . uploadedAtOstrio } , { $set : { "uploadedAtOstrio" : this . _now } } ) ;
2022-08-03 12:53:07 +02:00
2025-10-11 11:05:46 +03:00
// 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 ) ) ;
}
2020-09-13 19:15:45 -05:00
} ,
2022-03-25 14:08:37 +01:00
interceptDownload ( http , fileObj , versionName ) {
2022-04-07 23:06:16 +02:00
const ret = fileStoreStrategyFactory . getFileStrategy ( fileObj , versionName ) . interceptDownload ( http , this . cacheControl ) ;
2022-03-25 14:08:37 +01:00
return ret ;
2020-09-13 19:15:45 -05:00
} ,
2022-03-25 14:08:37 +01:00
onAfterRemove ( files ) {
2020-09-14 01:07:17 -05:00
files . forEach ( fileObj => {
2022-03-25 14:08:37 +01:00
Object . keys ( fileObj . versions ) . forEach ( versionName => {
2022-04-07 23:06:16 +02:00
fileStoreStrategyFactory . getFileStrategy ( fileObj , versionName ) . onAfterRemove ( ) ;
2022-03-25 14:08:37 +01:00
} ) ;
2020-09-13 19:15:45 -05:00
} ) ;
} ,
// We authorize the attachment download either:
// - if the board is public, everyone (even unconnected) can download it
// - if the board is private, only board members can download it
2020-09-14 01:07:17 -05:00
protected ( fileObj ) {
2022-08-03 12:53:07 +02:00
// file may have been deleted already again after upload validation failed
if ( ! fileObj ) {
return false ;
}
2023-01-14 13:29:57 +01:00
const board = ReactiveCache . getBoard ( fileObj . meta . boardId ) ;
2020-09-13 19:15:45 -05:00
if ( board . isPublic ( ) ) {
return true ;
}
2022-08-03 12:53:07 +02:00
2020-10-20 17:44:04 -05:00
return board . hasMember ( this . userId ) ;
2020-09-13 19:15:45 -05:00
} ,
} ) ;
if ( Meteor . isServer ) {
Attachments . allow ( {
2020-09-14 01:07:17 -05:00
insert ( userId , fileObj ) {
2023-01-14 13:29:57 +01:00
return allowIsBoardMember ( userId , ReactiveCache . getBoard ( fileObj . boardId ) ) ;
2020-09-13 19:15:45 -05:00
} ,
2025-10-10 22:59:20 +03:00
update ( userId , fileObj , fields ) {
// Only allow updates to specific fields that don't affect security
const allowedFields = [ 'name' , 'size' , 'type' , 'extension' , 'extensionWithDot' , 'meta' , 'versions' ] ;
const isAllowedField = fields . every ( field => allowedFields . includes ( field ) ) ;
if ( ! isAllowedField ) {
if ( process . env . DEBUG === 'true' ) {
console . warn ( 'Blocked attempt to update restricted attachment fields:' , fields ) ;
}
return false ;
}
2023-01-14 13:29:57 +01:00
return allowIsBoardMember ( userId , ReactiveCache . getBoard ( fileObj . boardId ) ) ;
2020-09-13 19:15:45 -05:00
} ,
2020-09-14 01:07:17 -05:00
remove ( userId , fileObj ) {
2025-10-10 22:59:20 +03:00
// Additional security check: ensure the file belongs to the board the user has access to
if ( ! fileObj || ! fileObj . boardId ) {
if ( process . env . DEBUG === 'true' ) {
console . warn ( 'Blocked attachment removal: file has no boardId' ) ;
}
return false ;
}
const board = ReactiveCache . getBoard ( fileObj . boardId ) ;
if ( ! board ) {
if ( process . env . DEBUG === 'true' ) {
console . warn ( 'Blocked attachment removal: board not found' ) ;
}
return false ;
}
return allowIsBoardMember ( userId , board ) ;
2020-09-13 19:15:45 -05:00
} ,
2025-10-10 22:59:20 +03:00
fetch : [ 'meta' , 'boardId' ] ,
2020-05-25 17:54:51 +03:00
} ) ;
2022-03-25 14:08:37 +01:00
Meteor . methods ( {
2025-10-10 22:16:47 +03:00
// Validate image URL to prevent SVG-based DoS attacks
validateImageUrl ( imageUrl ) {
check ( imageUrl , String ) ;
if ( ! imageUrl ) {
return { valid : false , reason : 'Empty URL' } ;
}
// Block SVG files and data URIs
if ( imageUrl . endsWith ( '.svg' ) || imageUrl . startsWith ( 'data:image/svg' ) ) {
if ( process . env . DEBUG === 'true' ) {
console . warn ( 'Blocked potentially malicious SVG image URL:' , imageUrl ) ;
}
return { valid : false , reason : 'SVG images are blocked for security reasons' } ;
}
// Block data URIs that could contain malicious content
if ( imageUrl . startsWith ( 'data:' ) ) {
if ( process . env . DEBUG === 'true' ) {
console . warn ( 'Blocked data URI image URL:' , imageUrl ) ;
}
return { valid : false , reason : 'Data URIs are blocked for security reasons' } ;
}
// Validate URL format
try {
const url = new URL ( imageUrl ) ;
// Only allow http and https protocols
if ( ! [ 'http:' , 'https:' ] . includes ( url . protocol ) ) {
return { valid : false , reason : 'Only HTTP and HTTPS protocols are allowed' } ;
}
} catch ( e ) {
return { valid : false , reason : 'Invalid URL format' } ;
}
return { valid : true } ;
} ,
2022-04-07 22:49:13 +02:00
moveAttachmentToStorage ( fileObjId , storageDestination ) {
2022-03-25 14:08:37 +01:00
check ( fileObjId , String ) ;
check ( storageDestination , String ) ;
2023-02-04 00:30:55 +01:00
const fileObj = ReactiveCache . getAttachment ( fileObjId ) ;
2022-04-07 22:49:13 +02:00
moveToStorage ( fileObj , storageDestination , fileStoreStrategyFactory ) ;
2022-03-25 14:08:37 +01:00
} ,
2022-04-24 17:12:31 +02:00
renameAttachment ( fileObjId , newName ) {
check ( fileObjId , String ) ;
check ( newName , String ) ;
2023-02-21 21:27:34 +02:00
2025-10-10 22:59:20 +03:00
const currentUserId = Meteor . userId ( ) ;
if ( ! currentUserId ) {
throw new Meteor . Error ( 'not-authorized' , 'User must be logged in' ) ;
}
2023-02-04 00:30:55 +01:00
const fileObj = ReactiveCache . getAttachment ( fileObjId ) ;
2025-10-10 22:59:20 +03:00
if ( ! fileObj ) {
throw new Meteor . Error ( 'file-not-found' , 'Attachment not found' ) ;
}
// Verify the user has permission to modify this attachment
const board = ReactiveCache . getBoard ( fileObj . boardId ) ;
if ( ! board ) {
throw new Meteor . Error ( 'board-not-found' , 'Board not found' ) ;
}
if ( ! allowIsBoardMember ( currentUserId , board ) ) {
if ( process . env . DEBUG === 'true' ) {
console . warn ( ` Blocked unauthorized attachment rename attempt: user ${ currentUserId } tried to rename attachment ${ fileObjId } in board ${ fileObj . boardId } ` ) ;
}
throw new Meteor . Error ( 'not-authorized' , 'You do not have permission to modify this attachment' ) ;
}
2023-02-21 21:27:34 +02:00
rename ( fileObj , newName , fileStoreStrategyFactory ) ;
2022-04-24 17:12:31 +02:00
} ,
2022-08-15 21:09:46 +02:00
validateAttachment ( fileObjId ) {
check ( fileObjId , String ) ;
2023-02-04 00:30:55 +01:00
const fileObj = ReactiveCache . getAttachment ( fileObjId ) ;
2022-08-19 14:30:22 +02:00
const isValid = Promise . await ( isFileValid ( fileObj , attachmentUploadMimeTypes , attachmentUploadSize , attachmentUploadExternalProgram ) ) ;
2022-08-15 21:09:46 +02:00
if ( ! isValid ) {
Attachments . remove ( fileObjId ) ;
}
} ,
validateAttachmentAndMoveToStorage ( fileObjId , storageDestination ) {
check ( fileObjId , String ) ;
check ( storageDestination , String ) ;
2022-08-19 14:30:22 +02:00
Meteor . call ( 'validateAttachment' , fileObjId ) ;
2022-08-15 21:09:46 +02:00
2023-02-04 00:30:55 +01:00
const fileObj = ReactiveCache . getAttachment ( fileObjId ) ;
2022-08-15 21:09:46 +02:00
if ( fileObj ) {
2022-08-16 22:27:22 +03:00
Meteor . defer ( ( ) => Meteor . call ( 'moveAttachmentToStorage' , fileObjId , storageDestination ) ) ;
2022-08-15 21:09:46 +02:00
}
} ,
2022-03-25 14:08:37 +01:00
} ) ;
2020-09-13 19:15:45 -05:00
Meteor . startup ( ( ) => {
2022-04-22 11:27:03 +02:00
Attachments . collection . createIndex ( { 'meta.cardId' : 1 } ) ;
const storagePath = fileStoreStrategyFactory . storagePath ;
if ( ! fs . existsSync ( storagePath ) ) {
console . log ( "create storagePath because it doesn't exist: " + storagePath ) ;
fs . mkdirSync ( storagePath , { recursive : true } ) ;
}
2019-08-10 00:48:05 -04:00
} ) ;
2025-10-10 19:07:04 +03:00
// Add backward compatibility methods
Attachments . getAttachmentWithBackwardCompatibility = getAttachmentWithBackwardCompatibility ;
Attachments . getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackwardCompatibility ;
2019-08-10 00:48:05 -04:00
}
2019-06-26 17:47:27 -05:00
export default Attachments ;