Yuki Takei 2 лет назад
Родитель
Сommit
070fd67183

+ 4 - 0
apps/app/src/server/interfaces/attachment.ts

@@ -18,3 +18,7 @@ export type IContentHeaders = {
   contentSecurityPolicy?: ExpressHttpHeader<'Content-Security-Policy'>;
   contentDisposition?: ExpressHttpHeader<'Content-Disposition'>;
 }
+
+export type RespondOptions = {
+  download?: boolean,
+}

+ 2 - 5
apps/app/src/server/routes/attachment/download.ts

@@ -11,7 +11,6 @@ import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify
 import {
   GetRequest, GetResponse, getActionFactory, retrieveAttachmentFromIdParam,
 } from './get';
-import { ContentHeaders } from './utils/headers';
 
 
 const logger = loggerFactory('growi:routes:attachment:download');
@@ -48,10 +47,8 @@ export const downloadRouterFactory = (crowi: Crowi): Router => {
         await crowi.activityService.createActivity(activityParameters);
       };
 
-      const contentHeaders = new ContentHeaders(attachment);
-      const getAction = getActionFactory(crowi, attachment, contentHeaders);
-
-      await getAction(req, res);
+      const getAction = getActionFactory(crowi, attachment);
+      await getAction(req, res, { download: true });
 
       createActivity();
     });

+ 1 - 4
apps/app/src/server/routes/attachment/get-brand-logo.ts

@@ -13,7 +13,6 @@ import { Attachment } from '../../models';
 import ApiResponse from '../../util/apiResponse';
 
 import { getActionFactory } from './get';
-import { ContentHeaders } from './utils/headers';
 
 
 const logger = loggerFactory('growi:routes:attachment:get-brand-logo');
@@ -33,9 +32,7 @@ export const getBrandLogoRouterFactory = (crowi: Crowi): Router => {
       return res.status(404).json(ApiResponse.error('Brand logo does not exist'));
     }
 
-    const contentHeaders = new ContentHeaders(brandLogoAttachment, { inline: true });
-    const getAction = getActionFactory(crowi, brandLogoAttachment, contentHeaders);
-
+    const getAction = getActionFactory(crowi, brandLogoAttachment);
     getAction(req, res);
   });
 

+ 10 - 11
apps/app/src/server/routes/attachment/get.ts

@@ -8,7 +8,7 @@ import type {
 import mongoose from 'mongoose';
 
 import type { CrowiProperties, CrowiRequest } from '~/interfaces/crowi-request';
-import { ExpressHttpHeader } from '~/server/interfaces/attachment';
+import type { ExpressHttpHeader, RespondOptions } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
@@ -80,10 +80,8 @@ export const generateHeadersForFresh = (attachment: IAttachmentDocument): Expres
 };
 
 
