local.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import type { ReadStream } from 'fs';
  2. import type { Writable } from 'stream';
  3. import { Readable } from 'stream';
  4. import { pipeline } from 'stream/promises';
  5. import type { Response } from 'express';
  6. import type Crowi from '~/server/crowi';
  7. import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
  8. import type { IAttachmentDocument } from '~/server/models/attachment';
  9. import loggerFactory from '~/utils/logger';
  10. import { configManager } from '../config-manager';
  11. import {
  12. AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
  13. } from './file-uploader';
  14. import {
  15. ContentHeaders, applyHeaders,
  16. } from './utils';
  17. const logger = loggerFactory('growi:service:fileUploaderLocal');
  18. const fs = require('fs');
  19. const fsPromises = require('fs/promises');
  20. const path = require('path');
  21. const mkdir = require('mkdirp');
  22. const urljoin = require('url-join');
  23. // TODO: rewrite this module to be a type-safe implementation
  24. class LocalFileUploader extends AbstractFileUploader {
  25. /**
  26. * @inheritdoc
  27. */
  28. override isValidUploadSettings(): boolean {
  29. throw new Error('Method not implemented.');
  30. }
  31. /**
  32. * @inheritdoc
  33. */
  34. override listFiles() {
  35. throw new Error('Method not implemented.');
  36. }
  37. /**
  38. * @inheritdoc
  39. */
  40. override saveFile(param: SaveFileParam) {
  41. throw new Error('Method not implemented.');
  42. }
  43. /**
  44. * @inheritdoc
  45. */
  46. override deleteFiles() {
  47. throw new Error('Method not implemented.');
  48. }
  49. deleteFileByFilePath(filePath: string): void {
  50. throw new Error('Method not implemented.');
  51. }
  52. /**
  53. * @inheritdoc
  54. */
  55. override determineResponseMode() {
  56. return configManager.getConfig('fileUpload:local:useInternalRedirect')
  57. ? ResponseMode.DELEGATE
  58. : ResponseMode.RELAY;
  59. }
  60. /**
  61. * @inheritdoc
  62. */
  63. override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
  64. throw new Error('Method not implemented.');
  65. }
  66. /**
  67. * @inheritdoc
  68. */
  69. override respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void {
  70. throw new Error('Method not implemented.');
  71. }
  72. /**
  73. * @inheritdoc
  74. */
  75. override findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
  76. throw new Error('Method not implemented.');
  77. }
  78. /**
  79. * @inheritDoc
  80. */
  81. override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
  82. throw new Error('LocalFileUploader does not support ResponseMode.REDIRECT.');
  83. }
  84. }
  85. module.exports = function(crowi: Crowi) {
  86. const lib = new LocalFileUploader(crowi);
  87. const basePath = path.posix.join(crowi.publicDir, 'uploads');
  88. function getFilePathOnStorage(attachment) {
  89. const dirName = (attachment.page != null)
  90. ? 'attachment'
  91. : 'user';
  92. const filePath = path.posix.join(basePath, dirName, attachment.fileName);
  93. return filePath;
  94. }
  95. async function readdirRecursively(dirPath) {
  96. const directories = await fsPromises.readdir(dirPath, { withFileTypes: true });
  97. const files = await Promise.all(directories.map((directory) => {
  98. const childDirPathOrFilePath = path.resolve(dirPath, directory.name);
  99. return directory.isDirectory() ? readdirRecursively(childDirPathOrFilePath) : childDirPathOrFilePath;
  100. }));
  101. return files.flat();
  102. }
  103. lib.isValidUploadSettings = function() {
  104. return true;
  105. };
  106. (lib as any).deleteFile = async function(attachment) {
  107. const filePath = getFilePathOnStorage(attachment);
  108. return lib.deleteFileByFilePath(filePath);
  109. };
  110. (lib as any).deleteFiles = async function(attachments) {
  111. attachments.map((attachment) => {
  112. return (lib as any).deleteFile(attachment);
  113. });
  114. };
  115. lib.deleteFileByFilePath = async function(filePath) {
  116. // check file exists
  117. try {
  118. fs.statSync(filePath);
  119. }
  120. catch (err) {
  121. logger.warn(`Any AttachmentFile which path is '${filePath}' does not exist in local fs`);
  122. return;
  123. }
  124. return fs.unlinkSync(filePath);
  125. };
  126. lib.uploadAttachment = async function(fileStream, attachment) {
  127. logger.debug(`File uploading: fileName=${attachment.fileName}`);
  128. const filePath = getFilePathOnStorage(attachment);
  129. const dirpath = path.posix.dirname(filePath);
  130. // mkdir -p
  131. mkdir.sync(dirpath);
  132. const writeStream: Writable = fs.createWriteStream(filePath);
  133. return pipeline(fileStream, writeStream);
  134. };
  135. lib.saveFile = async function({ filePath, contentType, data }) {
  136. const absFilePath = path.posix.join(basePath, filePath);
  137. const dirpath = path.posix.dirname(absFilePath);
  138. // mkdir -p
  139. mkdir.sync(dirpath);
  140. const fileStream = new Readable();
  141. fileStream.push(data);
  142. fileStream.push(null); // EOF
  143. const writeStream: Writable = fs.createWriteStream(absFilePath);
  144. return pipeline(fileStream, writeStream);
  145. };
  146. /**
  147. * Find data substance
  148. *
  149. * @param {Attachment} attachment
  150. * @return {stream.Readable} readable stream
  151. */
  152. lib.findDeliveryFile = async function(attachment) {
  153. const filePath = getFilePathOnStorage(attachment);
  154. // check file exists
  155. try {
  156. fs.statSync(filePath);
  157. }
  158. catch (err) {
  159. throw new Error(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in local fs`);
  160. }
  161. // return stream.Readable
  162. return fs.createReadStream(filePath);
  163. };
  164. /**
  165. * check the file size limit
  166. *
  167. * In detail, the followings are checked.
  168. * - per-file size limit (specified by MAX_FILE_SIZE)
  169. */
  170. (lib as any).checkLimit = async function(uploadFileSize) {
  171. const maxFileSize = configManager.getConfig('app:maxFileSize');
  172. const totalLimit = configManager.getConfig('app:fileUploadTotalLimit');
  173. return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
  174. };
  175. /**
  176. * Respond to the HTTP request.
  177. * @param {Response} res
  178. * @param {Response} attachment
  179. */
  180. lib.respond = function(res, attachment, opts) {
  181. // Responce using internal redirect of nginx or Apache.
  182. const storagePath = getFilePathOnStorage(attachment);
  183. const relativePath = path.relative(crowi.publicDir, storagePath);
  184. const internalPathRoot = configManager.getConfig('fileUpload:local:internalRedirectPath');
  185. const internalPath = urljoin(internalPathRoot, relativePath);
  186. const isDownload = opts?.download ?? false;
  187. const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
  188. applyHeaders(res, [
  189. ...contentHeaders.toExpressHttpHeaders(),
  190. { field: 'X-Accel-Redirect', value: internalPath },
  191. { field: 'X-Sendfile', value: storagePath },
  192. ]);
  193. return res.end();
  194. };
  195. /**
  196. * List files in storage
  197. */
  198. lib.listFiles = async function() {
  199. // `mkdir -p` to avoid ENOENT error
  200. await mkdir(basePath);
  201. const filePaths = await readdirRecursively(basePath);
  202. return Promise.all(
  203. filePaths.map(
  204. file => fsPromises.stat(file).then(({ size }) => ({
  205. name: path.relative(basePath, file),
  206. size,
  207. })),
  208. ),
  209. );
  210. };
  211. return lib;
  212. };