Explorar o código

refactor findDeliveryFile

Yuki Takei %!s(int64=2) %!d(string=hai) anos
pai
achega
44c8d8a422

+ 73 - 62
apps/app/src/server/crowi/index.js

@@ -23,6 +23,7 @@ import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
+import { FileUploader } from '../service/file-uploader/file-uploader'; // eslint-disable-line no-unused-vars
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
@@ -38,68 +39,78 @@ const httpErrorHandler = require('../middlewares/http-error-handler');
 
 const sep = path.sep;
 
-function Crowi() {
-  this.version = pkg.version;
-  this.runtimeVersions = undefined; // initialized by scanRuntimeVersions()
-
-  this.publicDir = path.join(projectRoot, 'public') + sep;
-  this.resourceDir = path.join(projectRoot, 'resource') + sep;
-  this.localeDir = path.join(this.resourceDir, 'locales') + sep;
-  this.viewsDir = path.resolve(__dirname, '../views') + sep;
-  this.tmpDir = path.join(projectRoot, 'tmp') + sep;
-  this.cacheDir = path.join(this.tmpDir, 'cache');
-
-  this.express = null;
-
-  this.config = {};
-  this.configManager = null;
-  this.s2sMessagingService = null;
-  this.g2gTransferPusherService = null;
-  this.g2gTransferReceiverService = null;
-  this.mailService = null;
-  this.passportService = null;
-  this.globalNotificationService = null;
-  this.userNotificationService = null;
-  this.xssService = null;
-  this.aclService = null;
-  this.appService = null;
-  this.fileUploadService = null;
-  this.restQiitaAPIService = null;
-  this.growiBridgeService = null;
-  this.exportService = null;
-  this.importService = null;
-  this.pluginService = null;
-  this.searchService = null;
-  this.socketIoService = null;
-  this.pageService = null;
-  this.syncPageStatusService = null;
-  this.cdnResourcesService = new CdnResourcesService();
-  this.slackIntegrationService = null;
-  this.inAppNotificationService = null;
-  this.activityService = null;
-  this.commentService = null;
-  this.xss = new Xss();
-  this.questionnaireService = null;
-  this.questionnaireCronService = null;
-
-  this.tokens = null;
-
-  this.models = {};
-
-  this.env = process.env;
-  this.node_env = this.env.NODE_ENV || 'development';
-
-  this.port = this.env.PORT || 3000;
-
-  this.events = {
-    user: new UserEvent(this),
-    page: new (require('../events/page'))(this),
-    activity: new (require('../events/activity'))(this),
-    bookmark: new (require('../events/bookmark'))(this),
-    comment: new (require('../events/comment'))(this),
-    tag: new (require('../events/tag'))(this),
-    admin: new (require('../events/admin'))(this),
-  };
+class Crowi {
+
+  /** @type {AppService} */
+  appService;
+
+  /** @type {FileUploader} */
+  fileUploadService;
+
+  constructor() {
+    this.version = pkg.version;
+    this.runtimeVersions = undefined; // initialized by scanRuntimeVersions()
+
+    this.publicDir = path.join(projectRoot, 'public') + sep;
+    this.resourceDir = path.join(projectRoot, 'resource') + sep;
+    this.localeDir = path.join(this.resourceDir, 'locales') + sep;
+    this.viewsDir = path.resolve(__dirname, '../views') + sep;
+    this.tmpDir = path.join(projectRoot, 'tmp') + sep;
+    this.cacheDir = path.join(this.tmpDir, 'cache');
+
+    this.express = null;
+
+    this.config = {};
+    this.configManager = null;
+    this.s2sMessagingService = null;
+    this.g2gTransferPusherService = null;
+    this.g2gTransferReceiverService = null;
+    this.mailService = null;
+    this.passportService = null;
+    this.globalNotificationService = null;
+    this.userNotificationService = null;
+    this.xssService = null;
+    this.aclService = null;
+    this.appService = null;
+    this.fileUploadService = null;
+    this.restQiitaAPIService = null;
+    this.growiBridgeService = null;
+    this.exportService = null;
+    this.importService = null;
+    this.pluginService = null;
+    this.searchService = null;
+    this.socketIoService = null;
+    this.pageService = null;
+    this.syncPageStatusService = null;
+    this.cdnResourcesService = new CdnResourcesService();
+    this.slackIntegrationService = null;
+    this.inAppNotificationService = null;
+    this.activityService = null;
+    this.commentService = null;
+    this.xss = new Xss();
+    this.questionnaireService = null;
+    this.questionnaireCronService = null;
+
+    this.tokens = null;
+
+    this.models = {};
+
+    this.env = process.env;
+    this.node_env = this.env.NODE_ENV || 'development';
+
+    this.port = this.env.PORT || 3000;
+
+    this.events = {
+      user: new UserEvent(this),
+      page: new (require('../events/page'))(this),
+      activity: new (require('../events/activity'))(this),
+      bookmark: new (require('../events/bookmark'))(this),
+      comment: new (require('../events/comment'))(this),
+      tag: new (require('../events/tag'))(this),
+      admin: new (require('../events/admin'))(this),
+    };
+  }
+
 }
 
 Crowi.prototype.init = async function() {

+ 149 - 14
apps/app/src/server/routes/attachment/get.ts

@@ -1,34 +1,49 @@
-import { getIdForRef, type IPage, type IUser } from '@growi/core';
-import type { NextFunction, Request, Response } from 'express';
+import {
+  getIdForRef, type IPage, type IUser,
+} from '@growi/core';
+import express from 'express';
+import type {
+  NextFunction, Request, Response, Router,
+} from 'express';
 import mongoose from 'mongoose';
 
+import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiProperties } from '~/interfaces/crowi-request';
-import { Attachment, IAttachmentDocument } from '~/server/models';
-import ApiResponse from '~/server/util/apiResponse';
+import loggerFactory from '~/utils/logger';
+
+import type Crowi from '../../crowi';
+import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
+import { Attachment, type IAttachmentDocument } from '../../models';
+import ApiResponse from '../../util/apiResponse';
+
+
+const logger = loggerFactory('growi:routes:attachment:get');
 
 
 interface PageModel {
   isAccessiblePageByViewer: (pageId: string, user: IUser | undefined) => Promise<boolean>
 }
 
-type Req = CrowiProperties & Request<
+type LocalsAfterDataInjection = { attachment: IAttachmentDocument };
+
+type ValidateGetRequest = CrowiProperties & Request<
   { id: string },
-  undefined, undefined, undefined,
-  { attachment?: IAttachmentDocument }
+  any, any, any,
+  LocalsAfterDataInjection
 >;
 
-type AttachmentReq = Request<
-  undefined,
-  undefined, undefined, undefined,
-  { attachment: IAttachmentDocument }
+type ValidateGetResponse = Response<
+  any,
+  LocalsAfterDataInjection
 >;
 
-export const validateGetRequest = async(req: Req, res: Response, next: NextFunction): Promise<Response|void> => {
+export const validateGetRequest = async(req: ValidateGetRequest, res: ValidateGetResponse, next: NextFunction): Promise<void> => {
   const id = req.params.id;
   const attachment = await Attachment.findById(id);
 
   if (attachment == null) {
-    return res.json(ApiResponse.error('attachment not found'));
+    res.json(ApiResponse.error('attachment not found'));
+    return;
   }
 
   const user = req.user;
@@ -38,9 +53,129 @@ export const validateGetRequest = async(req: Req, res: Response, next: NextFunct
     const Page = mongoose.model<IPage, PageModel>('Page');
     const isAccessible = await Page.isAccessiblePageByViewer(getIdForRef(attachment.page), user);
     if (!isAccessible) {
-      return res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'. This attachment might belong to other pages.`));
+      res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'. This attachment might belong to other pages.`));
+      return;
     }
   }
 
+  res.locals.attachment = attachment;
+
   return next();
 };
+
+
+type GetRequest = CrowiProperties & Request<
+  { id: string },
+  any, any, any,
+  LocalsAfterDataInjection
+>;
+
+type GetResponse = Response<
+  any,
+  LocalsAfterDataInjection
+>
+
+export const setCommonHeadersToRes = (res: GetResponse): void => {
+  const { attachment } = res.locals;
+
+  res.set({
+    ETag: `Attachment-${attachment._id}`,
+    'Last-Modified': attachment.createdAt.toUTCString(),
+  });
+
+  if (attachment.fileSize) {
+    res.set({
+      'Content-Length': attachment.fileSize,
+    });
+  }
+
+  // // download
+  // if (forceDownload) {
+  //   res.set({
+  //     'Content-Disposition': `attachment;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+  //   });
+  // }
+  // // reference
+  // else {
+  //   res.set({
+  //     'Content-Type': attachment.fileFormat,
+  //     // eslint-disable-next-line max-len
+  //     'Content-Security-Policy': "script-src 'unsafe-hashes'; style-src 'self' 'unsafe-inline'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
+  //     'Content-Disposition': `inline;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+  //   });
+  // }
+};
+
+
+export const getRouterFactory = (crowi: Crowi): Router => {
+
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+
+  const router = express.Router();
+
+
+  const generateActivityParameters = (req: GetRequest) => {
+    return {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_ATTACHMENT_DOWNLOAD,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+  };
+
+
+  // note: validateGetRequest requires `req.params.id`
+  router.get<{ id: string }>('/:id([0-9a-z]{24})',
+    certifySharedPageAttachmentMiddleware, loginRequired, validateGetRequest,
+    async(req: GetRequest, res: GetResponse) => {
+      const { attachment } = res.locals;
+
+      const { fileUploadService } = crowi;
+
+      // add headers before evaluating 'req.fresh'
+      setCommonHeadersToRes(res);
+
+      res.set({
+        'Content-Type': attachment.fileFormat,
+        // eslint-disable-next-line max-len
+        'Content-Security-Policy': "script-src 'unsafe-hashes'; style-src 'self' 'unsafe-inline'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
+        'Content-Disposition': `inline;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+      });
+
+      const activityParameters = generateActivityParameters(req);
+      const createActivity = async() => {
+        await crowi.activityService.createActivity(activityParameters);
+      };
+
+      // return 304 if request is "fresh"
+      // see: http://expressjs.com/en/5x/api.html#req.fresh
+      if (req.fresh) {
+        res.sendStatus(304);
+        createActivity();
+        return;
+      }
+
+      if (fileUploadService.canRespond()) {
+        fileUploadService.respond(res, attachment);
+        createActivity();
+        return;
+      }
+
+      try {
+        const readable = await fileUploadService.findDeliveryFile(attachment);
+        readable.pipe(res);
+      }
+      catch (e) {
+        logger.error(e);
+        return res.json(ApiResponse.error(e.message));
+      }
+
+      createActivity();
+      return;
+    });
+
+  return router;
+};

+ 20 - 7
apps/app/src/server/service/file-uploader/aws.ts

@@ -7,7 +7,6 @@ import {
   DeleteObjectCommand,
   ListObjectsCommand,
   type GetObjectCommandInput,
-  type GetObjectCommandOutput,
 } from '@aws-sdk/client-s3';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import type { Response } from 'express';
@@ -75,6 +74,13 @@ class AwsFileUploader extends AbstractFileUploader {
     throw new Error('Method not implemented.');
   }
 
+  /**
+   * @inheritdoc
+   */
+  override findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
+    throw new Error('Method not implemented.');
+  }
+
 }
 
 module.exports = (crowi) => {
@@ -256,7 +262,7 @@ module.exports = (crowi) => {
     return s3.send(new PutObjectCommand(params));
   };
 
-  (lib as any).findDeliveryFile = async function(attachment) {
+  lib.findDeliveryFile = async function(attachment) {
     if (!lib.getIsReadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -276,17 +282,24 @@ module.exports = (crowi) => {
       throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
     }
 
-    let stream : GetObjectCommandOutput['Body'];
     try {
-      stream = (await s3.send(new GetObjectCommand(params))).Body;
+      const body = (await s3.send(new GetObjectCommand(params))).Body;
+
+      if (body == null) {
+        throw new Error(`S3 returned null for the Attachment (${filePath})`);
+      }
+
+      // eslint-disable-next-line no-nested-ternary
+      return 'stream' in body
+        ? body.stream() // get stream from Blob
+        : !('read' in body)
+          ? body as unknown as NodeJS.ReadableStream // cast force
+          : body;
     }
     catch (err) {
       logger.error(err);
       throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
     }
-
-    // return stream.Readable
-    return stream;
   };
 
   (lib as any).checkLimit = async function(uploadFileSize) {

+ 11 - 2
apps/app/src/server/service/file-uploader/azure.ts

@@ -15,7 +15,9 @@ import {
   BlockBlobParallelUploadOptions,
   BlockBlobUploadStreamOptions,
 } from '@azure/storage-blob';
+import type { Response } from 'express';
 
+import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
@@ -62,7 +64,14 @@ class AzureFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: Response): void {
+  override respond(res: Response, attachment: IAttachmentDocument): void {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
     throw new Error('Method not implemented.');
   }
 
@@ -206,7 +215,7 @@ module.exports = (crowi) => {
     return;
   };
 
-  (lib as any).findDeliveryFile = async function(attachment) {
+  lib.findDeliveryFile = async function(attachment) {
     if (!lib.getIsReadable()) {
       throw new Error('Azure is not configured.');
     }

+ 6 - 0
apps/app/src/server/service/file-uploader/file-uploader.ts

@@ -34,6 +34,7 @@ export interface FileUploader {
   doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult>,
   canRespond(): boolean
   respond(res: Response, attachment: IAttachmentDocument): void,
+  findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>
 }
 
 export abstract class AbstractFileUploader implements FileUploader {
@@ -149,4 +150,9 @@ export abstract class AbstractFileUploader implements FileUploader {
    */
   abstract respond(res: Response, attachment: IAttachmentDocument): void;
 
+  /**
+   * Find the file and Return ReadStream
+   */
+  abstract findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>;
+
 }

+ 12 - 8
apps/app/src/server/service/file-uploader/gcs.ts

@@ -1,7 +1,8 @@
 import { Storage } from '@google-cloud/storage';
-import { Response } from 'express';
+import type { Response } from 'express';
 import urljoin from 'url-join';
 
+import type Crowi from '~/server/crowi';
 import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
@@ -43,12 +44,19 @@ class GcsFileUploader extends AbstractFileUploader {
     throw new Error('Method not implemented.');
   }
 
+  /**
+   * @inheritdoc
+   */
+  override findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
+    throw new Error('Method not implemented.');
+  }
+
 }
 
 
 let _instance: Storage;
 
-module.exports = function(crowi) {
+module.exports = function(crowi: Crowi) {
   const lib = new GcsFileUploader(crowi);
 
   function getGcsBucket() {
@@ -188,7 +196,7 @@ module.exports = function(crowi) {
    * @param {Attachment} attachment
    * @return {stream.Readable} readable stream
    */
-  (lib as any).findDeliveryFile = async function(attachment) {
+  lib.findDeliveryFile = async function(attachment) {
     if (!lib.getIsReadable()) {
       throw new Error('GCS is not configured.');
     }
@@ -204,17 +212,13 @@ module.exports = function(crowi) {
       throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in GCS`);
     }
 
-    let stream;
     try {
-      stream = file.createReadStream();
+      return file.createReadStream();
     }
     catch (err) {
       logger.error(err);
       throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
     }
-
-    // return stream.Readable
-    return stream;
   };
 
   /**

+ 13 - 8
apps/app/src/server/service/file-uploader/gridfs.ts

@@ -1,8 +1,11 @@
 import { Readable } from 'stream';
 import util from 'util';
 
+import type { Response } from 'express';
 import mongoose from 'mongoose';
+import { createModel } from 'mongoose-gridfs';
 
+import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
@@ -39,7 +42,14 @@ class GridfsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: Response): void {
+  override respond(res: Response, attachment: IAttachmentDocument): void {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
     throw new Error('Method not implemented.');
   }
 
@@ -52,7 +62,6 @@ module.exports = function(crowi) {
   const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 
   // instantiate mongoose-gridfs
-  const { createModel } = require('mongoose-gridfs');
   const AttachmentFile = createModel({
     modelName: COLLECTION_NAME,
     bucketName: COLLECTION_NAME,
@@ -162,12 +171,8 @@ module.exports = function(crowi) {
    * @param {Attachment} attachment
    * @return {stream.Readable} readable stream
    */
-  (lib as any).findDeliveryFile = async function(attachment) {
-    let filenameValue = attachment.fileName;
-
-    if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
-      filenameValue = attachment.filePath;
-    }
+  lib.findDeliveryFile = async function(attachment) {
+    const filenameValue = attachment.fileName;
 
     const attachmentFile = await AttachmentFile.findOne({ filename: filenameValue });
 

+ 6 - 3
apps/app/src/server/service/g2g-transfer.ts

@@ -17,9 +17,12 @@ import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 import { TransferKey } from '~/utils/vo/transfer-key';
 
+import type Crowi from '../crowi';
 import { Attachment } from '../models';
 import { G2GTransferError, G2GTransferErrorCode } from '../models/vo/g2g-transfer-error';
 
+import { configManager } from './config-manager';
+
 const logger = loggerFactory('growi:service:g2g-transfer');
 
 /**
@@ -209,7 +212,7 @@ interface Receiver {
  */
 export class G2GTransferPusherService implements Pusher {
 
-  crowi: any;
+  crowi: Crowi;
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   constructor(crowi: any) {
@@ -242,7 +245,7 @@ export class G2GTransferPusherService implements Pusher {
   }
 
   public async getTransferability(destGROWIInfo: IDataGROWIInfo): Promise<Transferability> {
-    const { fileUploadService, configManager } = this.crowi;
+    const { fileUploadService } = this.crowi;
 
     const version = this.crowi.version;
     if (version !== destGROWIInfo.version) {
@@ -424,7 +427,7 @@ export class G2GTransferPusherService implements Pusher {
     const targetConfigKeys = UPLOAD_CONFIG_KEYS;
 
     const uploadConfigs = Object.fromEntries(targetConfigKeys.map((key) => {
-      return [key, this.crowi.configManager.getConfig('crowi', key)];
+      return [key, configManager.getConfig('crowi', key)];
     }));
 
     let zipFileStream: ReadStream;