fileStoreStrategy.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. import fs from 'fs';
  2. import path from 'path';
  3. import { createObjectId } from './grid/createObjectId';
  4. import { httpStreamOutput } from './httpStream.js';
  5. //import {} from './s3/Server-side-file-store.js';
  6. import { ObjectID } from 'bson';
  7. // DISABLED: Minio support removed due to Node.js compatibility issues
  8. // var Minio = require('minio');
  9. export const STORAGE_NAME_FILESYSTEM = "fs";
  10. export const STORAGE_NAME_GRIDFS = "gridfs";
  11. export const STORAGE_NAME_S3 = "s3";
  12. /** Factory for FileStoreStrategy */
  13. export default class FileStoreStrategyFactory {
  14. /** constructor
  15. * @param classFileStoreStrategyFilesystem use this strategy for filesystem storage
  16. * @param storagePath file storage path
  17. * @param classFileStoreStrategyGridFs use this strategy for GridFS storage
  18. * @param gridFsBucket use this GridFS Bucket as GridFS Storage
  19. * @param classFileStoreStrategyS3 DISABLED: S3 storage strategy removed due to Node.js compatibility
  20. * @param s3Bucket DISABLED: S3 bucket removed due to Node.js compatibility
  21. */
  22. constructor(classFileStoreStrategyFilesystem, storagePath, classFileStoreStrategyGridFs, gridFsBucket, classFileStoreStrategyS3, s3Bucket) {
  23. this.classFileStoreStrategyFilesystem = classFileStoreStrategyFilesystem;
  24. this.storagePath = storagePath;
  25. this.classFileStoreStrategyGridFs = classFileStoreStrategyGridFs;
  26. this.gridFsBucket = gridFsBucket;
  27. // DISABLED: S3 storage strategy removed due to Node.js compatibility
  28. // this.classFileStoreStrategyS3 = classFileStoreStrategyS3;
  29. // this.s3Bucket = s3Bucket;
  30. }
  31. /** returns the right FileStoreStrategy
  32. * @param fileObj the current file object
  33. * @param versionName the current version
  34. * @param use this storage, or if not set, get the storage from fileObj
  35. */
  36. getFileStrategy(fileObj, versionName, storage) {
  37. if (!storage) {
  38. storage = fileObj.versions[versionName].storage;
  39. if (!storage) {
  40. if (fileObj.meta.source == "import" || fileObj.versions[versionName].meta.gridFsFileId) {
  41. // uploaded by import, so it's in GridFS (MongoDB)
  42. storage = STORAGE_NAME_GRIDFS;
  43. } else if (fileObj && fileObj.versions && fileObj.versions[version] && fileObj.versions[version].meta && fileObj.versions[version].meta.pipePath) {
  44. // DISABLED: S3 storage removed due to Node.js compatibility - fallback to filesystem
  45. storage = STORAGE_NAME_FILESYSTEM;
  46. } else {
  47. // newly uploaded, so it's at the filesystem
  48. storage = STORAGE_NAME_FILESYSTEM;
  49. }
  50. }
  51. }
  52. let ret;
  53. if ([STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS].includes(storage)) {
  54. if (storage == STORAGE_NAME_FILESYSTEM) {
  55. ret = new this.classFileStoreStrategyFilesystem(fileObj, versionName);
  56. } else if (storage == STORAGE_NAME_GRIDFS) {
  57. ret = new this.classFileStoreStrategyGridFs(this.gridFsBucket, fileObj, versionName);
  58. }
  59. } else if (storage == STORAGE_NAME_S3) {
  60. // DISABLED: S3 storage removed due to Node.js compatibility - fallback to filesystem
  61. console.warn('S3 storage is disabled due to Node.js compatibility issues, falling back to filesystem storage');
  62. ret = new this.classFileStoreStrategyFilesystem(fileObj, versionName);
  63. }
  64. return ret;
  65. }
  66. }
  67. /** Strategy to store files */
  68. class FileStoreStrategy {
  69. /** constructor
  70. * @param fileObj the current file object
  71. * @param versionName the current version
  72. */
  73. constructor(fileObj, versionName) {
  74. this.fileObj = fileObj;
  75. this.versionName = versionName;
  76. }
  77. /** after successfull upload */
  78. onAfterUpload() {
  79. }
  80. /** download the file
  81. * @param http the current http request
  82. * @param cacheControl cacheControl of FilesCollection
  83. */
  84. interceptDownload(http, cacheControl) {
  85. }
  86. /** after file remove */
  87. onAfterRemove() {
  88. }
  89. /** returns a read stream
  90. * @return the read stream
  91. */
  92. getReadStream() {
  93. }
  94. /** returns a write stream
  95. * @param filePath if set, use this path
  96. * @return the write stream
  97. */
  98. getWriteStream(filePath) {
  99. }
  100. /** writing finished
  101. * @param finishedData the data of the write stream finish event
  102. */
  103. writeStreamFinished(finishedData) {
  104. }
  105. /** returns the new file path
  106. * @param storagePath use this storage path
  107. * @return the new file path
  108. */
  109. getNewPath(storagePath, name) {
  110. if (!_.isString(name)) {
  111. name = this.fileObj.name;
  112. }
  113. const ret = path.join(storagePath, this.fileObj._id + "-" + this.versionName + "-" + name);
  114. return ret;
  115. }
  116. /** remove the file */
  117. unlink() {
  118. }
  119. /** rename the file (physical)
  120. * @li at database the filename is updated after this method
  121. * @param newFilePath the new file path
  122. */
  123. rename(newFilePath) {
  124. }
  125. /** return the storage name
  126. * @return the storage name
  127. */
  128. getStorageName() {
  129. }
  130. }
  131. /** Strategy to store attachments at GridFS (MongoDB) */
  132. export class FileStoreStrategyGridFs extends FileStoreStrategy {
  133. /** constructor
  134. * @param gridFsBucket use this GridFS Bucket
  135. * @param fileObj the current file object
  136. * @param versionName the current version
  137. */
  138. constructor(gridFsBucket, fileObj, versionName) {
  139. super(fileObj, versionName);
  140. this.gridFsBucket = gridFsBucket;
  141. }
  142. /** download the file
  143. * @param http the current http request
  144. * @param cacheControl cacheControl of FilesCollection
  145. */
  146. interceptDownload(http, cacheControl) {
  147. const readStream = this.getReadStream();
  148. const downloadFlag = http?.params?.query?.download;
  149. let ret = false;
  150. if (readStream) {
  151. ret = true;
  152. httpStreamOutput(readStream, this.fileObj.name, http, downloadFlag, cacheControl);
  153. }
  154. return ret;
  155. }
  156. /** after file remove */
  157. onAfterRemove() {
  158. this.unlink();
  159. super.onAfterRemove();
  160. }
  161. /** returns a read stream
  162. * @return the read stream
  163. */
  164. getReadStream() {
  165. const gfsId = this.getGridFsObjectId();
  166. let ret;
  167. if (gfsId) {
  168. ret = this.gridFsBucket.openDownloadStream(gfsId);
  169. }
  170. return ret;
  171. }
  172. /** returns a write stream
  173. * @param filePath if set, use this path
  174. * @return the write stream
  175. */
  176. getWriteStream(filePath) {
  177. const fileObj = this.fileObj;
  178. const versionName = this.versionName;
  179. const metadata = { ...fileObj.meta, versionName, fileId: fileObj._id };
  180. const ret = this.gridFsBucket.openUploadStream(this.fileObj.name, {
  181. contentType: fileObj.type || 'binary/octet-stream',
  182. metadata,
  183. });
  184. return ret;
  185. }
  186. /** writing finished
  187. * @param finishedData the data of the write stream finish event
  188. */
  189. writeStreamFinished(finishedData) {
  190. const gridFsFileIdName = this.getGridFsFileIdName();
  191. Attachments.update({ _id: this.fileObj._id }, { $set: { [gridFsFileIdName]: finishedData._id.toHexString(), } });
  192. }
  193. /** remove the file */
  194. unlink() {
  195. const gfsId = this.getGridFsObjectId();
  196. if (gfsId) {
  197. this.gridFsBucket.delete(gfsId, err => {
  198. if (err) {
  199. console.error("error on gfs bucket.delete: ", err);
  200. }
  201. });
  202. }
  203. const gridFsFileIdName = this.getGridFsFileIdName();
  204. Attachments.update({ _id: this.fileObj._id }, { $unset: { [gridFsFileIdName]: 1 } });
  205. }
  206. /** return the storage name
  207. * @return the storage name
  208. */
  209. getStorageName() {
  210. return STORAGE_NAME_GRIDFS;
  211. }
  212. /** returns the GridFS Object-Id
  213. * @return the GridFS Object-Id
  214. */
  215. getGridFsObjectId() {
  216. let ret;
  217. const gridFsFileId = this.getGridFsFileId();
  218. if (gridFsFileId) {
  219. ret = createObjectId({ gridFsFileId });
  220. }
  221. return ret;
  222. }
  223. /** returns the GridFS Object-Id
  224. * @return the GridFS Object-Id
  225. */
  226. getGridFsFileId() {
  227. const ret = (this.fileObj.versions[this.versionName].meta || {})
  228. .gridFsFileId;
  229. return ret;
  230. }
  231. /** returns the property name of gridFsFileId
  232. * @return the property name of gridFsFileId
  233. */
  234. getGridFsFileIdName() {
  235. const ret = `versions.${this.versionName}.meta.gridFsFileId`;
  236. return ret;
  237. }
  238. }
  239. /** Strategy to store attachments at filesystem */
  240. export class FileStoreStrategyFilesystem extends FileStoreStrategy {
  241. /** constructor
  242. * @param fileObj the current file object
  243. * @param versionName the current version
  244. */
  245. constructor(fileObj, versionName) {
  246. super(fileObj, versionName);
  247. }
  248. /** returns a read stream
  249. * @return the read stream
  250. */
  251. getReadStream() {
  252. const v = this.fileObj.versions[this.versionName] || {};
  253. const originalPath = v.path || '';
  254. const normalized = (originalPath || '').replace(/\\/g, '/');
  255. const isAvatar = normalized.includes('/avatars/') || (this.fileObj.collectionName === 'avatars');
  256. const baseDir = isAvatar ? 'avatars' : 'attachments';
  257. const storageRoot = path.join(process.env.WRITABLE_PATH || process.cwd(), baseDir);
  258. // Build candidate list in priority order
  259. const candidates = [];
  260. // 1) Original as-is (absolute or relative resolved to CWD)
  261. if (originalPath) {
  262. candidates.push(originalPath);
  263. if (!path.isAbsolute(originalPath)) {
  264. candidates.push(path.resolve(process.cwd(), originalPath));
  265. }
  266. }
  267. // 2) Same basename in storageRoot
  268. const baseName = path.basename(normalized || this.fileObj._id || '');
  269. if (baseName) {
  270. candidates.push(path.join(storageRoot, baseName));
  271. }
  272. // 3) Only ObjectID (no extension) in storageRoot
  273. if (this.fileObj && this.fileObj._id) {
  274. candidates.push(path.join(storageRoot, String(this.fileObj._id)));
  275. }
  276. // 4) New strategy naming pattern: <id>-<version>-<name>
  277. if (this.fileObj && this.fileObj._id && this.fileObj.name) {
  278. candidates.push(path.join(storageRoot, `${this.fileObj._id}-${this.versionName}-${this.fileObj.name}`));
  279. }
  280. // Pick first existing candidate
  281. let chosen;
  282. for (const c of candidates) {
  283. try {
  284. if (c && fs.existsSync(c)) {
  285. chosen = c;
  286. break;
  287. }
  288. } catch (_) {}
  289. }
  290. if (!chosen) {
  291. // No existing candidate found
  292. return undefined;
  293. }
  294. return fs.createReadStream(chosen);
  295. }
  296. /** returns a write stream
  297. * @param filePath if set, use this path
  298. * @return the write stream
  299. */
  300. getWriteStream(filePath) {
  301. if (!_.isString(filePath)) {
  302. filePath = this.fileObj.versions[this.versionName].path;
  303. }
  304. const ret = fs.createWriteStream(filePath);
  305. return ret;
  306. }
  307. /** writing finished
  308. * @param finishedData the data of the write stream finish event
  309. */
  310. writeStreamFinished(finishedData) {
  311. }
  312. /** remove the file */
  313. unlink() {
  314. const filePath = this.fileObj.versions[this.versionName].path;
  315. fs.unlink(filePath, () => {});
  316. }
  317. /** rename the file (physical)
  318. * @li at database the filename is updated after this method
  319. * @param newFilePath the new file path
  320. */
  321. rename(newFilePath) {
  322. fs.renameSync(this.fileObj.versions[this.versionName].path, newFilePath);
  323. }
  324. /** return the storage name
  325. * @return the storage name
  326. */
  327. getStorageName() {
  328. return STORAGE_NAME_FILESYSTEM;
  329. }
  330. }
  331. /** DISABLED: Strategy to store attachments at S3 - Minio support removed due to Node.js compatibility */
  332. export class FileStoreStrategyS3 extends FileStoreStrategy {
  333. constructor(s3Bucket, fileObj, versionName) {
  334. super(fileObj, versionName);
  335. this.s3Bucket = s3Bucket;
  336. }
  337. onAfterUpload() {
  338. console.warn('S3 storage is disabled due to Node.js compatibility issues');
  339. }
  340. interceptDownload(http, fileRef, version) {
  341. console.warn('S3 storage is disabled due to Node.js compatibility issues');
  342. http.response.writeHead(503, { 'Content-Type': 'text/plain' });
  343. http.response.end('S3 storage is disabled');
  344. }
  345. getReadStream() {
  346. throw new Error('S3 storage is disabled due to Node.js compatibility issues');
  347. }
  348. getWriteStream(filePath) {
  349. throw new Error('S3 storage is disabled due to Node.js compatibility issues');
  350. }
  351. getPath() {
  352. throw new Error('S3 storage is disabled due to Node.js compatibility issues');
  353. }
  354. getNewPath(storagePath) {
  355. throw new Error('S3 storage is disabled due to Node.js compatibility issues');
  356. }
  357. getStorageName() {
  358. return STORAGE_NAME_S3;
  359. }
  360. writeStreamFinished(finishedData) {
  361. console.warn('S3 storage is disabled due to Node.js compatibility issues');
  362. }
  363. unlink() {
  364. console.warn('S3 storage is disabled due to Node.js compatibility issues');
  365. }
  366. getS3FileIdName() {
  367. const ret = `versions.${this.versionName}.meta.s3FileId`;
  368. return ret;
  369. }
  370. }
  371. /** move the fileObj to another storage
  372. * @param fileObj move this fileObj to another storage
  373. * @param storageDestination the storage destination (fs or gridfs)
  374. * @param fileStoreStrategyFactory get FileStoreStrategy from this factory
  375. */
  376. export const moveToStorage = function(fileObj, storageDestination, fileStoreStrategyFactory) {
  377. Object.keys(fileObj.versions).forEach(versionName => {
  378. const strategyRead = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName);
  379. const strategyWrite = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName, storageDestination);
  380. if (strategyRead.constructor.name != strategyWrite.constructor.name) {
  381. const readStream = strategyRead.getReadStream();
  382. const filePath = strategyWrite.getNewPath(fileStoreStrategyFactory.storagePath);
  383. const writeStream = strategyWrite.getWriteStream(filePath);
  384. writeStream.on('error', error => {
  385. console.error('[writeStream error]: ', error, fileObj._id);
  386. });
  387. readStream.on('error', error => {
  388. console.error('[readStream error]: ', error, fileObj._id);
  389. });
  390. writeStream.on('finish', Meteor.bindEnvironment((finishedData) => {
  391. strategyWrite.writeStreamFinished(finishedData);
  392. }));
  393. // https://forums.meteor.com/t/meteor-code-must-always-run-within-a-fiber-try-wrapping-callbacks-that-you-pass-to-non-meteor-libraries-with-meteor-bindenvironmen/40099/8
  394. readStream.on('end', Meteor.bindEnvironment(() => {
  395. Attachments.update({ _id: fileObj._id }, { $set: {
  396. [`versions.${versionName}.storage`]: strategyWrite.getStorageName(),
  397. [`versions.${versionName}.path`]: filePath,
  398. } });
  399. strategyRead.unlink();
  400. }));
  401. readStream.pipe(writeStream);
  402. }
  403. });
  404. };
  405. export const copyFile = function(fileObj, newCardId, fileStoreStrategyFactory) {
  406. const newCard = ReactiveCache.getCard(newCardId);
  407. Object.keys(fileObj.versions).forEach(versionName => {
  408. const strategyRead = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName);
  409. const readStream = strategyRead.getReadStream();
  410. const strategyWrite = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName, STORAGE_NAME_FILESYSTEM);
  411. const tempPath = path.join(fileStoreStrategyFactory.storagePath, Random.id() + "-" + versionName + "-" + fileObj.name);
  412. const writeStream = strategyWrite.getWriteStream(tempPath);
  413. writeStream.on('error', error => {
  414. console.error('[writeStream error]: ', error, fileObj._id);
  415. });
  416. readStream.on('error', error => {
  417. console.error('[readStream error]: ', error, fileObj._id);
  418. });
  419. // https://forums.meteor.com/t/meteor-code-must-always-run-within-a-fiber-try-wrapping-callbacks-that-you-pass-to-non-meteor-libraries-with-meteor-bindenvironmen/40099/8
  420. readStream.on('end', Meteor.bindEnvironment(() => {
  421. const fileId = new ObjectID().toString();
  422. Attachments.addFile(
  423. tempPath,
  424. {
  425. fileName: fileObj.name,
  426. type: fileObj.type,
  427. meta: {
  428. boardId: newCard.boardId,
  429. cardId: newCardId,
  430. listId: newCard.listId,
  431. swimlaneId: newCard.swimlaneId,
  432. source: 'copy',
  433. copyFrom: fileObj._id,
  434. copyStorage: strategyRead.getStorageName(),
  435. },
  436. userId: fileObj.userId,
  437. size: fileObj.fileSize,
  438. fileId,
  439. },
  440. (err, fileRef) => {
  441. if (err) {
  442. console.log(err);
  443. } else {
  444. // Set the userId again
  445. Attachments.update({ _id: fileRef._id }, { $set: { userId: fileObj.userId } });
  446. }
  447. },
  448. true,
  449. );
  450. }));
  451. readStream.pipe(writeStream);
  452. });
  453. };
  454. export const rename = function(fileObj, newName, fileStoreStrategyFactory) {
  455. Object.keys(fileObj.versions).forEach(versionName => {
  456. const strategy = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName);
  457. const newFilePath = strategy.getNewPath(fileStoreStrategyFactory.storagePath, newName);
  458. strategy.rename(newFilePath);
  459. Attachments.update({ _id: fileObj._id }, { $set: {
  460. "name": newName,
  461. [`versions.${versionName}.path`]: newFilePath,
  462. } });
  463. });
  464. };