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

Merge pull request #10289 from growilabs/imprv/170631-content-disposition-api-improvement

imprv: Change API to update MIME types in inline and attachment lists
arvid-e 6 месяцев назад
Родитель
Сommit
ec854dc770

+ 127 - 108
apps/app/src/server/routes/apiv3/content-disposition-settings.ts

@@ -1,5 +1,5 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
-import { body, param } from 'express-validator';
+import { body } from 'express-validator';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -12,152 +12,126 @@ const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-const validator = {
-  updateContentDisposition: [
-    param('mimeType')
-      .exists()
-      .notEmpty()
-      .withMessage('MIME type is required')
-      .bail()
-      .matches(/^.+\/.+$/)
-      .custom((value) => {
-        const mimeTypeDefaults = configManager.getConfig('attachments:contentDisposition:mimeTypeOverrides');
-        return Object.keys(mimeTypeDefaults).includes(value);
-      })
-      .withMessage('Invalid or unconfigurable MIME type specified.'),
-
-    body('disposition')
-      .isIn(['inline', 'attachment']) // Validate that it's one of these two strings
-      .withMessage('`disposition` must be either "inline" or "attachment".'),
-  ],
-};
-
-/*
- * @swagger
- *
- *  components:
- *    schemas:
- *    parameters:
- *      - $ref: '#/components/parameters/MimeTypePathParam'
-  */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware();
   const addActivity = generateAddActivityMiddleware();
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
+  const validateUpdateMimeTypes = [
+    body('newInlineMimeTypes').exists().withMessage('Inline mime types field is required.').bail(),
+    body('newInlineMimeTypes').isArray().withMessage('Inline mime types must be an array.'),
+
+    body('newAttachmentMimeTypes').exists().withMessage('Attachment mime types field is required.').bail(),
+    body('newAttachmentMimeTypes').isArray().withMessage('Attachment mime types must be an array.'),
+  ];
+
+  type InlineMimeTypesConfig = { inlineMimeTypes: string[] };
+  type AttachmentMimeTypesConfig = { attachmentMimeTypes: string[] };
+
+  interface UpdateMimeTypesPayload {
+    newInlineMimeTypes: string[];
+    newAttachmentMimeTypes: string[];
+  }
+
+  const isArrayOfStrings = (arr: unknown): arr is string[] => {
+    if (!Array.isArray(arr)) {
+      return false;
+    }
+    return arr.every(item => typeof item === 'string');
+  };
+
+  const isUpdateMimeTypesPayload = (data: any): data is UpdateMimeTypesPayload => {
+    return isArrayOfStrings(data.newInlineMimeTypes)
+      && isArrayOfStrings(data.newAttachmentMimeTypes);
+  };
+
   /**
   /**
  * @swagger
  * @swagger
  *
  *
- * /content-disposition-settings:
- *   get:
+ * /content-disposition-settings/:
+ *   put:
  *     tags: [Content-Disposition Settings]
  *     tags: [Content-Disposition Settings]
- *     summary: Get content disposition settings for configurable MIME types
+ *     summary: Replace content disposition settings for configurable MIME types with recieved lists.
  *     security:
  *     security:
  *       - cookieAuth: []
  *       - cookieAuth: []
  *     responses:
  *     responses:
  *       200:
  *       200:
- *         description: Successfully retrieved content disposition settings.
+ *         description: Successfully set content disposition settings.
  *         content:
  *         content:
  *           application/json:
  *           application/json:
  *             schema:
  *             schema:
  *               type: object
  *               type: object
  *               properties:
  *               properties:
- *                 contentDispositionSettings:
+ *                 currentDispositionSettings:
  *                   type: object
  *                   type: object
- *                   additionalProperties:
- *                     type: string
- *                     description: inline or attachment
- *
- */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
-    try {
-
-      const mimeTypeDefaults = configManager.getConfig('attachments:contentDisposition:mimeTypeOverrides');
-      const contentDispositionSettings: Record<string, 'inline' | 'attachment'> = mimeTypeDefaults;
-
-      return res.apiv3({ contentDispositionSettings });
-    }
-    catch (err) {
-      logger.error('Error retrieving content disposition settings:', err);
-      return res.apiv3Err(new ErrorV3('Failed to retrieve content disposition settings', 'get-content-disposition-failed'));
-    }
-  });
-
-  /**
- * @swagger
+ *                   properties:
+ *                     inlineMimeTypes:
+ *                     type: array
+ *                     description: The list of MIME types set to inline.
+ *                     items:
+ *                       type: string
+ *                     attachmentMimeTypes:
+ *                     type: array
+ *                     description: The list of MIME types set to attachment.
+ *                     items:
+ *                       type: string
  *
  *
- * paths:
- *    /content-disposition-settings/{mimeType}:
- *      put:
- *        tags: [Content Disposition Settings]
- *        summary: Update content disposition setting for a specific MIME type
- *        security:
- *          - cookieAuth: []
- *        parameters:
- *          - $ref: '#/components/parameters/MimeTypePathParam'
- *        requestBody:
- *          required: true
- *          content:
- *            application/json:
- *              schema:
- *                type: object
- *                required:
- *                  - disposition
- *                properties:
- *                  disposition:
- *                     type: string
- *        responses:
- *          200:
- *            description: Successfully updated content disposition setting
- *            content:
- *              application/json:
- *                schema:
- *                  type: object
- *                  properties:
- *                    setting:
- *                       type: object
- *                       properties:
- *                         mimeType:
- *                           type: string
- *                         disposition:
- *                           type: string
  */
  */
   router.put(
   router.put(
-    '/:mimeType(*)',
+    '/',
     loginRequiredStrictly,
     loginRequiredStrictly,
     adminRequired,
     adminRequired,
-    addActivity,
-    validator.updateContentDisposition,
+    validateUpdateMimeTypes,
     apiV3FormValidator,
     apiV3FormValidator,
+    addActivity,
     async(req, res) => {
     async(req, res) => {
-      const { mimeType } = req.params;
-      const { disposition } = req.body;
 
 
-      try {
-        const currentMimeTypeDefaults = configManager.getConfig('attachments:contentDisposition:mimeTypeOverrides');
+      if (!isUpdateMimeTypesPayload(req.body)) {
+        return res.apiv3Err(new ErrorV3('Internal Type Error', 'internal-error'));
+      }
 
 
-        const newDisposition: 'inline' | 'attachment' = disposition;
+      const { newInlineMimeTypes } = req.body;
+      const { newAttachmentMimeTypes } = req.body;
 
 
-        const updatedMimeTypeDefaults = {
-          ...currentMimeTypeDefaults,
-          [mimeType]: newDisposition,
-        };
+      // Ensure no MIME type is in both lists.
+      const inlineSet = new Set(newInlineMimeTypes);
+      const attachmentSet = new Set(newAttachmentMimeTypes);
+      const intersection = [...inlineSet].filter(mimeType => attachmentSet.has(mimeType));
 
 
-        await configManager.updateConfigs({ 'attachments:contentDisposition:mimeTypeOverrides': updatedMimeTypeDefaults });
-        const updatedDispositionFromDb = configManager.getConfig('attachments:contentDisposition:mimeTypeOverrides')[mimeType];
+      if (intersection.length > 0) {
+        const msg = `MIME types cannot be in both inline and attachment lists: ${intersection.join(', ')}`;
+        return res.apiv3Err(new ErrorV3(msg, 'invalid-payload'));
+      }
+
+      try {
+        await configManager.updateConfigs({
+          'attachments:contentDisposition:inlineMimeTypes': {
+            inlineMimeTypes: Array.from(inlineSet),
+          } as InlineMimeTypesConfig,
+          'attachments:contentDisposition:attachmentMimeTypes': {
+            attachmentMimeTypes: Array.from(attachmentSet),
+          } as AttachmentMimeTypesConfig,
+        });
 
 
         const parameters = {
         const parameters = {
           action: SupportedAction.ACTION_ADMIN_ATTACHMENT_DISPOSITION_UPDATE,
           action: SupportedAction.ACTION_ADMIN_ATTACHMENT_DISPOSITION_UPDATE,
-          mimeType,
-          disposition: updatedDispositionFromDb,
+          currentDispositionSettings: {
+            inlineMimeTypes: Array.from(inlineSet),
+            attachmentMimeTypes: Array.from(attachmentSet),
+          },
         };
         };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
-        return res.apiv3({ mimeType, disposition: updatedDispositionFromDb });
+        return res.apiv3({
+          currentDispositionSettings: {
+            inlineMimeTypes: Array.from(inlineSet),
+            attachmentMimeTypes: Array.from(attachmentSet),
+          },
+        });
       }
       }
       catch (err) {
       catch (err) {
-        const msg = `Error occurred in updating content disposition for MIME type: ${mimeType}`;
+        const msg = 'Error occurred in updating content disposition for MIME types';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(
         return res.apiv3Err(
           new ErrorV3(msg, 'update-content-disposition-failed'),
           new ErrorV3(msg, 'update-content-disposition-failed'),
@@ -166,5 +140,50 @@ module.exports = (crowi) => {
     },
     },
   );
   );
 
 
+  /**
+ * @swagger
+ *
+ * /content-disposition-settings:
+ *   get:
+ *     tags: [Content-Disposition Settings]
+ *     summary: Get content disposition settings for configurable MIME types
+ *     security:
+ *       - cookieAuth: []
+ *     responses:
+ *       200:
+ *         description: Successfully retrieved content disposition settings.
+ *         content:
+ *           application/json:
+ *             schema:
+ *               type: object
+ *               properties:
+ *                 currentDispositionSettings:
+ *                   type: object
+ *                   properties:
+ *                     inlineMimeTypes:
+ *                       type: array
+ *                       description: The list of MIME types set to inline.
+ *                       items:
+ *                         type: string
+ *                     attachmentMimeTypes:
+ *                       type: array
+ *                       description: The list of MIME types set to attachment.
+ *                       items:
+ *                         type: string
+ *
+ */
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    try {
+      const inlineDispositionSettings = configManager.getConfig('attachments:contentDisposition:inlineMimeTypes');
+      const attachmentDispositionSettings = configManager.getConfig('attachments:contentDisposition:attachmentMimeTypes');
+
+      return res.apiv3({ inlineDispositionSettings, attachmentDispositionSettings });
+    }
+    catch (err) {
+      logger.error('Error retrieving content disposition settings:', err);
+      return res.apiv3Err(new ErrorV3('Failed to retrieve content disposition settings', 'get-content-disposition-failed'));
+    }
+  });
+
   return router;
   return router;
 };
 };

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

@@ -111,8 +111,7 @@ const respondForRedirectMode = async(res: Response, fileUploadService: FileUploa
 const respondForRelayMode = async(res: Response, fileUploadService: FileUploader,
 const respondForRelayMode = async(res: Response, fileUploadService: FileUploader,
     attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> => {
     attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> => {
   // apply content-* headers before response
   // apply content-* headers before response
-  const isDownload = opts?.download ?? false;
-  const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+  const contentHeaders = new ContentHeaders(attachment);
   applyHeaders(res, contentHeaders.toExpressHttpHeaders());
   applyHeaders(res, contentHeaders.toExpressHttpHeaders());
 
 
   try {
   try {

+ 10 - 8
apps/app/src/server/service/config-manager/config-definition.ts

@@ -79,7 +79,9 @@ export const CONFIG_KEYS = [
   'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest',
   'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest',
 
 
   // Content-Disposition settings for MIME types
   // Content-Disposition settings for MIME types
-  'attachments:contentDisposition:mimeTypeOverrides',
+  'attachments:contentDisposition:inlineMimeTypes',
+  'attachments:contentDisposition:attachmentMimeTypes',
+
 
 
   // Security Settings
   // Security Settings
   'security:wikiMode',
   'security:wikiMode',
@@ -542,17 +544,17 @@ export const CONFIG_DEFINITIONS = {
   }),
   }),
 
 
   // Attachment Content-Disposition settings
   // Attachment Content-Disposition settings
-  'attachments:contentDisposition:mimeTypeOverrides': defineConfig<Record<string, 'inline' | 'attachment'>>({
+  'attachments:contentDisposition:inlineMimeTypes': defineConfig<{ inlineMimeTypes: string[]; }>({
     defaultValue: {
     defaultValue: {
-      'text/html': 'attachment',
-      'image/svg+xml': 'attachment',
-      'application/pdf': 'attachment',
-      'application/json': 'attachment',
-      'text/csv': 'attachment',
-      'font/*': 'attachment',
+      inlineMimeTypes: [],
     },
     },
   }),
   }),
 
 
+  'attachments:contentDisposition:attachmentMimeTypes': defineConfig<{ attachmentMimeTypes: string[]; }>({
+    defaultValue: {
+      attachmentMimeTypes: [],
+    },
+  }),
 
 
   // Security Settings
   // Security Settings
   'security:wikiMode': defineConfig<string | undefined>({
   'security:wikiMode': defineConfig<string | undefined>({

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

@@ -251,8 +251,7 @@ class AwsFileUploader extends AbstractFileUploader {
 
 
     // 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 isDownload = opts?.download ?? false;
-    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const contentHeaders = new ContentHeaders(attachment);
     const params: GetObjectCommandInput = {
     const params: GetObjectCommandInput = {
       Bucket: getS3Bucket(),
       Bucket: getS3Bucket(),
       Key: filePath,
       Key: filePath,

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

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

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

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

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

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

+ 15 - 10
apps/app/src/server/service/file-uploader/utils/headers.ts

@@ -22,9 +22,6 @@ export class ContentHeaders implements IContentHeaders {
 
 
   constructor(
   constructor(
       attachment: IAttachmentDocument,
       attachment: IAttachmentDocument,
-      opts?: {
-        inline?: boolean,
-    },
   ) {
   ) {
     const attachmentContentType = attachment.fileFormat;
     const attachmentContentType = attachment.fileFormat;
     const filename = attachment.originalName;
     const filename = attachment.originalName;
@@ -38,18 +35,26 @@ export class ContentHeaders implements IContentHeaders {
 
 
     let finalDispositionValue: string;
     let finalDispositionValue: string;
 
 
-    const requestedInline = opts?.inline ?? false;
-    const mimeTypeOverrides = configManager.getConfig('attachments:contentDisposition:mimeTypeOverrides');
-    const overrideSetting = mimeTypeOverrides[mimeType];
+    const currentInlineMimeTypes = configManager.getConfig('attachments:contentDisposition:inlineMimeTypes');
+    const adminInlineMimeTypes = currentInlineMimeTypes.inlineMimeTypes;
+
+    const currentAttachmentMimeTypes = configManager.getConfig('attachments:contentDisposition:attachmentMimeTypes');
+    const adminAttachmentMimeTypes = currentAttachmentMimeTypes.attachmentMimeTypes;
 
 
-    if (overrideSetting) {
-      finalDispositionValue = overrideSetting;
-    }
 
 
+    // 1. Check for explicit admin override to 'inline'
+    if (adminInlineMimeTypes.includes(mimeType)) {
+      finalDispositionValue = 'inline';
+    }
+    // 2. Check for explicit admin override to 'attachment'
+    else if (adminAttachmentMimeTypes.includes(mimeType)) {
+      finalDispositionValue = 'attachment';
+    }
+    // 3. If no override, fall back to the default setting
     else {
     else {
       const defaultSetting = defaultContentDispositionSettings[mimeType];
       const defaultSetting = defaultContentDispositionSettings[mimeType];
 
 
-      if (defaultSetting === 'inline' && requestedInline) {
+      if (defaultSetting === 'inline') {
         finalDispositionValue = 'inline';
         finalDispositionValue = 'inline';
       }
       }
       else {
       else {

+ 55 - 0
apps/app/src/server/service/file-uploader/utils/security.ts

@@ -40,3 +40,58 @@ export const defaultContentDispositionSettings: Record<string, 'inline' | 'attac
   'application/x-rar-compressed': 'attachment',
   'application/x-rar-compressed': 'attachment',
   'text/csv': 'attachment',
   'text/csv': 'attachment',
 };
 };
+
+export const strictMimeTypeSettings: Record<string, 'inline' | 'attachment'> = {
+  // Documents
+  'application/pdf': 'attachment',
+  'application/json': 'attachment',
+  'text/plain': 'attachment',
+  'text/csv': 'attachment',
+  'text/html': 'attachment',
+
+  // Images
+  'image/jpeg': 'attachment',
+  'image/png': 'attachment',
+  'image/gif': 'attachment',
+  'image/webp': 'attachment',
+  'image/svg+xml': 'attachment',
+
+  // Audio and Video
+  'audio/mpeg': 'attachment',
+  'video/mp4': 'attachment',
+  'video/webm': 'attachment',
+
+  // Fonts
+  'font/woff2': 'attachment',
+  'font/woff': 'attachment',
+  'font/ttf': 'attachment',
+  'font/otf': 'attachment',
+};
+
+
+export const laxMimeTypeSettings: Record<string, 'inline' | 'attachment'> = {
+  // Documents
+  'application/pdf': 'inline',
+  'application/json': 'inline',
+  'text/plain': 'inline',
+  'text/csv': 'inline',
+  'text/html': 'attachment',
+
+  // Images
+  'image/jpeg': 'inline',
+  'image/png': 'inline',
+  'image/gif': 'inline',
+  'image/webp': 'inline',
+  'image/svg+xml': 'attachment',
+
+  // Audio and Video
+  'audio/mpeg': 'inline',
+  'video/mp4': 'inline',
+  'video/webm': 'inline',
+
+  // Fonts
+  'font/woff2': 'inline',
+  'font/woff': 'inline',
+  'font/ttf': 'inline',
+  'font/otf': 'inline',
+};