attachment.js 5.6 KB

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