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

Merge branch 'master' into dev/7.5.x

Yuki Takei 3 недель назад
Родитель
Сommit
358568c745
25 измененных файлов с 946 добавлено и 33 удалено
  1. 14 1
      CHANGELOG.md
  2. 13 11
      apps/app/bin/openapi/definition-apiv3.js
  3. 1 1
      apps/app/docker/README.md
  4. 12 0
      apps/app/public/static/locales/en_US/admin.json
  5. 12 0
      apps/app/public/static/locales/fr_FR/admin.json
  6. 12 0
      apps/app/public/static/locales/ko_KR/admin.json
  7. 12 0
      apps/app/public/static/locales/zh_CN/admin.json
  8. 238 0
      apps/app/src/client/components/Admin/MarkdownSetting/ContentDispositionSettings.tsx
  9. 4 0
      apps/app/src/client/components/Admin/MarkdownSetting/MarkDownSettingContents.tsx
  10. 14 10
      apps/app/src/client/components/PageHeader/PageHeader.tsx
  11. 82 0
      apps/app/src/client/services/admin-content-disposition.ts
  12. 4 0
      apps/app/src/interfaces/activity.ts
  13. 216 0
      apps/app/src/server/routes/apiv3/content-disposition-settings.ts
  14. 4 0
      apps/app/src/server/routes/apiv3/index.js
  15. 1 3
      apps/app/src/server/routes/apiv3/markdown-setting.js
  16. 1 1
      apps/app/src/server/routes/attachment/get.ts
  17. 21 0
      apps/app/src/server/service/config-manager/config-definition.ts
  18. 1 1
      apps/app/src/server/service/file-uploader/aws/index.ts
  19. 1 1
      apps/app/src/server/service/file-uploader/azure.ts
  20. 2 1
      apps/app/src/server/service/file-uploader/gcs/index.ts
  21. 1 1
      apps/app/src/server/service/file-uploader/local.ts
  22. 113 0
      apps/app/src/server/service/file-uploader/utils/headers.spec.ts
  23. 42 2
      apps/app/src/server/service/file-uploader/utils/headers.ts
  24. 102 0
      apps/app/src/server/service/file-uploader/utils/security.ts
  25. 23 0
      apps/app/src/states/ui/device.ts

+ 14 - 1
CHANGELOG.md

