Просмотр исходного кода

Merge pull request #10658 from growilabs/feat/158232-167085-content-disposition-headers-logic

feat: content disposition headers logic
Yuki Takei 1 месяц назад
Родитель
Сommit
75d471818f

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

@@ -132,10 +132,7 @@ const respondForRelayMode = async (
   opts?: RespondOptions,
 ): Promise<void> => {
   // apply content-* headers before response
-  const isDownload = opts?.download ?? false;
-  const contentHeaders = createContentHeaders(attachment, {
-    inline: !isDownload,
-  });
+  const contentHeaders = createContentHeaders(attachment);
   applyHeaders(res, contentHeaders);
 
   try {

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

@@ -286,8 +286,7 @@ class AwsFileUploader extends AbstractFileUploader {
 
     // issue signed url (default: expires 120 seconds)
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
-    const isDownload = opts?.download ?? false;
-    const contentHeaders = createContentHeaders(attachment, { inline: !isDownload });
+    const contentHeaders = createContentHeaders(attachment);
     const params: GetObjectCommandInput = {
       Bucket: getS3Bucket(),
       Key: filePath,

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

@@ -274,8 +274,7 @@ class AzureFileUploader extends AbstractFileUploader {
       const expiresOn = new Date(now + lifetimeSecForTemporaryUrl * 1000);
       const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
 
-      const isDownload = opts?.download ?? false;
-      const contentHeaders = createContentHeaders(attachment, { inline: !isDownload });
+      const contentHeaders = createContentHeaders(attachment);
 
       // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
       // r:read, a:add, c:create, w:write, d:delete, l:list

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

@@ -217,8 +217,7 @@ class GcsFileUploader extends AbstractFileUploader {
 
     // issue signed url (default: expires 120 seconds)
     // https://cloud.google.com/storage/docs/access-control/signed-urls
-    const isDownload = opts?.download ?? false;
-    const contentHeaders = createContentHeaders(attachment, { inline: !isDownload });
+    const contentHeaders = createContentHeaders(attachment);
     const [signedUrl] = await file.getSignedUrl({
       action: 'read',
       expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,

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

@@ -249,8 +249,7 @@ module.exports = function(crowi: Crowi) {
     const internalPathRoot = configManager.getConfig('fileUpload:local:internalRedirectPath');
     const internalPath = urljoin(internalPathRoot, relativePath);
 
-    const isDownload = opts?.download ?? false;
-    const contentHeaders = createContentHeaders(attachment, { inline: !isDownload });
+    const contentHeaders = createContentHeaders(attachment);
     applyHeaders(res, [
       ...toExpressHttpHeaders(contentHeaders),
       { field: 'X-Accel-Redirect', value: internalPath },

+ 113 - 0
apps/app/src/server/service/file-uploader/utils/headers.spec.ts

@@ -0,0 +1,113 @@
+import {
+  vi, describe, it, expect, beforeEach,
+} from 'vitest';
+
+import { configManager } from '../../config-manager';
+
+import { determineDisposition } from './headers';
+
+vi.mock('../../config-manager', () => ({
+  configManager: {
+    getConfig: vi.fn(),
+  },
+}));
+
+describe('determineDisposition', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+  });
+
+  const setupMocks = (inlineMimeTypes: string[], attachmentMimeTypes: string[]) => {
+    vi.mocked(configManager.getConfig).mockImplementation(((key: string) => {
+      if (key === 'attachments:contentDisposition:inlineMimeTypes') {
+        return { inlineMimeTypes };
+      }
+      if (key === 'attachments:contentDisposition:attachmentMimeTypes') {
+        return { attachmentMimeTypes };
+      }
+      return {};
+    }) as typeof configManager.getConfig);
+  };
+
+  describe('priority: attachmentMimeTypes over inlineMimeTypes', () => {
+    it('should return attachment when MIME type is in both lists', () => {
+      setupMocks(['image/png'], ['image/png']);
+
+      const result = determineDisposition('image/png');
+
+      expect(result).toBe('attachment');
+    });
+  });
+
+  describe('case-insensitive matching', () => {
+    it('should match attachmentMimeTypes case-insensitively', () => {
+      setupMocks([], ['image/png']);
+
+      const result = determineDisposition('IMAGE/PNG');
+
+      expect(result).toBe('attachment');
+    });
+
+    it('should match inlineMimeTypes case-insensitively', () => {
+      setupMocks(['image/png'], []);
+
+      const result = determineDisposition('IMAGE/PNG');
+
+      expect(result).toBe('inline');
+    });
+
+    it('should match when config has uppercase MIME type', () => {
+      setupMocks(['IMAGE/PNG'], []);
+
+      const result = determineDisposition('image/png');
+
+      expect(result).toBe('inline');
+    });
+  });
+
+  describe('defaultContentDispositionSettings fallback', () => {
+    it('should return inline for image/png when not in admin config', () => {
+      setupMocks([], []);
+
+      const result = determineDisposition('image/png');
+
+      expect(result).toBe('inline');
+    });
+
+    it('should return attachment for text/html when not in admin config', () => {
+      setupMocks([], []);
+
+      const result = determineDisposition('text/html');
+
+      expect(result).toBe('attachment');
+    });
+  });
+
+  describe('unknown MIME types', () => {
+    it('should return attachment for unknown MIME types', () => {
+      setupMocks([], []);
+
+      const result = determineDisposition('application/x-unknown-type');
+
+      expect(result).toBe('attachment');
+    });
+  });
+
+  describe('admin config takes priority over defaults', () => {
+    it('should return attachment for image/png when in admin attachmentMimeTypes', () => {
+      setupMocks([], ['image/png']);
+
+      const result = determineDisposition('image/png');
+
+      expect(result).toBe('attachment');
+    });
+
+    it('should return inline for text/html when in admin inlineMimeTypes', () => {
+      setupMocks(['text/html'], []);
+
+      const result = determineDisposition('text/html');
+
+      expect(result).toBe('inline');
+    });
+  });
+});

+ 28 - 2
apps/app/src/server/service/file-uploader/utils/headers.ts

@@ -3,14 +3,39 @@ import type { Response } from 'express';
 import type { ExpressHttpHeader } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models/attachment';
 
+import { configManager } from '../../config-manager';
+
+import { defaultContentDispositionSettings } from './security';
+
 type ContentHeaderField = 'Content-Type' | 'Content-Security-Policy' | 'Content-Disposition' | 'Content-Length';
 type ContentHeader = ExpressHttpHeader<ContentHeaderField>;
 
+export const determineDisposition = (
+    fileFormat: string,
+): 'inline' | 'attachment' => {
+  const inlineMimeTypes = configManager.getConfig('attachments:contentDisposition:inlineMimeTypes').inlineMimeTypes;
+  const attachmentMimeTypes = configManager.getConfig('attachments:contentDisposition:attachmentMimeTypes').attachmentMimeTypes;
+
+  const normalizedFileFormat = fileFormat.toLowerCase();
+
+  if (attachmentMimeTypes.some(mimeType => mimeType.toLowerCase() === normalizedFileFormat)) {
+    return 'attachment';
+  }
+  if (inlineMimeTypes.some(mimeType => mimeType.toLowerCase() === normalizedFileFormat)) {
+    return 'inline';
+  }
+  const defaultSetting = defaultContentDispositionSettings[normalizedFileFormat];
+  if (defaultSetting != null) {
+    return defaultSetting;
+  }
+  return 'attachment';
+};
+
 /**
  * Factory function to generate content headers.
  * This approach avoids creating a class instance for each call, improving memory efficiency.
  */
-export const createContentHeaders = (attachment: IAttachmentDocument, opts?: { inline?: boolean }): ContentHeader[] => {
+export const createContentHeaders = (attachment: IAttachmentDocument): ContentHeader[] => {
   const headers: ContentHeader[] = [];
 
   // Content-Type
@@ -27,9 +52,10 @@ export const createContentHeaders = (attachment: IAttachmentDocument, opts?: { i
   });
 
   // Content-Disposition
+  const disposition = determineDisposition(attachment.fileFormat);
   headers.push({
     field: 'Content-Disposition',
-    value: `${opts?.inline ? 'inline' : 'attachment'};filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+    value: `${disposition};filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
   });
 
   // Content-Length