attachments.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. import { Meteor } from 'meteor/meteor';
  3. import { FilesCollection } from 'meteor/ostrio:files';
  4. import { isFileValid } from './fileValidation';
  5. import { createBucket } from './lib/grid/createBucket';
  6. import fs from 'fs';
  7. import path from 'path';
  8. import { AttachmentStoreStrategyFilesystem, AttachmentStoreStrategyGridFs } from '/models/lib/attachmentStoreStrategy';
  9. // DISABLED: S3 storage strategy removed due to Node.js compatibility
  10. // import { AttachmentStoreStrategyS3 } from '/models/lib/attachmentStoreStrategy';
  11. import FileStoreStrategyFactory, {moveToStorage, rename, STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS} from '/models/lib/fileStoreStrategy';
  12. // DISABLED: S3 storage removed due to Node.js compatibility
  13. // import { STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
  14. import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
  15. import AttachmentStorageSettings from './attachmentStorageSettings';
  16. import { generateUniversalAttachmentUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator';
  17. let attachmentUploadExternalProgram;
  18. let attachmentUploadMimeTypes = [];
  19. let attachmentUploadSize = 0;
  20. let attachmentBucket;
  21. let storagePath;
  22. if (Meteor.isServer) {
  23. attachmentBucket = createBucket('attachments');
  24. if (process.env.ATTACHMENTS_UPLOAD_MIME_TYPES) {
  25. attachmentUploadMimeTypes = process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',');
  26. attachmentUploadMimeTypes = attachmentUploadMimeTypes.map(value => value.trim());
  27. }
  28. if (process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) {
  29. attachmentUploadSize = parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE);
  30. if (isNaN(attachmentUploadSize)) {
  31. attachmentUploadSize = 0
  32. }
  33. }
  34. if (process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM) {
  35. attachmentUploadExternalProgram = process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM;
  36. if (!attachmentUploadExternalProgram.includes("{file}")) {
  37. attachmentUploadExternalProgram = undefined;
  38. }
  39. }
  40. storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'attachments');
  41. }
  42. export const fileStoreStrategyFactory = new FileStoreStrategyFactory(AttachmentStoreStrategyFilesystem, storagePath, AttachmentStoreStrategyGridFs, attachmentBucket);
  43. // XXX Enforce a schema for the Attachments FilesCollection
  44. // see: https://github.com/VeliovGroup/Meteor-Files/wiki/Schema
  45. Attachments = new FilesCollection({
  46. debug: false, // Change to `true` for debugging
  47. collectionName: 'attachments',
  48. allowClientCode: true,
  49. namingFunction(opts) {
  50. let filenameWithoutExtension = ""
  51. let fileId = "";
  52. if (opts?.name) {
  53. // Client
  54. filenameWithoutExtension = opts.name.replace(/(.+)\..+/, "$1");
  55. fileId = opts.meta.fileId;
  56. delete opts.meta.fileId;
  57. } else if (opts?.file?.name) {
  58. // Server
  59. if (opts.file.extension) {
  60. filenameWithoutExtension = opts.file.name.replace(new RegExp(opts.file.extensionWithDot + "$"), "")
  61. } else {
  62. // file has no extension, so don't replace anything, otherwise the last character is removed (because extensionWithDot = '.')
  63. filenameWithoutExtension = opts.file.name;
  64. }
  65. fileId = opts.fileId;
  66. }
  67. else {
  68. // should never reach here
  69. filenameWithoutExtension = Math.random().toString(36).slice(2);
  70. fileId = Math.random().toString(36).slice(2);
  71. }
  72. // OLD:
  73. //const ret = fileId + "-original-" + filenameWithoutExtension;
  74. // NEW: Save file only with filename of ObjectID, not including filename.
  75. // Fixes https://github.com/wekan/wekan/issues/4416#issuecomment-1510517168
  76. const ret = fileId;
  77. // remove fileId from meta, it was only stored there to have this information here in the namingFunction function
  78. return ret;
  79. },
  80. sanitize(str, max, replacement) {
  81. // keep the original filename
  82. return str;
  83. },
  84. storagePath() {
  85. const ret = fileStoreStrategyFactory.storagePath;
  86. return ret;
  87. },
  88. onBeforeUpload(file) {
  89. // Block SVG files for attachments to prevent XSS attacks
  90. if (file.name && file.name.toLowerCase().endsWith('.svg')) {
  91. if (process.env.DEBUG === 'true') {
  92. console.warn('Blocked SVG file upload for attachment:', file.name);
  93. }
  94. return 'SVG files are not allowed for attachments due to security reasons. Please use PNG, JPG, GIF, or other safe formats.';
  95. }
  96. if (file.type === 'image/svg+xml') {
  97. if (process.env.DEBUG === 'true') {
  98. console.warn('Blocked SVG MIME type upload for attachment:', file.type);
  99. }
  100. return 'SVG files are not allowed for attachments due to security reasons. Please use PNG, JPG, GIF, or other safe formats.';
  101. }
  102. return true;
  103. },
  104. onAfterUpload(fileObj) {
  105. // Get default storage backend from settings
  106. let defaultStorage = STORAGE_NAME_FILESYSTEM;
  107. try {
  108. const settings = AttachmentStorageSettings.findOne({});
  109. if (settings) {
  110. defaultStorage = settings.getDefaultStorage();
  111. }
  112. } catch (error) {
  113. console.warn('Could not get attachment storage settings, using default:', error);
  114. }
  115. // Set initial storage to filesystem (temporary)
  116. Object.keys(fileObj.versions).forEach(versionName => {
  117. fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
  118. });
  119. this._now = new Date();
  120. Attachments.update({ _id: fileObj._id }, { $set: { "versions" : fileObj.versions } });
  121. Attachments.update({ _id: fileObj.uploadedAtOstrio }, { $set: { "uploadedAtOstrio" : this._now } });
  122. // Use selected storage backend or copy storage if specified
  123. let storageDestination = fileObj.meta.copyStorage || defaultStorage;
  124. // Only migrate if the destination is different from filesystem
  125. if (storageDestination !== STORAGE_NAME_FILESYSTEM) {
  126. Meteor.defer(() => Meteor.call('validateAttachmentAndMoveToStorage', fileObj._id, storageDestination));
  127. }
  128. },
  129. interceptDownload(http, fileObj, versionName) {
  130. const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
  131. return ret;
  132. },
  133. onAfterRemove(files) {
  134. files.forEach(fileObj => {
  135. Object.keys(fileObj.versions).forEach(versionName => {
  136. fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
  137. });
  138. });
  139. },
  140. // We authorize the attachment download either:
  141. // - if the board is public, everyone (even unconnected) can download it
  142. // - if the board is private, only board members can download it
  143. protected(fileObj) {
  144. // file may have been deleted already again after upload validation failed
  145. if (!fileObj) {
  146. return false;
  147. }
  148. const board = ReactiveCache.getBoard(fileObj.meta.boardId);
  149. if (board.isPublic()) {
  150. return true;
  151. }
  152. return board.hasMember(this.userId);
  153. },
  154. });
  155. if (Meteor.isServer) {
  156. Attachments.allow({
  157. insert(userId, fileObj) {
  158. return allowIsBoardMember(userId, ReactiveCache.getBoard(fileObj.boardId));
  159. },
  160. update(userId, fileObj, fields) {
  161. // Only allow updates to specific fields that don't affect security
  162. const allowedFields = ['name', 'size', 'type', 'extension', 'extensionWithDot', 'meta', 'versions'];
  163. const isAllowedField = fields.every(field => allowedFields.includes(field));
  164. if (!isAllowedField) {
  165. if (process.env.DEBUG === 'true') {
  166. console.warn('Blocked attempt to update restricted attachment fields:', fields);
  167. }
  168. return false;
  169. }
  170. return allowIsBoardMember(userId, ReactiveCache.getBoard(fileObj.boardId));
  171. },
  172. remove(userId, fileObj) {
  173. // Additional security check: ensure the file belongs to the board the user has access to
  174. if (!fileObj || !fileObj.boardId) {
  175. if (process.env.DEBUG === 'true') {
  176. console.warn('Blocked attachment removal: file has no boardId');
  177. }
  178. return false;
  179. }
  180. const board = ReactiveCache.getBoard(fileObj.boardId);
  181. if (!board) {
  182. if (process.env.DEBUG === 'true') {
  183. console.warn('Blocked attachment removal: board not found');
  184. }
  185. return false;
  186. }
  187. return allowIsBoardMember(userId, board);
  188. },
  189. fetch: ['meta', 'boardId'],
  190. });
  191. Meteor.methods({
  192. // Validate image URL to prevent SVG-based DoS attacks
  193. validateImageUrl(imageUrl) {
  194. check(imageUrl, String);
  195. if (!imageUrl) {
  196. return { valid: false, reason: 'Empty URL' };
  197. }
  198. // Block SVG files and data URIs
  199. if (imageUrl.endsWith('.svg') || imageUrl.startsWith('data:image/svg')) {
  200. if (process.env.DEBUG === 'true') {
  201. console.warn('Blocked potentially malicious SVG image URL:', imageUrl);
  202. }
  203. return { valid: false, reason: 'SVG images are blocked for security reasons' };
  204. }
  205. // Block data URIs that could contain malicious content
  206. if (imageUrl.startsWith('data:')) {
  207. if (process.env.DEBUG === 'true') {
  208. console.warn('Blocked data URI image URL:', imageUrl);
  209. }
  210. return { valid: false, reason: 'Data URIs are blocked for security reasons' };
  211. }
  212. // Validate URL format
  213. try {
  214. const url = new URL(imageUrl);
  215. // Only allow http and https protocols
  216. if (!['http:', 'https:'].includes(url.protocol)) {
  217. return { valid: false, reason: 'Only HTTP and HTTPS protocols are allowed' };
  218. }
  219. } catch (e) {
  220. return { valid: false, reason: 'Invalid URL format' };
  221. }
  222. return { valid: true };
  223. },
  224. moveAttachmentToStorage(fileObjId, storageDestination) {
  225. check(fileObjId, String);
  226. check(storageDestination, String);
  227. const fileObj = ReactiveCache.getAttachment(fileObjId);
  228. moveToStorage(fileObj, storageDestination, fileStoreStrategyFactory);
  229. },
  230. renameAttachment(fileObjId, newName) {
  231. check(fileObjId, String);
  232. check(newName, String);
  233. const currentUserId = Meteor.userId();
  234. if (!currentUserId) {
  235. throw new Meteor.Error('not-authorized', 'User must be logged in');
  236. }
  237. const fileObj = ReactiveCache.getAttachment(fileObjId);
  238. if (!fileObj) {
  239. throw new Meteor.Error('file-not-found', 'Attachment not found');
  240. }
  241. // Verify the user has permission to modify this attachment
  242. const board = ReactiveCache.getBoard(fileObj.boardId);
  243. if (!board) {
  244. throw new Meteor.Error('board-not-found', 'Board not found');
  245. }
  246. if (!allowIsBoardMember(currentUserId, board)) {
  247. if (process.env.DEBUG === 'true') {
  248. console.warn(`Blocked unauthorized attachment rename attempt: user ${currentUserId} tried to rename attachment ${fileObjId} in board ${fileObj.boardId}`);
  249. }
  250. throw new Meteor.Error('not-authorized', 'You do not have permission to modify this attachment');
  251. }
  252. rename(fileObj, newName, fileStoreStrategyFactory);
  253. },
  254. validateAttachment(fileObjId) {
  255. check(fileObjId, String);
  256. const fileObj = ReactiveCache.getAttachment(fileObjId);
  257. const isValid = Promise.await(isFileValid(fileObj, attachmentUploadMimeTypes, attachmentUploadSize, attachmentUploadExternalProgram));
  258. if (!isValid) {
  259. Attachments.remove(fileObjId);
  260. }
  261. },
  262. validateAttachmentAndMoveToStorage(fileObjId, storageDestination) {
  263. check(fileObjId, String);
  264. check(storageDestination, String);
  265. Meteor.call('validateAttachment', fileObjId);
  266. const fileObj = ReactiveCache.getAttachment(fileObjId);
  267. if (fileObj) {
  268. Meteor.defer(() => Meteor.call('moveAttachmentToStorage', fileObjId, storageDestination));
  269. }
  270. },
  271. });
  272. Meteor.startup(() => {
  273. Attachments.collection.createIndex({ 'meta.cardId': 1 });
  274. const storagePath = fileStoreStrategyFactory.storagePath;
  275. if (!fs.existsSync(storagePath)) {
  276. console.log("create storagePath because it doesn't exist: " + storagePath);
  277. fs.mkdirSync(storagePath, { recursive: true });
  278. }
  279. });
  280. }
  281. // Add backward compatibility methods - available on both client and server
  282. Attachments.getAttachmentWithBackwardCompatibility = getAttachmentWithBackwardCompatibility;
  283. Attachments.getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackwardCompatibility;
  284. // Override the link method to use universal URLs
  285. if (Meteor.isClient) {
  286. // Override the original FilesCollection link method to use universal URLs
  287. // This must override the ostrio:files method to avoid "Match error: Expected plain object"
  288. const originalLink = Attachments.link;
  289. Attachments.link = function(versionName) {
  290. // Accept both direct calls and collection.helpers style calls
  291. const fileRef = this._id ? this : (versionName && versionName._id ? versionName : this);
  292. const version = (typeof versionName === 'string') ? versionName : 'original';
  293. if (fileRef && fileRef._id) {
  294. const url = generateUniversalAttachmentUrl(fileRef._id, version);
  295. if (process.env.DEBUG === 'true') {
  296. console.log('Attachment link generated:', url, 'for ID:', fileRef._id);
  297. }
  298. return url;
  299. }
  300. // Fallback to original if somehow we don't have an ID
  301. return originalLink ? originalLink.call(this, versionName) : '';
  302. };
  303. // Also add as collection helper for document instances
  304. Attachments.collection.helpers({
  305. link(version) {
  306. // Handle both no-argument and string argument cases
  307. const ver = (typeof version === 'string') ? version : 'original';
  308. const url = generateUniversalAttachmentUrl(this._id, ver);
  309. if (process.env.DEBUG === 'true') {
  310. console.log('Attachment link (helper) generated:', url, 'for ID:', this._id);
  311. }
  312. return url;
  313. }
  314. });
  315. }
  316. export default Attachments;