avatars.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. import { Meteor } from 'meteor/meteor';
  3. import { FilesCollection } from 'meteor/ostrio:files';
  4. import { formatFleURL } from 'meteor/ostrio:files/lib';
  5. import { isFileValid } from './fileValidation';
  6. import { createBucket } from './lib/grid/createBucket';
  7. import { TAPi18n } from '/imports/i18n';
  8. import fs from 'fs';
  9. import path from 'path';
  10. import FileStoreStrategyFactory, { FileStoreStrategyFilesystem, FileStoreStrategyGridFs, STORAGE_NAME_FILESYSTEM } from '/models/lib/fileStoreStrategy';
  11. import { generateUniversalAvatarUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator';
  12. const filesize = require('filesize');
  13. let avatarsUploadExternalProgram;
  14. let avatarsUploadMimeTypes = [];
  15. let avatarsUploadSize = 72000;
  16. let avatarsBucket;
  17. let storagePath;
  18. if (Meteor.isServer) {
  19. if (process.env.AVATARS_UPLOAD_MIME_TYPES) {
  20. avatarsUploadMimeTypes = process.env.AVATARS_UPLOAD_MIME_TYPES.split(',');
  21. avatarsUploadMimeTypes = avatarsUploadMimeTypes.map(value => value.trim());
  22. }
  23. if (process.env.AVATARS_UPLOAD_MAX_SIZE) {
  24. avatarsUploadSize_ = parseInt(process.env.AVATARS_UPLOAD_MAX_SIZE);
  25. if (_.isNumber(avatarsUploadSize_) && avatarsUploadSize_ > 0) {
  26. avatarsUploadSize = avatarsUploadSize_;
  27. }
  28. }
  29. if (process.env.AVATARS_UPLOAD_EXTERNAL_PROGRAM) {
  30. avatarsUploadExternalProgram = process.env.AVATARS_UPLOAD_EXTERNAL_PROGRAM;
  31. if (!avatarsUploadExternalProgram.includes("{file}")) {
  32. avatarsUploadExternalProgram = undefined;
  33. }
  34. }
  35. avatarsBucket = createBucket('avatars');
  36. storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'avatars');
  37. }
  38. export const fileStoreStrategyFactory = new FileStoreStrategyFactory(FileStoreStrategyFilesystem, storagePath, FileStoreStrategyGridFs, avatarsBucket);
  39. Avatars = new FilesCollection({
  40. debug: false, // Change to `true` for debugging
  41. collectionName: 'avatars',
  42. allowClientCode: true,
  43. namingFunction(opts) {
  44. let filenameWithoutExtension = ""
  45. let fileId = "";
  46. if (opts?.name) {
  47. // Client
  48. filenameWithoutExtension = opts.name.replace(/(.+)\..+/, "$1");
  49. fileId = opts.meta.fileId;
  50. delete opts.meta.fileId;
  51. } else if (opts?.file?.name) {
  52. // Server
  53. if (opts.file.extension) {
  54. filenameWithoutExtension = opts.file.name.replace(new RegExp(opts.file.extensionWithDot + "$"), "")
  55. } else {
  56. // file has no extension, so don't replace anything, otherwise the last character is removed (because extensionWithDot = '.')
  57. filenameWithoutExtension = opts.file.name;
  58. }
  59. fileId = opts.fileId;
  60. }
  61. else {
  62. // should never reach here
  63. filenameWithoutExtension = Math.random().toString(36).slice(2);
  64. fileId = Math.random().toString(36).slice(2);
  65. }
  66. const ret = fileId + "-original-" + filenameWithoutExtension;
  67. // remove fileId from meta, it was only stored there to have this information here in the namingFunction function
  68. return ret;
  69. },
  70. sanitize(str, max, replacement) {
  71. // keep the original filename
  72. return str;
  73. },
  74. storagePath() {
  75. const ret = fileStoreStrategyFactory.storagePath;
  76. return ret;
  77. },
  78. onBeforeUpload(file) {
  79. // Block SVG files for avatars to prevent XSS attacks
  80. if (file.name && file.name.toLowerCase().endsWith('.svg')) {
  81. if (process.env.DEBUG === 'true') {
  82. console.warn('Blocked SVG file upload for avatar:', file.name);
  83. }
  84. return 'SVG files are not allowed for avatars due to security reasons. Please use PNG, JPG, or GIF format.';
  85. }
  86. if (file.type === 'image/svg+xml') {
  87. if (process.env.DEBUG === 'true') {
  88. console.warn('Blocked SVG MIME type upload for avatar:', file.type);
  89. }
  90. return 'SVG files are not allowed for avatars due to security reasons. Please use PNG, JPG, or GIF format.';
  91. }
  92. if (file.size <= avatarsUploadSize && file.type.startsWith('image/')) {
  93. return true;
  94. }
  95. return TAPi18n.__('avatar-too-big', {size: filesize(avatarsUploadSize)});
  96. },
  97. onAfterUpload(fileObj) {
  98. // current storage is the filesystem, update object and database
  99. Object.keys(fileObj.versions).forEach(versionName => {
  100. fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
  101. });
  102. Avatars.update({ _id: fileObj._id }, { $set: { "versions": fileObj.versions } });
  103. const isValid = Promise.await(isFileValid(fileObj, avatarsUploadMimeTypes, avatarsUploadSize, avatarsUploadExternalProgram));
  104. if (isValid) {
  105. // Set avatar URL using universal URL generator (URL-agnostic)
  106. const universalUrl = generateUniversalAvatarUrl(fileObj._id);
  107. ReactiveCache.getUser(fileObj.userId).setAvatarUrl(universalUrl);
  108. } else {
  109. Avatars.remove(fileObj._id);
  110. }
  111. },
  112. interceptDownload(http, fileObj, versionName) {
  113. const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
  114. return ret;
  115. },
  116. onBeforeRemove(files) {
  117. files.forEach(fileObj => {
  118. if (fileObj.userId) {
  119. ReactiveCache.getUser(fileObj.userId).setAvatarUrl('');
  120. }
  121. });
  122. return true;
  123. },
  124. onAfterRemove(files) {
  125. files.forEach(fileObj => {
  126. Object.keys(fileObj.versions).forEach(versionName => {
  127. fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
  128. });
  129. });
  130. },
  131. });
  132. function isOwner(userId, doc) {
  133. return userId && userId === doc.userId;
  134. }
  135. if (Meteor.isServer) {
  136. Avatars.allow({
  137. insert: isOwner,
  138. update: isOwner,
  139. remove: isOwner,
  140. fetch: ['userId'],
  141. });
  142. Meteor.startup(() => {
  143. const storagePath = fileStoreStrategyFactory.storagePath;
  144. if (!fs.existsSync(storagePath)) {
  145. console.log("create storagePath because it doesn't exist: " + storagePath);
  146. fs.mkdirSync(storagePath, { recursive: true });
  147. }
  148. });
  149. }
  150. // Override the link method to use universal URLs
  151. if (Meteor.isClient) {
  152. // Add custom link method to avatar documents
  153. Avatars.collection.helpers({
  154. link(version = 'original') {
  155. // Use universal URL generator for consistent, URL-agnostic URLs
  156. return generateUniversalAvatarUrl(this._id, version);
  157. }
  158. });
  159. }
  160. export default Avatars;