attachment.js 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import loggerFactory from '~/utils/logger';
  2. import { AttachmentType } from '../interfaces/attachment';
  3. import { Attachment } from '../models/attachment';
  4. const fs = require('fs');
  5. const mongoose = require('mongoose');
  6. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  7. const logger = loggerFactory('growi:service:AttachmentService');
  8. const createReadStream = (filePath) => {
  9. return fs.createReadStream(filePath, {
  10. flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true,
  11. });
  12. };
  13. /**
  14. * the service class for Attachment and file-uploader
  15. */
  16. class AttachmentService {
  17. /** @type {Array<(pageId: string, file: Express.Multer.File, readable: Readable) => Promise<void>>} */
  18. attachHandlers = [];
  19. /** @type {Array<(attachmentId: string) => Promise<void>>} */
  20. detachHandlers = [];
  21. /** @type {import('~/server/crowi').default} Crowi instance */
  22. crowi;
  23. /** @param {import('~/server/crowi').default} crowi Crowi instance */
  24. constructor(crowi) {
  25. this.crowi = crowi;
  26. }
  27. async createAttachment(file, user, pageId = null, attachmentType, disposeTmpFileCallback) {
  28. const { fileUploadService } = this.crowi;
  29. // check limit
  30. const res = await fileUploadService.checkLimit(file.size);
  31. if (!res.isUploadable) {
  32. throw new Error(res.errorMessage);
  33. }
  34. const readStreamForCreateAttachmentDocument = createReadStream(file.path);
  35. // create an Attachment document and upload file
  36. let attachment;
  37. try {
  38. attachment = Attachment.createWithoutSave(pageId, user, file.originalname, file.mimetype, file.size, attachmentType);
  39. await fileUploadService.uploadAttachment(readStreamForCreateAttachmentDocument, attachment);
  40. await attachment.save();
  41. // Creates a new stream for each operation instead of reusing the original stream.
  42. // REASON: Node.js Readable streams cannot be reused after consumption.
  43. // When a stream is piped or consumed, its internal state changes and the data pointers
  44. // are advanced to the end, making it impossible to read the same data again.
  45. const readStreamForAttachedHandlers = [];
  46. const attachedHandlerPromises = this.attachHandlers.map((handler) => {
  47. const readStreamForAttachedHandler = createReadStream(file.path);
  48. readStreamForAttachedHandlers.push(readStreamForAttachedHandler);
  49. return handler(pageId, file, readStreamForAttachedHandler);
  50. });
  51. // Do not await, run in background
  52. Promise.all(attachedHandlerPromises)
  53. .catch((err) => {
  54. logger.error('Error while executing attach handler', err);
  55. })
  56. .finally(() => {
  57. // readStreamForAttachedHandler?.destroy();
  58. readStreamForAttachedHandlers.forEach(readStream => readStream.destroy());
  59. disposeTmpFileCallback?.(file);
  60. });
  61. }
  62. catch (err) {
  63. logger.error('Error while creating attachment', err);
  64. disposeTmpFileCallback?.(file);
  65. throw err;
  66. }
  67. finally {
  68. readStreamForCreateAttachmentDocument.destroy();
  69. }
  70. return attachment;
  71. }
  72. async removeAllAttachments(attachments) {
  73. const { fileUploadService } = this.crowi;
  74. const attachmentsCollection = mongoose.connection.collection('attachments');
  75. const unorderAttachmentsBulkOp = attachmentsCollection.initializeUnorderedBulkOp();
  76. if (attachments.length === 0) {
  77. return;
  78. }
  79. attachments.forEach((attachment) => {
  80. unorderAttachmentsBulkOp.find({ _id: attachment._id }).delete();
  81. });
  82. await unorderAttachmentsBulkOp.execute();
  83. await fileUploadService.deleteFiles(attachments);
  84. return;
  85. }
  86. async removeAttachment(attachmentId) {
  87. const { fileUploadService } = this.crowi;
  88. const attachment = await Attachment.findById(attachmentId);
  89. await fileUploadService.deleteFile(attachment);
  90. await attachment.remove();
  91. const detachedHandlerPromises = this.detachHandlers.map((handler) => {
  92. return handler(attachment._id);
  93. });
  94. // Do not await, run in background
  95. Promise.all(detachedHandlerPromises)
  96. .catch((err) => {
  97. logger.error('Error while executing detached handler', err);
  98. });
  99. return;
  100. }
  101. async isBrandLogoExist() {
  102. const query = { attachmentType: AttachmentType.BRAND_LOGO };
  103. const count = await Attachment.countDocuments(query);
  104. return count >= 1;
  105. }
  106. /**
  107. * Register a handler that will be called after attachment creation
  108. * @param {(pageId: string, file: Express.Multer.File, readable: Readable) => Promise<void>} handler
  109. */
  110. addAttachHandler(handler) {
  111. this.attachHandlers.push(handler);
  112. }
  113. /**
  114. * Register a handler that will be called before attachment deletion
  115. * @param {(attachmentId: string) => Promise<void>} handler
  116. */
  117. addDetachHandler(handler) {
  118. this.detachHandlers.push(handler);
  119. }
  120. }
  121. module.exports = AttachmentService;