-export const getActionFactory = (crowi: Crowi, attachment: IAttachmentDocument, contentHeaders: ContentHeaders) => {
-  return async(req: CrowiRequest, res: Response): Promise<void> => {
-
-    const { fileUploadService } = crowi;
+export const getActionFactory = (crowi: Crowi, attachment: IAttachmentDocument) => {
+  return async(req: CrowiRequest, res: Response, opts?: RespondOptions): Promise<void> => {
 
     // add headers before evaluating 'req.fresh'
     applyHeaders(res, generateHeadersForFresh(attachment));
@@ -95,12 +93,16 @@ export const getActionFactory = (crowi: Crowi, attachment: IAttachmentDocument,
       return;
     }
 
-    if (fileUploadService.canRespond()) {
-      fileUploadService.respond(res, attachment, contentHeaders);
+    const { fileUploadService } = crowi;
+
+    if (fileUploadService.shouldDelegateToResponse()) {
+      fileUploadService.respond(res, attachment, opts);
       return;
     }
 
     // apply content-* headers before response
+    const isDownload = opts?.download ?? false;
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     applyHeaders(res, contentHeaders.toExpressHttpHeaders());
 
     try {
@@ -142,10 +144,7 @@ export const getRouterFactory = (crowi: Crowi): Router => {
 
     (req: GetRequest, res: GetResponse) => {
       const { attachment } = res.locals;
-
-      const contentHeaders = new ContentHeaders(attachment, { inline: true });
-      const getAction = getActionFactory(crowi, attachment, contentHeaders);
-
+      const getAction = getActionFactory(crowi, attachment);
       getAction(req, res);
     });
 

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

@@ -12,8 +12,9 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import type { Response } from 'express';
 import urljoin from 'url-join';
 
-import type { IContentHeaders } from '~/server/interfaces/attachment';
+import type { RespondOptions } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models';
+import { ContentHeaders } from '~/server/routes/attachment/utils/headers';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
@@ -78,7 +79,7 @@ class AwsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: IAttachmentDocument, contentHeaders: IContentHeaders): void {
+  override respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void {
     throw new Error('Method not implemented.');
   }
 
@@ -148,17 +149,22 @@ module.exports = (crowi) => {
       && configManager.getConfig('crowi', 'aws:s3Bucket') != null;
   };
 
-  lib.canRespond = function() {
+  lib.shouldDelegateToResponse = function() {
     return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
   };
 
-  lib.respond = async function(res, attachment: IAttachmentDocument, contentHeaders: IContentHeaders) {
+  lib.respond = async function(res, attachment, opts?) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
-    const temporaryUrl = attachment.getValidTemporaryUrl();
-    if (temporaryUrl != null) {
-      return res.redirect(temporaryUrl);
+
+    const isDownload = opts?.download ?? false;
+
+    if (!isDownload) {
+      const temporaryUrl = attachment.getValidTemporaryUrl();
+      if (temporaryUrl != null) {
+        return res.redirect(temporaryUrl);
+      }
     }
 
     const s3 = S3Factory();
@@ -168,6 +174,7 @@ module.exports = (crowi) => {
 
     // issue signed url (default: expires 120 seconds)
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     const { contentType, contentDisposition } = contentHeaders;
     const params: GetObjectCommandInput = {
       Bucket: awsConfig.bucket,

+ 25 - 4
apps/app/src/server/service/file-uploader/azure.ts

@@ -17,6 +17,7 @@ import {
 } from '@azure/storage-blob';
 import type { Response } from 'express';
 
+import type { RespondOptions } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
@@ -71,7 +72,7 @@ class AzureFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: IAttachmentDocument): void {
+  override respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void {
     throw new Error('Method not implemented.');
   }
 
@@ -144,19 +145,39 @@ module.exports = (crowi) => {
       && configManager.getConfig('crowi', 'azure:storageContainerName') != null;
   };
 
-  lib.canRespond = function() {
+  lib.shouldDelegateToResponse = function() {
     return !configManager.getConfig('crowi', 'azure:referenceFileWithRelayMode');
   };
 
-  lib.respond = async function(res, attachment) {
+  lib.respond = async function(res, attachment, opts) {
+    if (!lib.getIsUploadable()) {
+      throw new Error('Azure Blob is not configured.');
+    }
+
+    const isDownload = opts?.download ?? false;
+
+    if (!isDownload) {
+      const temporaryUrl = attachment.getValidTemporaryUrl();
+      if (temporaryUrl != null) {
+        return res.redirect(temporaryUrl);
+      }
+    }
+
     const containerClient = await getContainerClient();
     const filePath = getFilePathOnStorage(attachment);
-    const blockBlobClient: BlockBlobClient = await containerClient.getBlockBlobClient(filePath);
+    const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
     const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'azure:lifetimeSecForTemporaryUrl');
 
     const sasToken = await getSasToken(lifetimeSecForTemporaryUrl);
     const signedUrl = `${blockBlobClient.url}?${sasToken}`;
 
+    // TODO: re-impl using generateSasUrl
+    // const contentHeaders = new ContentHeaders(attachment, { inline: true });
+    // const signedUrl = blockBlobClient.generateSasUrl({
+    //   contentType: contentHeaders.contentType?.value.toString(),
+    //   contentDisposition: contentHeaders.contentDisposition?.value.toString(),
+    // });
+
     res.redirect(signedUrl);
 
     try {

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

@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
 
 import type { Response } from 'express';
 
-import type { IContentHeaders } from '~/server/interfaces/attachment';
+import type { RespondOptions } from '~/server/interfaces/attachment';
 import { Attachment, type IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
@@ -34,8 +34,8 @@ export interface FileUploader {
   getFileUploadTotalLimit(): number,
   getTotalFileSize(): Promise<number>,
   doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult>,
-  canRespond(): boolean
-  respond(res: Response, attachment: IAttachmentDocument, contentHeaders: IContentHeaders): void,
+  shouldDelegateToResponse(): boolean
+  respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
   findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>
 }
 
@@ -145,14 +145,14 @@ export abstract class AbstractFileUploader implements FileUploader {
   /**
    * Checks if Uploader can respond to the HTTP request.
    */
-  canRespond(): boolean {
+  shouldDelegateToResponse(): boolean {
     return false;
   }
 
   /**
    * Respond to the HTTP request.
    */
-  abstract respond(res: Response, attachment: IAttachmentDocument, contentHeaders: IContentHeaders): void;
+  abstract respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void;
 
   /**
    * Find the file and Return ReadStream

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

@@ -3,7 +3,9 @@ import type { Response } from 'express';
 import urljoin from 'url-join';
 
 import type Crowi from '~/server/crowi';
+import type { RespondOptions } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models';
+import { ContentHeaders } from '~/server/routes/attachment/utils/headers';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
@@ -47,7 +49,7 @@ class GcsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: IAttachmentDocument): void {
+  override respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void {
     throw new Error('Method not implemented.');
   }
 
@@ -107,17 +109,22 @@ module.exports = function(crowi: Crowi) {
       && configManager.getConfig('crowi', 'gcs:bucket') != null;
   };
 
-  lib.canRespond = function() {
+  lib.shouldDelegateToResponse = function() {
     return !configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode');
   };
 
-  lib.respond = async function(res, attachment) {
+  lib.respond = async function(res, attachment, opts) {
     if (!lib.getIsUploadable()) {
       throw new Error('GCS is not configured.');
     }
-    const temporaryUrl = attachment.getValidTemporaryUrl();
-    if (temporaryUrl != null) {
-      return res.redirect(temporaryUrl);
+
+    const isDownload = opts?.download ?? false;
+
+    if (!isDownload) {
+      const temporaryUrl = attachment.getValidTemporaryUrl();
+      if (temporaryUrl != null) {
+        return res.redirect(temporaryUrl);
+      }
     }
 
     const gcs = getGcsInstance();
@@ -128,9 +135,12 @@ module.exports = function(crowi: Crowi) {
 
     // issue signed url (default: expires 120 seconds)
     // https://cloud.google.com/storage/docs/access-control/signed-urls
+    const contentHeaders = new ContentHeaders(attachment, { inline: true });
     const [signedUrl] = await file.getSignedUrl({
       action: 'read',
       expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
+      responseType: contentHeaders.contentType?.value.toString(),
+      responseDisposition: contentHeaders.contentDisposition?.value.toString(),
     });
 
     res.redirect(signedUrl);

+ 2 - 1
apps/app/src/server/service/file-uploader/gridfs.ts

@@ -5,6 +5,7 @@ import type { Response } from 'express';
 import mongoose from 'mongoose';
 import { createModel } from 'mongoose-gridfs';
 
+import type { RespondOptions } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
@@ -49,7 +50,7 @@ class GridfsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: IAttachmentDocument): void {
+  override respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void {
     throw new Error('Method not implemented.');
   }
 

+ 14 - 4
apps/app/src/server/service/file-uploader/local.js

@@ -1,5 +1,9 @@
 import { Readable } from 'stream';
 
+import {
+  ContentHeaders, applyHeaders,
+  ExpressHttpHeaders,
+} from '~/server/routes/attachment/utils/headers';
 import loggerFactory from '~/utils/logger';
 
 import { AbstractFileUploader } from './file-uploader';
@@ -129,7 +133,7 @@ module.exports = function(crowi) {
   /**
    * Checks if Uploader can respond to the HTTP request.
    */
-  lib.canRespond = function() {
+  lib.shouldDelegateToResponse = function() {
     // Check whether to use internal redirect of nginx or Apache.
     return configManager.getConfig('crowi', 'fileUpload:local:useInternalRedirect');
   };
@@ -139,14 +143,20 @@ module.exports = function(crowi) {
    * @param {Response} res
    * @param {Response} attachment
    */
-  lib.respond = function(res, attachment) {
+  lib.respond = function(res, attachment, opts) {
     // Responce using internal redirect of nginx or Apache.
     const storagePath = getFilePathOnStorage(attachment);
     const relativePath = path.relative(crowi.publicDir, storagePath);
     const internalPathRoot = configManager.getConfig('crowi', 'fileUpload:local:internalRedirectPath');
     const internalPath = urljoin(internalPathRoot, relativePath);
-    res.set('X-Accel-Redirect', internalPath);
-    res.set('X-Sendfile', storagePath);
+
+    const contentHeaders = new ContentHeaders(attachment, { inline: true });
+    applyHeaders(res, [
+      ...contentHeaders.toExpressHttpHeaders(),
+      { field: 'X-Accel-Redirect', value: internalPath },
+      { field: 'X-Sendfile', value: storagePath },
+    ]);
+
     return res.end();
   };