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

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

@@ -5,3 +5,16 @@ export const AttachmentType = {
 } as const;
 } as const;
 
 
 export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType];
 export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType];
+
+
+export type ExpressHttpHeader<Field = string> = {
+  field: Field,
+  value: string | string[]
+};
+
+export type IContentHeaders = {
+  contentType?: ExpressHttpHeader<'Content-Type'>;
+  contentLength?: ExpressHttpHeader<'Content-Length'>;
+  contentSecurityPolicy?: ExpressHttpHeader<'Content-Security-Policy'>;
+  contentDisposition?: ExpressHttpHeader<'Content-Disposition'>;
+}

+ 3 - 4
apps/app/src/server/routes/attachment/download.ts

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

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

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

+ 15 - 18
apps/app/src/server/routes/attachment/get.ts

@@ -8,6 +8,7 @@ import type {
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 import type { CrowiProperties, CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiProperties, CrowiRequest } from '~/interfaces/crowi-request';
+import { ExpressHttpHeader } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
@@ -15,6 +16,10 @@ import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify
 import { Attachment, type IAttachmentDocument } from '../../models';
 import { Attachment, type IAttachmentDocument } from '../../models';
 import ApiResponse from '../../util/apiResponse';
 import ApiResponse from '../../util/apiResponse';
 
 
+import {
+  toExpressHttpHeaders, ContentHeaders, applyHeaders,
+} from './utils/headers';
+
 
 
 const logger = loggerFactory('growi:routes:attachment:get');
 const logger = loggerFactory('growi:routes:attachment:get');
 
 
@@ -67,30 +72,21 @@ export const retrieveAttachmentFromIdParam = async(
 };
 };
 
 
 
 
-export const setCommonHeadersToRes = (res: Response, attachment: IAttachmentDocument): void => {
-  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';",
+export const generateHeadersForFresh = (attachment: IAttachmentDocument): ExpressHttpHeader[] => {
+  return toExpressHttpHeaders({
     ETag: `Attachment-${attachment._id}`,
     ETag: `Attachment-${attachment._id}`,
     'Last-Modified': attachment.createdAt.toUTCString(),
     'Last-Modified': attachment.createdAt.toUTCString(),
   });
   });
-
-  if (attachment.fileSize) {
-    res.set({
-      'Content-Length': attachment.fileSize,
-    });
-  }
 };
 };
 
 
 
 