@@ -1,9 +1,22 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.4.5...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.4.6...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.4.6](https://github.com/growilabs/compare/v7.4.5...v7.4.6) - 2026-03-10
+
+### 🐛 Bug Fixes
+
+* fix: mobile editor page title display (#10712) @satof3
+* fix: Exclude user page data from search response when user pages are disabled (#10740) @arvid-e
+* fix(search): prevent Downshift from intercepting Home/End keys in search input (#10815) @yuki-takei
+* fix: Openai thread IDOR (#10806) @ryotaro-nagahara
+
+### 🧰 Maintenance
+
+* support(claude): Add SessionStart hook for Claude Code on the web (#10816) @yuki-takei
+
 ## [v7.4.5](https://github.com/growilabs/compare/v7.4.4...v7.4.5) - 2026-02-19
 
 ### 💎 Features

+ 13 - 11
apps/app/bin/openapi/definition-apiv3.js

@@ -51,6 +51,18 @@ module.exports = {
         name: 'x-growi-transfer-key',
       },
     },
+    parameters: {
+      MimeTypePathParam: {
+        name: 'mimeType',
+        in: 'path',
+        required: true,
+        description: 'The MIME type to configure.',
+        schema: {
+          type: 'string',
+          example: 'image/png',
+        },
+      },
+    },
   },
   'x-tagGroups': [
     {
@@ -66,21 +78,11 @@ module.exports = {
         'ShareLinks',
         'Users',
         'UserUISettings',
-        '',
       ],
     },
     {
       name: 'User Personal Settings API',
-      tags: [
-        'GeneralSetting',
-        'EditorSetting',
-        'InAppNotificationSettings',
-        '',
-        '',
-        '',
-        '',
-        '',
-      ],
+      tags: ['GeneralSetting', 'EditorSetting', 'InAppNotificationSettings'],
     },
     {
       name: 'System Management API',

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.4.5`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.5/apps/app/docker/Dockerfile)
+* [`7.4.6`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.6/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 

+ 12 - 0
apps/app/public/static/locales/en_US/admin.json

@@ -456,6 +456,18 @@
       "tag_names": "Tag names",
       "tag_attributes": "Tag attributes",
       "import_recommended": "Import recommended {{target}}"
+    },
+    "content-disposition_header": "Content-Disposition Mime Type Settings",
+    "content-disposition_options": {
+      "add_header": "Add Mime Types",
+      "note": "Note: Adding a mime type will automatically remove it from the other list.",
+      "inline_header": "Inline Mime Types",
+      "attachment_header": "Attachment Mime Types",
+      "inline_button": "Inline",
+      "attachment_button": "Attachment",
+      "no_inline": "No inline types set.",
+      "no_attachment": "No attachment types set.",
+      "remove_button": "Remove"
     }
   },
   "customize_settings": {

+ 12 - 0
apps/app/public/static/locales/fr_FR/admin.json

@@ -456,6 +456,18 @@
       "tag_names": "Nom de tags",
       "tag_attributes": "Attributs de tags",
       "import_recommended": "Importer {{target}}"
+    },
+    "content-disposition_header": "Paramètres des types MIME Content-Disposition",
+    "content-disposition_options": {
+      "add_header": "Ajouter des types MIME",
+      "note": "Note : L'ajout d'un type MIME le supprimera automatiquement de l'autre liste.",
+      "inline_header": "Types MIME Inline",
+      "attachment_header": "Types MIME en pièce jointe",
+      "inline_button": "Inline",
+      "attachment_button": "Pièce jointe",
+      "no_inline": "Aucun type inline défini.",
+      "no_attachment": "Aucun type de pièce jointe défini.",
+      "remove_button": "Supprimer"
     }
   },
   "customize_settings": {

+ 12 - 0
apps/app/public/static/locales/ko_KR/admin.json

@@ -456,6 +456,18 @@
       "tag_names": "태그 이름",
       "tag_attributes": "태그 속성",
       "import_recommended": "권장 {{target}} 가져오기"
+    },
+    "content-disposition_header": "Content-Disposition 마임 타입(MIME Type) 설정",
+    "content-disposition_options": {
+      "add_header": "마임 타입 추가",
+      "note": "참고: 마임 타입을 추가하면 다른 리스트에서 자동으로 제거됩니다.",
+      "inline_header": "인라인(Inline) 마임 타입",
+      "attachment_header": "파일 첨부(Attachment) 마임 타입",
+      "inline_button": "인라인",
+      "attachment_button": "파일 첨부",
+      "no_inline": "설정된 인라인 타입이 없습니다.",
+      "no_attachment": "설정된 파일 첨부 타입이 없습니다.",
+      "remove_button": "삭제"
     }
   },
   "customize_settings": {

+ 12 - 0
apps/app/public/static/locales/zh_CN/admin.json

@@ -465,6 +465,18 @@
       "tag_names": "标记名",
       "tag_attributes": "标记属性",
       "import_recommended": "导入建议 {{target}}"
+    },
+    "content-disposition_header": "Content-Disposition MIME 类型设置",
+    "content-disposition_options": {
+      "add_header": "添加 MIME 类型",
+      "note": "注意:添加 MIME 类型将自动从另一个列表中将其删除。",
+      "inline_header": "内联 (Inline) MIME 类型",
+      "attachment_header": "附件 (Attachment) MIME 类型",
+      "inline_button": "内联",
+      "attachment_button": "附件",
+      "no_inline": "未设置内联类型。",
+      "no_attachment": "未设置附件类型。",
+      "remove_button": "移除"
     }
   },
   "customize_settings": {

+ 238 - 0
apps/app/src/client/components/Admin/MarkdownSetting/ContentDispositionSettings.tsx

@@ -0,0 +1,238 @@
+import type React from 'react';
+import { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
+
+import {
+  type ContentDispositionSettings as ContentDispositionSettingsType,
+  useContentDisposition,
+} from '../../../services/admin-content-disposition';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+interface MimeTypeListProps {
+  title: string;
+  items: string[];
+  emptyText: string;
+  onRemove: (mimeType: string) => void;
+  removeLabel: string;
+  isUpdating: boolean;
+}
+
+const normalizeMimeType = (mimeType: string): string =>
+  mimeType.trim().toLowerCase();
+
+const MimeTypeList = ({
+  title,
+  items,
+  emptyText,
+  onRemove,
+  removeLabel,
+  isUpdating,
+}: MimeTypeListProps) => (
+  <div className="col-md-6 col-sm-12 mb-4">
+    <div className="card shadow-sm rounded-3">
+      <div className="card-header bg-transparent fw-bold">{title}</div>
+      <div className="card-body">
+        <ul className="list-group list-group-flush">
+          {items.length === 0 && (
+            <li className="list-group-item text-muted small border-0">
+              {emptyText}
+            </li>
+          )}
+          {items.map((m: string) => (
+            <li
+              key={m}
+              className="list-group-item d-flex justify-content-between align-items-center border-0 px-0"
+            >
+              <code>{m}</code>
+              <button
+                type="button"
+                className="btn btn-sm btn-outline-danger rounded-3"
+                onClick={() => onRemove(m)}
+                disabled={isUpdating}
+              >
+                {removeLabel}
+              </button>
+            </li>
+          ))}
+        </ul>
+      </div>
+    </div>
+  </div>
+);
+
+const ContentDispositionSettings: React.FC = () => {
+  const { t } = useTranslation('admin');
+  const { currentSettings, isLoading, isUpdating, updateSettings } =
+    useContentDisposition();
+
+  const [currentInput, setCurrentInput] = useState<string>('');
+  const [error, setError] = useState<string | null>(null);
+
+  const {
+    handleSubmit,
+    setValue,
+    watch,
+    reset,
+    formState: { isDirty },
+  } = useForm<ContentDispositionSettingsType>({
+    defaultValues: {
+      inlineMimeTypes: [],
+      attachmentMimeTypes: [],
+    },
+  });
+
+  useEffect(() => {
+    if (currentSettings) {
+      reset(currentSettings);
+    }
+  }, [currentSettings, reset]);
+
+  const inlineMimeTypes = watch('inlineMimeTypes');
+  const attachmentMimeTypes = watch('attachmentMimeTypes');
+
+  const handleSetMimeType = useCallback(
+    (disposition: 'inline' | 'attachment') => {
+      const mimeType = normalizeMimeType(currentInput);
+      if (!mimeType) return;
+
+      const otherDisposition =
+        disposition === 'inline' ? 'attachment' : 'inline';
+
+      const currentTargetList = watch(`${disposition}MimeTypes`);
+      const currentOtherList = watch(`${otherDisposition}MimeTypes`);
+
+      if (!currentTargetList.includes(mimeType)) {
+        setValue(`${disposition}MimeTypes`, [...currentTargetList, mimeType], {
+          shouldDirty: true,
+        });
+      }
+
+      setValue(
+        `${otherDisposition}MimeTypes`,
+        currentOtherList.filter((m) => m !== mimeType),
+        { shouldDirty: true },
+      );
+
+      setCurrentInput('');
+      setError(null);
+    },
+    [currentInput, setValue, watch],
+  );
+
+  const handleRemove = useCallback(
+    (mimeType: string, disposition: 'inline' | 'attachment') => {
+      const currentList = watch(`${disposition}MimeTypes`);
+      setValue(
+        `${disposition}MimeTypes`,
+        currentList.filter((m) => m !== mimeType),
+        { shouldDirty: true },
+      );
+    },
+    [setValue, watch],
+  );
+
+  const onSubmit = async (data: ContentDispositionSettingsType) => {
+    try {
+      setError(null);
+      await updateSettings(data);
+      reset(data);
+    } catch (err) {
+      setError((err as Error).message);
+    }
+  };
+
+  if (isLoading && !currentSettings) return <div>Loading...</div>;
+
+  return (
+    <div className="row">
+      <div className="col-12">
+        <h2 className="mb-4 border-0">
+          {t('markdown_settings.content-disposition_header')}
+        </h2>
+
+        <div className="card shadow-sm mb-4 rounded-3 border-0">
+          <div className="card-body">
+            <div className="form-group">
+              <label htmlFor="mime-type-input" className="form-label fw-bold">
+                {t('markdown_settings.content-disposition_options.add_header')}
+              </label>
+              <div className="d-flex align-items-center gap-2 mb-3">
+                <input
+                  type="text"
+                  className="form-control rounded-3 w-50"
+                  value={currentInput}
+                  onChange={(e) => setCurrentInput(e.target.value)}
+                  placeholder="e.g. image/png"
+                />
+                <button
+                  className="btn btn-primary px-3 flex-shrink-0 rounded-3 fw-bold"
+                  type="button"
+                  onClick={() => handleSetMimeType('inline')}
+                  disabled={!currentInput.trim() || isUpdating}
+                >
+                  {t(
+                    'markdown_settings.content-disposition_options.inline_button',
+                  )}
+                </button>
+                <button
+                  className="btn btn-primary text-white px-3 flex-shrink-0 rounded-3 fw-bold"
+                  type="button"
+                  onClick={() => handleSetMimeType('attachment')}
+                  disabled={!currentInput.trim() || isUpdating}
+                >
+                  {t(
+                    'markdown_settings.content-disposition_options.attachment_button',
+                  )}
+                </button>
+              </div>
+              <small className="form-text text-muted">
+                {t('markdown_settings.content-disposition_options.note')}
+              </small>
+            </div>
+          </div>
+        </div>
+
+        {error && <div className="alert alert-danger rounded-3">{error}</div>}
+
+        <div className="row">
+          <MimeTypeList
+            title={t(
+              'markdown_settings.content-disposition_options.inline_header',
+            )}
+            items={inlineMimeTypes}
+            emptyText={t(
+              'markdown_settings.content-disposition_options.no_inline',
+            )}
+            onRemove={(m) => handleRemove(m, 'inline')}
+            removeLabel={t(
+              'markdown_settings.content-disposition_options.remove_button',
+            )}
+            isUpdating={isUpdating}
+          />
+          <MimeTypeList
+            title={t(
+              'markdown_settings.content-disposition_options.attachment_header',
+            )}
+            items={attachmentMimeTypes}
+            emptyText={t(
+              'markdown_settings.content-disposition_options.no_attachment',
+            )}
+            onRemove={(m) => handleRemove(m, 'attachment')}
+            removeLabel={t(
+              'markdown_settings.content-disposition_options.remove_button',
+            )}
+            isUpdating={isUpdating}
+          />
+        </div>
+
+        <AdminUpdateButtonRow
+          onClick={handleSubmit(onSubmit)}
+          disabled={!isDirty || isUpdating}
+        />
+      </div>
+    </div>
+  );
+};
+
+export default ContentDispositionSettings;

+ 4 - 0
apps/app/src/client/components/Admin/MarkdownSetting/MarkDownSettingContents.tsx

@@ -8,6 +8,7 @@ import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
+import ContentDispositionSettings from './ContentDispositionSettings';
 import IndentForm from './IndentForm';
 import LineBreakForm from './LineBreakForm';
 import XssForm from './XssForm';
@@ -70,6 +71,9 @@ const MarkDownSettingContents = React.memo((props: Props): JSX.Element => {
         </CardBody>
       </Card>
       <XssForm />
+
+      {/* Content-Disposition Setting */}
+      <ContentDispositionSettings />
     </div>
   );
 });

+ 14 - 10
apps/app/src/client/components/PageHeader/PageHeader.tsx

@@ -1,6 +1,7 @@
 import { type JSX, useCallback, useEffect, useRef, useState } from 'react';
 
 import { useCurrentPageData } from '~/states/page';
+import { useDeviceLargerThanSm } from '~/states/ui/device';
 import { usePageControlsX } from '~/states/ui/page';
 
 import { PagePathHeader } from './PagePathHeader';
@@ -13,23 +14,26 @@ const moduleClass = styles['page-header'] ?? '';
 export const PageHeader = (): JSX.Element => {
   const currentPage = useCurrentPageData();
   const pageControlsX = usePageControlsX();
+  const [isLargerThanSm] = useDeviceLargerThanSm();
   const pageHeaderRef = useRef<HTMLDivElement>(null);
 
-  const [maxWidth, setMaxWidth] = useState<number>();
+  const [maxWidth, setMaxWidth] = useState<number>(300);
 
   const calcMaxWidth = useCallback(() => {
-    if (pageControlsX == null || pageHeaderRef.current == null) {
-      // Length that allows users to use PageHeader functionality.
-      setMaxWidth(300);
+    if (pageHeaderRef.current == null) {
       return;
     }
 
-    // PageControls.x - PageHeader.x
-    const maxWidth =
-      pageControlsX - pageHeaderRef.current.getBoundingClientRect().x;
-
-    setMaxWidth(maxWidth);
-  }, [pageControlsX]);
+    const pageHeaderX = pageHeaderRef.current.getBoundingClientRect().x;
+    setMaxWidth(
+      !isLargerThanSm
+        ? window.innerWidth - pageHeaderX
+        : pageControlsX != null
+          ? pageControlsX - pageHeaderX
+          : // Length that allows users to use PageHeader functionality.
+            300,
+    );
+  }, [isLargerThanSm, pageControlsX]);
 
   useEffect(() => {
     calcMaxWidth();

+ 82 - 0
apps/app/src/client/services/admin-content-disposition.ts

@@ -0,0 +1,82 @@
+import { useCallback } from 'react';
+import useSWR from 'swr';
+import useSWRMutation from 'swr/mutation';
+
+import { apiv3Get, apiv3Put } from '../util/apiv3-client';
+
+export interface ContentDispositionSettings {
+  inlineMimeTypes: string[];
+  attachmentMimeTypes: string[];
+}
+
+interface ContentDispositionGetResponse {
+  currentDispositionSettings: ContentDispositionSettings;
+}
+
+interface ContentDispositionUpdateRequest {
+  newInlineMimeTypes: string[];
+  newAttachmentMimeTypes: string[];
+}
+
+interface ContentDispositionUpdateResponse {
+  currentDispositionSettings: ContentDispositionSettings;
+}
+
+interface UseContentDisposition {
+  currentSettings: ContentDispositionSettings | undefined;
+  isLoading: boolean;
+  isUpdating: boolean;
+  updateSettings: (
+    newSettings: ContentDispositionSettings,
+  ) => Promise<ContentDispositionSettings>;
+}
+
+export const useContentDisposition = (): UseContentDisposition => {
+  const { data, isLoading, mutate } = useSWR(
+    '/content-disposition-settings/',
+    (endpoint) =>
+      apiv3Get<ContentDispositionGetResponse>(endpoint).then(
+        (res) => res.data.currentDispositionSettings,
+      ),
+  );
+
+  const { trigger, isMutating: isUpdating } = useSWRMutation(
+    '/content-disposition-settings/',
+    async (
+      endpoint: string,
+      { arg }: { arg: ContentDispositionUpdateRequest },
+    ) => {
+      const response = await apiv3Put<ContentDispositionUpdateResponse>(
+        endpoint,
+        arg,
+      );
+      return response.data.currentDispositionSettings;
+    },
+  );
+
+  const updateSettings = useCallback(
+    async (
+      newSettings: ContentDispositionSettings,
+    ): Promise<ContentDispositionSettings> => {
+      const request: ContentDispositionUpdateRequest = {
+        newInlineMimeTypes: newSettings.inlineMimeTypes,
+        newAttachmentMimeTypes: newSettings.attachmentMimeTypes,
+      };
+
+      const updatedData = await trigger(request);
+
+      // Update local cache and avoid an unnecessary extra GET request
+      await mutate(updatedData, { revalidate: false });
+
+      return updatedData;
+    },
+    [trigger, mutate],
+  );
+
+  return {
+    currentSettings: data,
+    isLoading,
+    isUpdating,
+    updateSettings,
+  };
+};

+ 4 - 0
apps/app/src/interfaces/activity.ts

@@ -95,6 +95,8 @@ const ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE =
   'ADMIN_FILE_UPLOAD_CONFIG_UPDATE';
 const ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE =
   'ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE';
+const ACTION_ADMIN_ATTACHMENT_DISPOSITION_UPDATE =
+  'ADMIN_ATTACHMENT_DISPOSITION_UPDATE';
 const ACTION_ADMIN_MAINTENANCEMODE_ENABLED = 'ADMIN_MAINTENANCEMODE_ENABLED';
 const ACTION_ADMIN_MAINTENANCEMODE_DISABLED = 'ADMIN_MAINTENANCEMODE_DISABLED';
 const ACTION_ADMIN_SECURITY_SETTINGS_UPDATE = 'ADMIN_SECURITY_SETTINGS_UPDATE';
@@ -285,6 +287,7 @@ export const SupportedAction = {
   ACTION_ADMIN_MAIL_OAUTH2_UPDATE,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+  ACTION_ADMIN_ATTACHMENT_DISPOSITION_UPDATE,
   ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
@@ -477,6 +480,7 @@ export const LargeActionGroup = {
   ACTION_ADMIN_MAIL_OAUTH2_UPDATE,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+  ACTION_ADMIN_ATTACHMENT_DISPOSITION_UPDATE,
   ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,

+ 216 - 0
apps/app/src/server/routes/apiv3/content-disposition-settings.ts

@@ -0,0 +1,216 @@
+import type { IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request } from 'express';
+import express from 'express';
+import { body } from 'express-validator';
+
+import { SupportedAction } from '~/interfaces/activity';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from './interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:content-disposition-settings');
+
+const router = express.Router();
+
+module.exports = (crowi) => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
+  const addActivity = generateAddActivityMiddleware();
+  const activityEvent = crowi.events.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.'),
+  ];
+
+  interface AuthorizedRequest extends Request {
+    user?: IUserHasId;
+  }
+
+  interface UpdateMimeTypesBody {
+    newInlineMimeTypes: string[];
+    newAttachmentMimeTypes: string[];
+  }
+
+  interface UpdateMimeTypesRequest extends Request {
+    user?: IUserHasId;
+    body: UpdateMimeTypesBody;
+  }
+
+  /**
+   * @swagger
+   *
+   * /content-disposition-settings/:
+   *   put:
+   *     tags: [Content-Disposition Settings]
+   *     summary: Replace content disposition settings for configurable MIME types with recieved lists.
+   *     security:
+   *       - cookieAuth: []
+   *     responses:
+   *       200:
+   *         description: Successfully set 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.put(
+    '/',
+    loginRequiredStrictly,
+    adminRequired,
+    validateUpdateMimeTypes,
+    apiV3FormValidator,
+    addActivity,
+
+    async (req: UpdateMimeTypesRequest, res: ApiV3Response) => {
+      const newInlineMimeTypes: string[] = req.body.newInlineMimeTypes;
+      const newAttachmentMimeTypes: string[] = req.body.newAttachmentMimeTypes;
+
+      // 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),
+      );
+
+      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),
+          },
+          'attachments:contentDisposition:attachmentMimeTypes': {
+            attachmentMimeTypes: Array.from(attachmentSet),
+          },
+        });
+
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_ATTACHMENT_DISPOSITION_UPDATE,
+          currentDispositionSettings: {
+            inlineMimeTypes: Array.from(inlineSet),
+            attachmentMimeTypes: Array.from(attachmentSet),
+          },
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({
+          currentDispositionSettings: {
+            inlineMimeTypes: Array.from(inlineSet),
+            attachmentMimeTypes: Array.from(attachmentSet),
+          },
+        });
+      } catch (err) {
+        const msg =
+          'Error occurred in updating content disposition for MIME types';
+        logger.error(msg, err);
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-content-disposition-failed'),
+        );
+      }
+    },
+  );
+
+  /**
+   * @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,
+    (req: AuthorizedRequest, res: ApiV3Response) => {
+      try {
+        const inlineDispositionSettings = configManager.getConfig(
+          'attachments:contentDisposition:inlineMimeTypes',
+        );
+        const attachmentDispositionSettings = configManager.getConfig(
+          'attachments:contentDisposition:attachmentMimeTypes',
+        );
+
+        return res.apiv3({
+          currentDispositionSettings: {
+            inlineMimeTypes: inlineDispositionSettings.inlineMimeTypes,
+            attachmentMimeTypes:
+              attachmentDispositionSettings.attachmentMimeTypes,
+          },
+        });
+      } 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;
+};

+ 4 - 0
apps/app/src/server/routes/apiv3/index.js

@@ -37,6 +37,10 @@ module.exports = (crowi, app) => {
   // admin
   routerForAdmin.use('/admin-home', require('./admin-home')(crowi));
   routerForAdmin.use('/markdown-setting', require('./markdown-setting')(crowi));
+  routerForAdmin.use(
+    '/content-disposition-settings',
+    require('./content-disposition-settings')(crowi),
+  );
   routerForAdmin.use('/app-settings', require('./app-settings')(crowi));
   routerForAdmin.use(
     '/customize-setting',

+ 1 - 3
apps/app/src/server/routes/apiv3/markdown-setting.js

@@ -1,5 +1,6 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
+import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
@@ -13,8 +14,6 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:markdown-setting');
 
-const express = require('express');
-
 const router = express.Router();
 
 const { body } = require('express-validator');
@@ -121,7 +120,6 @@ const validator = {
  *            type: boolean
  *            description: force indent size
  */
-
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
   const loginRequiredStrictly = loginRequiredFactory(crowi);

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

@@ -135,7 +135,7 @@ const respondForRelayMode = async (
   // apply content-* headers before response
   const isDownload = opts?.download ?? false;
   const contentHeaders = createContentHeaders(attachment, {
-    inline: !isDownload,
+    forceAttachment: isDownload,
   });
   applyHeaders(res, contentHeaders);
 

+ 21 - 0
apps/app/src/server/service/config-manager/config-definition.ts

@@ -79,6 +79,10 @@ export const CONFIG_KEYS = [
   'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest',
   'app:isReadOnlyForNewUser',
 
+  // Content-Disposition settings for MIME types
+  'attachments:contentDisposition:inlineMimeTypes',
+  'attachments:contentDisposition:attachmentMimeTypes',
+
   // Security Settings
   'security:wikiMode',
   'security:sessionMaxAge',
@@ -526,6 +530,23 @@ export const CONFIG_DEFINITIONS = {
     defaultValue: false,
   }),
 
+  // Attachment Content-Disposition settings
+  'attachments:contentDisposition:inlineMimeTypes': defineConfig<{
+    inlineMimeTypes: string[];
+  }>({
+    defaultValue: {
+      inlineMimeTypes: [],
+    },
+  }),
+
+  'attachments:contentDisposition:attachmentMimeTypes': defineConfig<{
+    attachmentMimeTypes: string[];
+  }>({
+    defaultValue: {
+      attachmentMimeTypes: [],
+    },
+  }),
+
   // Security Settings
   'security:wikiMode': defineConfig<string | undefined>({
     envVarName: 'FORCE_WIKI_MODE',

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

@@ -366,7 +366,7 @@ class AwsFileUploader extends AbstractFileUploader {
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
     const isDownload = opts?.download ?? false;
     const contentHeaders = createContentHeaders(attachment, {
-      inline: !isDownload,
+      forceAttachment: isDownload,
     });
     const params: GetObjectCommandInput = {
       Bucket: getS3Bucket(),

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

@@ -345,7 +345,7 @@ class AzureFileUploader extends AbstractFileUploader {
 
       const isDownload = opts?.download ?? false;
       const contentHeaders = createContentHeaders(attachment, {
-        inline: !isDownload,
+        forceAttachment: isDownload,
       });
 
       // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24

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

@@ -146,6 +146,7 @@ class GcsFileUploader extends AbstractFileUploader {
    * @inheritdoc
    */
   override determineResponseMode() {
+    // This is already correct in your provided code, using this.configManager
     return configManager.getConfig('gcs:referenceFileWithRelayMode')
       ? ResponseMode.RELAY
       : ResponseMode.REDIRECT;
@@ -265,7 +266,7 @@ class GcsFileUploader extends AbstractFileUploader {
     // https://cloud.google.com/storage/docs/access-control/signed-urls
     const isDownload = opts?.download ?? false;
     const contentHeaders = createContentHeaders(attachment, {
-      inline: !isDownload,
+      forceAttachment: isDownload,
     });
     const [signedUrl] = await file.getSignedUrl({
       action: 'read',

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

@@ -268,7 +268,7 @@ module.exports = (crowi: Crowi) => {
 
     const isDownload = opts?.download ?? false;
     const contentHeaders = createContentHeaders(attachment, {
-      inline: !isDownload,
+      forceAttachment: isDownload,
     });
     applyHeaders(res, [
       ...toExpressHttpHeaders(contentHeaders),

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

@@ -0,0 +1,113 @@
+import { beforeEach, describe, expect, it, vi } 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');
+    });
+  });
+});

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

@@ -3,6 +3,9 @@ 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'
@@ -10,13 +13,47 @@ type ContentHeaderField =
   | '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 },
+  opts?: { forceAttachment?: boolean },
 ): ContentHeader[] => {
   const headers: ContentHeader[] = [];
 
@@ -34,9 +71,12 @@ export const createContentHeaders = (
   });
 
   // Content-Disposition
+  const disposition = opts?.forceAttachment
+    ? 'attachment'
+    : 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

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

@@ -0,0 +1,102 @@
+export const defaultContentDispositionSettings: Record<
+  string,
+  'inline' | 'attachment'
+> = {
+  // Image Types
+  'image/jpeg': 'inline',
+  'image/png': 'inline',
+  'image/gif': 'inline',
+  'image/webp': 'inline',
+  'image/bmp': 'inline',
+  'image/tiff': 'inline',
+  'image/x-icon': 'inline',
+
+  // Document & Media Types
+  'application/pdf': 'inline',
+  'text/plain': 'inline',
+  'video/mp4': 'inline',
+  'video/webm': 'inline',
+  'video/ogg': 'inline',
+  'audio/mpeg': 'inline',
+  'audio/ogg': 'inline',
+  'audio/wav': 'inline',
+
+  // Potentially Dangerous / Executable / Scriptable Types
+  'text/html': 'attachment',
+  'text/javascript': 'attachment',
+  'application/javascript': 'attachment',
+  'image/svg+xml': 'attachment',
+  'application/xml': 'attachment',
+  'application/json': 'attachment',
+  'application/x-sh': 'attachment',
+  'application/x-msdownload': 'attachment',
+  'application/octet-stream': 'attachment',
+
+  // Other Common Document Formats
+  'application/msword': 'attachment',
+  'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
+    'attachment',
+  'application/vnd.ms-excel': 'attachment',
+  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
+    'attachment',
+  'application/vnd.ms-powerpoint': 'attachment',
+  'application/vnd.openxmlformats-officedocument.presentationml.presentation':
+    'attachment',
+  'application/zip': 'attachment',
+  'application/x-rar-compressed': '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',
+};

+ 23 - 0
apps/app/src/states/ui/device.ts

@@ -10,6 +10,7 @@ import { atom, useAtom } from 'jotai';
 export const isDeviceLargerThanXlAtom = atom(false);
 export const isDeviceLargerThanLgAtom = atom(false);
 export const isDeviceLargerThanMdAtom = atom(false);
+export const isDeviceLargerThanSmAtom = atom(false);
 export const isMobileAtom = atom(false);
 
 export const useDeviceLargerThanXl = () => {
@@ -78,6 +79,28 @@ export const useDeviceLargerThanMd = () => {
   return [isLargerThanMd, setIsLargerThanMd] as const;
 };
 
+export const useDeviceLargerThanSm = () => {
+  const [isLargerThanSm, setIsLargerThanSm] = useAtom(isDeviceLargerThanSmAtom);
+
+  useEffect(() => {
+    const smOrAboveHandler = function (this: MediaQueryList): void {
+      // xs -> sm: matches will be true
+      // sm -> xs: matches will be false
+      setIsLargerThanSm(this.matches);
+    };
+    const mql = addBreakpointListener(Breakpoint.SM, smOrAboveHandler);
+
+    // initialize
+    setIsLargerThanSm(mql.matches);
+
+    return () => {
+      cleanupBreakpointListener(mql, smOrAboveHandler);
+    };
+  }, [setIsLargerThanSm]);
+
+  return [isLargerThanSm, setIsLargerThanSm] as const;
+};
+
 export const useIsMobile = () => {
   const [isMobile, setIsMobile] = useAtom(isMobileAtom);