-export const getActionFactory = (crowi: Crowi, attachment: IAttachmentDocument) => {
+export const getActionFactory = (crowi: Crowi, attachment: IAttachmentDocument, contentHeaders: ContentHeaders) => {
   return async(req: CrowiRequest, res: Response): Promise<void> => {
   return async(req: CrowiRequest, res: Response): Promise<void> => {
 
 
     const { fileUploadService } = crowi;
     const { fileUploadService } = crowi;
 
 
     // add headers before evaluating 'req.fresh'
     // add headers before evaluating 'req.fresh'
-    setCommonHeadersToRes(res, attachment);
+    applyHeaders(res, generateHeadersForFresh(attachment));
 
 
     // return 304 if request is "fresh"
     // return 304 if request is "fresh"
     // see: http://expressjs.com/en/5x/api.html#req.fresh
     // see: http://expressjs.com/en/5x/api.html#req.fresh
@@ -100,10 +96,13 @@ export const getActionFactory = (crowi: Crowi, attachment: IAttachmentDocument)
     }
     }
 
 
     if (fileUploadService.canRespond()) {
     if (fileUploadService.canRespond()) {
-      fileUploadService.respond(res, attachment);
+      fileUploadService.respond(res, attachment, contentHeaders);
       return;
       return;
     }
     }
 
 
+    // apply content-* headers before response
+    applyHeaders(res, contentHeaders.toExpressHttpHeaders());
+
     try {
     try {
       const readable = await fileUploadService.findDeliveryFile(attachment);
       const readable = await fileUploadService.findDeliveryFile(attachment);
       readable.pipe(res);
       readable.pipe(res);
@@ -144,11 +143,9 @@ export const getRouterFactory = (crowi: Crowi): Router => {
     (req: GetRequest, res: GetResponse) => {
     (req: GetRequest, res: GetResponse) => {
       const { attachment } = res.locals;
       const { attachment } = res.locals;
 
 
-      res.set({
-        'Content-Disposition': `inline;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
-      });
+      const contentHeaders = new ContentHeaders(attachment, { inline: true });
+      const getAction = getActionFactory(crowi, attachment, contentHeaders);
 
 
-      const getAction = getActionFactory(crowi, attachment);
       getAction(req, res);
       getAction(req, res);
     });
     });
 
 

+ 70 - 0
apps/app/src/server/routes/attachment/utils/headers.ts

@@ -0,0 +1,70 @@
+import type { Response } from 'express';
+
+import type { ExpressHttpHeader, IContentHeaders } from '~/server/interfaces/attachment';
+import type { IAttachmentDocument } from '~/server/models';
+
+
+export class ContentHeaders implements IContentHeaders {
+
+  contentType?: ExpressHttpHeader<'Content-Type'>;
+
+  contentLength?: ExpressHttpHeader<'Content-Length'>;
+
+  contentSecurityPolicy?: ExpressHttpHeader<'Content-Security-Policy'>;
+
+  contentDisposition?: ExpressHttpHeader<'Content-Disposition'>;
+
+  constructor(attachment: IAttachmentDocument, opts?: {
+    inline?: boolean,
+  }) {
+
+    this.contentType = {
+      field: 'Content-Type',
+      value: attachment.fileFormat,
+    };
+    this.contentSecurityPolicy = {
+      field: 'Content-Security-Policy',
+      // eslint-disable-next-line max-len
+      value: "script-src 'unsafe-hashes'; style-src 'self' 'unsafe-inline'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
+    };
+    this.contentDisposition = {
+      field: 'Content-Disposition',
+      value: `${opts?.inline ? 'inline' : 'attachment'};filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+    };
+
+    if (attachment.fileSize) {
+      this.contentLength = {
+        field: 'Content-Length',
+        value: attachment.fileSize.toString(),
+      };
+    }
+  }
+
+  /**
+   * Convert to ExpressHttpHeader[]
+   */
+  toExpressHttpHeaders(): ExpressHttpHeader[] {
+    return [
+      this.contentType,
+      this.contentLength,
+      this.contentSecurityPolicy,
+      this.contentDisposition,
+    ]
+      // exclude undefined
+      .filter((member): member is NonNullable<typeof member> => member != null);
+  }
+
+}
+
+/**
+ * Convert Record to ExpressHttpHeader[]
+ */
+export const toExpressHttpHeaders = (records: Record<string, string | string[]>): ExpressHttpHeader[] => {
+  return Object.entries(records).map(([field, value]) => { return { field, value } });
+};
+
+export const applyHeaders = (res: Response, headers: ExpressHttpHeader[]): void => {
+  headers.forEach((header) => {
+    res.header(header.field, header.value);
+  });
+};

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

@@ -12,6 +12,7 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import type { Response } from 'express';
 import type { Response } from 'express';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
+import type { IContentHeaders } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models';
 import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -77,7 +78,7 @@ class AwsFileUploader extends AbstractFileUploader {
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
-  override respond(res: Response, attachment: IAttachmentDocument): void {
+  override respond(res: Response, attachment: IAttachmentDocument, contentHeaders: IContentHeaders): void {
     throw new Error('Method not implemented.');
     throw new Error('Method not implemented.');
   }
   }
 
 
@@ -151,7 +152,7 @@ module.exports = (crowi) => {
     return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
     return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
   };
   };
 
 
-  lib.respond = async function(res, attachment: IAttachmentDocument) {
+  lib.respond = async function(res, attachment: IAttachmentDocument, contentHeaders: IContentHeaders) {
     if (!lib.getIsUploadable()) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
@@ -167,9 +168,12 @@ module.exports = (crowi) => {
 
 
     // issue signed url (default: expires 120 seconds)
     // issue signed url (default: expires 120 seconds)
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
+    const { contentType, contentDisposition } = contentHeaders;
     const params: GetObjectCommandInput = {
     const params: GetObjectCommandInput = {
       Bucket: awsConfig.bucket,
       Bucket: awsConfig.bucket,
       Key: filePath,
       Key: filePath,
+      ResponseContentType: contentType?.value.toString(),
+      ResponseContentDisposition: contentDisposition?.value.toString(),
     };
     };
     const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), {
     const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), {
       expiresIn: lifetimeSecForTemporaryUrl,
       expiresIn: lifetimeSecForTemporaryUrl,

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

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
 
 
 import type { Response } from 'express';
 import type { Response } from 'express';
 
 
+import type { IContentHeaders } from '~/server/interfaces/attachment';
 import { Attachment, type IAttachmentDocument } from '~/server/models';
 import { Attachment, type IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -34,7 +35,7 @@ export interface FileUploader {
   getTotalFileSize(): Promise<number>,
   getTotalFileSize(): Promise<number>,
   doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult>,
   doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult>,
   canRespond(): boolean
   canRespond(): boolean
-  respond(res: Response, attachment: IAttachmentDocument): void,
+  respond(res: Response, attachment: IAttachmentDocument, contentHeaders: IContentHeaders): void,
   findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>
   findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>
 }
 }
 
 
@@ -151,7 +152,7 @@ export abstract class AbstractFileUploader implements FileUploader {
   /**
   /**
    * Respond to the HTTP request.
    * Respond to the HTTP request.
    */
    */
-  abstract respond(res: Response, attachment: IAttachmentDocument): void;
+  abstract respond(res: Response, attachment: IAttachmentDocument, contentHeaders: IContentHeaders): void;
 
 
   /**
   /**
    * Find the file and Return ReadStream
    * Find the file and Return ReadStream