فهرست منبع

Merge branch 'dev/7.4.x' into feat/page-tree-virtualization

Yuki Takei 4 ماه پیش
والد
کامیت
2f301f4a44
42فایلهای تغییر یافته به همراه1902 افزوده شده و 1019 حذف شده
  1. 2 0
      apps/app/.eslintrc.js
  2. 1 1
      apps/app/package.json
  3. 5 1
      apps/app/public/static/locales/en_US/admin.json
  4. 5 1
      apps/app/public/static/locales/fr_FR/admin.json
  5. 6 1
      apps/app/public/static/locales/ja_JP/admin.json
  6. 5 1
      apps/app/public/static/locales/ko_KR/admin.json
  7. 5 1
      apps/app/public/static/locales/zh_CN/admin.json
  8. 5 8
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  9. 6 1
      apps/app/src/client/components/Admin/UserManagement.tsx
  10. 39 0
      apps/app/src/client/components/Admin/Users/UserStatisticsTable.tsx
  11. 4 4
      apps/app/src/client/components/Page/DisplaySwitcher.tsx
  12. 133 74
      apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx
  13. 0 6
      apps/app/src/client/services/AdminAppContainer.js
  14. 10 0
      apps/app/src/client/services/AdminUsersContainer.js
  15. 1 1
      apps/app/src/client/services/side-effects/page-updated.ts
  16. 1 10
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  17. 0 0
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.module.scss
  18. 1 1
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx
  19. 16 2
      apps/app/src/components/Layout/RawLayout.tsx
  20. 8 5
      apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx
  21. 20 13
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  22. 3 4
      apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts
  23. 2 1
      apps/app/src/pages/[[...path]]/index.page.tsx
  24. 7 2
      apps/app/src/pages/[[...path]]/use-same-route-navigation.ts
  25. 3 4
      apps/app/src/pages/general-page/configuration-props.ts
  26. 72 37
      apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.integ.ts
  27. 173 94
      apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.ts
  28. 339 175
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  29. 16 11
      apps/app/src/server/routes/apiv3/page/check-page-existence.ts
  30. 154 65
      apps/app/src/server/routes/apiv3/page/create-page.ts
  31. 71 50
      apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts
  32. 23 13
      apps/app/src/server/routes/apiv3/page/get-yjs-data.ts
  33. 501 296
      apps/app/src/server/routes/apiv3/page/index.ts
  34. 19 14
      apps/app/src/server/routes/apiv3/page/publish-page.ts
  35. 60 40
      apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts
  36. 19 14
      apps/app/src/server/routes/apiv3/page/unpublish-page.ts
  37. 153 56
      apps/app/src/server/routes/apiv3/page/update-page.ts
  38. 1 0
      apps/app/src/services/renderer/recommended-whitelist.ts
  39. 3 0
      apps/app/src/states/page/hydrate.ts
  40. 0 2
      biome.json
  41. 1 1
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/TableButton.tsx
  42. 9 9
      pnpm-lock.yaml

+ 2 - 0
apps/app/.eslintrc.js

@@ -57,6 +57,8 @@ module.exports = {
     'src/server/routes/apiv3/user/**',
     'src/server/routes/apiv3/personal-setting/**',
     'src/server/routes/apiv3/security-settings/**',
+    'src/server/routes/apiv3/app-settings/**',
+    'src/server/routes/apiv3/page/**',
     'src/server/routes/apiv3/*.ts',
   ],
   settings: {

+ 1 - 1
apps/app/package.json

@@ -248,7 +248,7 @@
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "uuid": "^11.0.3",
-    "validator": "^13.15.20",
+    "validator": "^13.15.22",
     "ws": "^8.17.1",
     "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",

+ 5 - 1
apps/app/public/static/locales/en_US/admin.json

@@ -796,7 +796,11 @@
     "unset": "No",
     "related_username": "Related user's ",
     "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
-    "current_users": "Current users:"
+    "user_statistics": {
+      "total": "Total Users",
+      "active": "Active",
+      "inactive": "Inactive"
+    }
   },
   "user_group_management": {
     "user_group_management": "User Group Management",

+ 5 - 1
apps/app/public/static/locales/fr_FR/admin.json

@@ -796,7 +796,11 @@
     "unset": "Non",
     "related_username": "Utilisateur ",
     "cannot_invite_maximum_users": "La limite maximale d'utilisateurs invitables est atteinte.",
-    "current_users": "Utilisateurs:"
+    "user_statistics": {
+      "total": "Utilisateurs totaux",
+      "active": "Actifs",
+      "inactive": "Inactifs"
+    }
   },
   "user_group_management": {
     "user_group_management": "Gestion des groupes",

+ 6 - 1
apps/app/public/static/locales/ja_JP/admin.json

@@ -805,8 +805,13 @@
     "unset": "未設定",
     "related_username": "関連付けられているユーザーの ",
     "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。",
-    "current_users": "現在のユーザー数:"
+    "user_statistics": {
+      "total": "総ユーザー数",
+      "active": "アクティブ",
+      "inactive": "非アクティブ"
+    }
   },
+
   "user_group_management": {
     "user_group_management": "グループ管理",
     "create_group": "新規グループの作成",

+ 5 - 1
apps/app/public/static/locales/ko_KR/admin.json

@@ -796,7 +796,11 @@
     "unset": "아니요",
     "related_username": "관련 사용자 ",
     "cannot_invite_maximum_users": "최대 사용자 수 이상을 초대할 수 없습니다.",
-    "current_users": "현재 사용자:"
+    "user_statistics": {
+      "total": "총 사용자",
+      "active": "활성",
+      "inactive": "비활성"
+    }
   },
   "user_group_management": {
     "user_group_management": "사용자 그룹 관리",

+ 5 - 1
apps/app/public/static/locales/zh_CN/admin.json

@@ -805,7 +805,11 @@
     "unset": "否",
     "related_username": "相关用户的",
     "cannot_invite_maximum_users": "邀请的用户数不能超过最大值。",
-    "current_users": "当前用户:"
+    "user_statistics": {
+      "total": "用户总数",
+      "active": "活跃",
+      "inactive": "非活跃"
+    }
   },
   "user_group_management": {
     "user_group_management": "用户组管理",

+ 5 - 8
apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx

@@ -108,15 +108,12 @@ const AppSettingsPageContents = (props: Props) => {
         </div>
       </div>
 
-      {/* TODO: Enable configuring bulk export for GROWI.cloud when it can be relased for cloud (https://redmine.weseek.co.jp/issues/163220) */}
-      {!adminAppContainer.state.isBulkExportDisabledForCloud && (
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.page_bulk_export_settings')}</h2>
-            <PageBulkExportSettings />
-          </div>
+      <div className="row mt-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:app_setting.page_bulk_export_settings')}</h2>
+          <PageBulkExportSettings />
         </div>
-      )}
+      </div>
 
       <div className="row">
         <div className="col-lg-12">

+ 6 - 1
apps/app/src/client/components/Admin/UserManagement.tsx

@@ -13,6 +13,7 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 
 import InviteUserControl from './Users/InviteUserControl';
 import PasswordResetModal from './Users/PasswordResetModal';
+import UserStatisticsTable from './Users/UserStatisticsTable';
 import UserTable from './Users/UserTable';
 
 import styles from './UserManagement.module.scss';
@@ -40,7 +41,8 @@ const UserManagement = (props: UserManagementProps) => {
   // for Next routing
   useEffect(() => {
     pagingHandler(1);
-  }, [pagingHandler]);
+    adminUsersContainer.retrieveUserStatistics();
+  }, [pagingHandler, adminUsersContainer]);
 
   const validateToggleStatus = (statusType: string) => {
     return (adminUsersContainer.isSelected(statusType)) ? (
@@ -134,6 +136,9 @@ const UserManagement = (props: UserManagementProps) => {
       </p>
 
       <h2>{t('user_management.user_management')}</h2>
+      <UserStatisticsTable
+        userStatistics={adminUsersContainer.state.userStatistics}
+      />
       <div className="border-top border-bottom">
 
         <div className="row d-flex justify-content-start align-items-center my-2">

+ 39 - 0
apps/app/src/client/components/Admin/Users/UserStatisticsTable.tsx

@@ -0,0 +1,39 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+type UserStatistics = {
+  total: number;
+  active: { total: number };
+  inactive: { total: number };
+};
+
+type Props = {
+  userStatistics?: UserStatistics | null;
+};
+
+const UserStatisticsTable: React.FC<Props> = ({ userStatistics }) => {
+  const { t } = useTranslation('admin');
+  if (userStatistics == null) return null;
+
+  return (
+    <table className="table table-bordered w-100">
+      <tbody>
+        <tr>
+          <th className="col-sm-4 align-top">{t('user_management.user_statistics.total')}</th>
+          <td className="align-top">{ userStatistics.total }</td>
+        </tr>
+        <tr>
+          <th className="col-sm-4 align-top">{t('user_management.user_statistics.active')}</th>
+          <td className="align-top">{ userStatistics.active.total }</td>
+        </tr>
+        <tr>
+          <th className="col-sm-4 align-top">{t('user_management.user_statistics.inactive')}</th>
+          <td className="align-top">{ userStatistics.inactive.total }</td>
+        </tr>
+      </tbody>
+    </table>
+  );
+};
+
+export default UserStatisticsTable;

+ 4 - 4
apps/app/src/client/components/Page/DisplaySwitcher.tsx

@@ -3,9 +3,8 @@ import type { JSX } from 'react';
 import dynamic from 'next/dynamic';
 
 import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
-import { useIsEditable } from '~/states/page';
+import { useIsEditable, useRevisionIdFromUrl } from '~/states/page';
 import { EditorMode, useEditorMode, useReservedNextCaretLine } from '~/states/ui/editor';
-import { useSWRxIsLatestRevision } from '~/stores/page';
 
 import { LazyRenderer } from '../Common/LazyRenderer';
 
@@ -18,14 +17,15 @@ export const DisplaySwitcher = (): JSX.Element => {
 
   const { editorMode } = useEditorMode();
   const isEditable = useIsEditable();
-  const { data: isLatestRevision } = useSWRxIsLatestRevision();
+  const revisionIdFromUrl = useRevisionIdFromUrl();
 
   useHashChangedEffect();
   useReservedNextCaretLine();
 
   return (
     <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>
-      { isLatestRevision !== false
+      {/* Display <PageEditorReadOnly /> when the user is intentionally viewing a specific (past) revision. */}
+      { revisionIdFromUrl == null
         ? <PageEditor />
         : <PageEditorReadOnly />
       }

+ 133 - 74
apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, useCallback, useMemo, type JSX,
+  useState, useCallback, useMemo, useEffect, type JSX,
 } from 'react';
 
 import { MarkdownTable, useHandsontableModalForEditorStatus, useHandsontableModalForEditorActions } from '@growi/editor';
@@ -12,7 +12,6 @@ import {
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
-
 import { replaceFocusedMarkdownTableWithEditor, getMarkdownTable } from '~/client/components/PageEditor/markdown-table-util-for-editor';
 import { useHandsontableModalActions, useHandsontableModalStatus } from '~/states/ui/modal/handsontable';
 
@@ -30,22 +29,33 @@ const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
   '': '',
 };
 
-export const HandsontableModalSubstance = (): JSX.Element => {
-
-  const { t } = useTranslation('commons');
-  const handsontableModalData = useHandsontableModalStatus();
-  const { close: closeHandsontableModal } = useHandsontableModalActions();
-  const handsontableModalForEditorData = useHandsontableModalForEditorStatus();
-  const { close: closeHandsontableModalForEditor } = useHandsontableModalForEditorActions();
+type HandsontableModalSubstanceProps = {
+  initialTable: MarkdownTable | undefined;
+  autoFormatMarkdownTable: boolean;
+  isWindowExpanded: boolean;
+  onSave: (table: MarkdownTable) => void;
+  onCancel: () => void;
+  expandWindow: () => void;
+  contractWindow: () => void;
+};
 
-  const isOpened = handsontableModalData?.isOpened ?? false;
-  const isOpendInEditor = handsontableModalForEditorData?.isOpened ?? false;
-  const table = handsontableModalData?.table;
-  const autoFormatMarkdownTable = handsontableModalData?.autoFormatMarkdownTable ?? false;
-  const editor = handsontableModalForEditorData?.editor;
-  const onSave = handsontableModalData?.onSave;
+/**
+ * HandsontableModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
+ */
+const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX.Element => {
+  const {
+    initialTable,
+    autoFormatMarkdownTable,
+    isWindowExpanded,
+
+    // Handlers
+    onSave,
+    onCancel,
+    expandWindow,
+    contractWindow,
+  } = props;
 
-  // Memoize default table creation
+  const { t } = useTranslation('commons');
   const defaultMarkdownTable = useMemo(() => {
     return new MarkdownTable(
       [
@@ -91,7 +101,6 @@ export const HandsontableModalSubstance = (): JSX.Element => {
   const [hotTable, setHotTable] = useState<HotTable | null>();
   const [hotTableContainer, setHotTableContainer] = useState<HTMLDivElement | null>();
   const [isDataImportAreaExpanded, setIsDataImportAreaExpanded] = useState<boolean>(false);
-  const [isWindowExpanded, setIsWindowExpanded] = useState<boolean>(false);
   const [markdownTable, setMarkdownTable] = useState<MarkdownTable>(defaultMarkdownTable);
   const [markdownTableOnInit, setMarkdownTableOnInit] = useState<MarkdownTable>(defaultMarkdownTable);
   const [handsontableHeight, setHandsontableHeight] = useState<number>(DEFAULT_HOT_HEIGHT);
@@ -112,36 +121,22 @@ export const HandsontableModalSubstance = (): JSX.Element => {
     debounce(100, handleWindowExpandedChange)
   ), [handleWindowExpandedChange]);
 
-  // Memoize modal open handler
-  const handleModalOpen = useCallback(() => {
-    const markdownTableState = table == null && editor != null ? getMarkdownTable(editor) : table;
-    const initTableInstance = markdownTableState == null ? defaultMarkdownTable : markdownTableState.clone();
-    setMarkdownTable(markdownTableState ?? defaultMarkdownTable);
+  // Initialize table data when component mounts (modal opens)
+  useEffect(() => {
+    const initTableInstance = initialTable == null ? defaultMarkdownTable : initialTable.clone();
+    setMarkdownTable(initialTable ?? defaultMarkdownTable);
     setMarkdownTableOnInit(initTableInstance);
     debouncedHandleWindowExpandedChange();
-  }, [table, editor, defaultMarkdownTable, debouncedHandleWindowExpandedChange]);
+  }, [debouncedHandleWindowExpandedChange, defaultMarkdownTable, initialTable]); // Run only on mount
 
-  // Memoize expand/contract handlers
-  const expandWindow = useCallback(() => {
-    setIsWindowExpanded(true);
-    debouncedHandleWindowExpandedChange();
-  }, [debouncedHandleWindowExpandedChange]);
-
-  const contractWindow = useCallback(() => {
-    setIsWindowExpanded(false);
-    // Set the height to the default value
-    setHandsontableHeight(DEFAULT_HOT_HEIGHT);
+  // Update handsontable size when window expansion changes
+  useEffect(() => {
+    if (!isWindowExpanded) {
+      // Reset height to default when contracted
+      setHandsontableHeight(DEFAULT_HOT_HEIGHT);
+    }
     debouncedHandleWindowExpandedChange();
-  }, [debouncedHandleWindowExpandedChange]);
-
-  const markdownTableOption = {
-    get latest() {
-      return {
-        align: [].concat(markdownTable.options.align),
-        pad: autoFormatMarkdownTable !== false,
-      };
-    },
-  };
+  }, [isWindowExpanded, debouncedHandleWindowExpandedChange]);
 
   /**
    * Reset table data to initial value
@@ -154,36 +149,30 @@ export const HandsontableModalSubstance = (): JSX.Element => {
     setMarkdownTable(markdownTableOnInit.clone());
   };
 
-  const cancel = () => {
-    closeHandsontableModal();
-    closeHandsontableModalForEditor();
+  const cancel = useCallback(() => {
     setIsDataImportAreaExpanded(false);
-    setIsWindowExpanded(false);
-  };
+    contractWindow();
+    onCancel();
+  }, [contractWindow, onCancel]);
 
-  const save = () => {
+  const save = useCallback(() => {
     if (hotTable == null) {
       return;
     }
 
+    const markdownTableOption = {
+      align: [].concat(markdownTable.options.align),
+      pad: autoFormatMarkdownTable !== false,
+    };
+
     const newMarkdownTable = new MarkdownTable(
       hotTable.hotInstance.getData(),
-      markdownTableOption.latest,
+      markdownTableOption,
     ).normalizeCells();
 
-    // onSave is passed only when editing table directly from the page.
-    if (onSave != null) {
-      onSave(newMarkdownTable);
-      cancel();
-      return;
-    }
-
-    if (editor == null) {
-      return;
-    }
-    replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
+    onSave(newMarkdownTable);
     cancel();
-  };
+  }, [hotTable, markdownTable.options.align, autoFormatMarkdownTable, onSave, cancel]);
 
   const beforeColumnResizeHandler = (currentColumn) => {
     /*
@@ -458,16 +447,7 @@ export const HandsontableModalSubstance = (): JSX.Element => {
   );
 
   return (
-    <Modal
-      isOpen={isOpened || isOpendInEditor}
-      toggle={cancel}
-      backdrop="static"
-      keyboard={false}
-      size="lg"
-      wrapClassName={`${styles['grw-handsontable']}`}
-      className={`handsontable-modal ${isWindowExpanded && 'grw-modal-expanded'}`}
-      onOpened={handleModalOpen}
-    >
+    <>
       <ModalHeader tag="h4" toggle={cancel} close={closeButton}>
         {t('handsontable_modal.title')}
       </ModalHeader>
@@ -531,10 +511,89 @@ export const HandsontableModalSubstance = (): JSX.Element => {
           <button type="button" className="btn btn-primary" onClick={save}>{t('handsontable_modal.done')}</button>
         </div>
       </ModalFooter>
-    </Modal>
+    </>
   );
 };
 
+/**
+ * HandsontableModal - Container component (lightweight, always rendered)
+ * Handles both View and Editor modes
+ */
 export const HandsontableModal = (): JSX.Element => {
-  return <HandsontableModalSubstance />;
+  // for View
+  const handsontableModalData = useHandsontableModalStatus();
+  const { close: closeHandsontableModal } = useHandsontableModalActions();
+
+  // for Editor
+  const handsontableModalForEditorData = useHandsontableModalForEditorStatus();
+  const { close: closeHandsontableModalForEditor } = useHandsontableModalForEditorActions();
+
+  const isOpenedForView = handsontableModalData.isOpened;
+  const isOpenedForEditor = handsontableModalForEditorData.isOpened;
+  const isOpened = isOpenedForView || isOpenedForEditor;
+
+  const [isWindowExpanded, setIsWindowExpanded] = useState<boolean>(false);
+
+  // Determine initial table based on mode
+  const initialTable = useMemo(() => {
+    if (isOpenedForEditor) {
+      const editor = handsontableModalForEditorData.editor;
+      return editor != null ? getMarkdownTable(editor) : undefined;
+    }
+    return handsontableModalData.table;
+  }, [isOpenedForEditor, handsontableModalForEditorData.editor, handsontableModalData.table]);
+
+  // Determine autoFormatMarkdownTable based on mode
+  const autoFormatMarkdownTable = handsontableModalData.autoFormatMarkdownTable ?? false;
+
+  const toggle = useCallback(() => {
+    closeHandsontableModal();
+    closeHandsontableModalForEditor();
+    setIsWindowExpanded(false);
+  }, [closeHandsontableModal, closeHandsontableModalForEditor]);
+
+  const expandWindow = useCallback(() => {
+    setIsWindowExpanded(true);
+  }, []);
+
+  const contractWindow = useCallback(() => {
+    setIsWindowExpanded(false);
+  }, []);
+
+  // Create save handler based on mode
+  const handleSave = useCallback((newMarkdownTable: MarkdownTable) => {
+    if (isOpenedForEditor) {
+      const editor = handsontableModalForEditorData.editor;
+      if (editor != null) {
+        replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
+      }
+    }
+    else {
+      handsontableModalData.onSave?.(newMarkdownTable);
+    }
+  }, [isOpenedForEditor, handsontableModalForEditorData.editor, handsontableModalData]);
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={toggle}
+      backdrop="static"
+      keyboard={false}
+      size="lg"
+      wrapClassName={`${styles['grw-handsontable']}`}
+      className={`handsontable-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''}`}
+    >
+      {isOpened && (
+        <HandsontableModalSubstance
+          initialTable={initialTable}
+          autoFormatMarkdownTable={autoFormatMarkdownTable}
+          onSave={handleSave}
+          onCancel={toggle}
+          isWindowExpanded={isWindowExpanded}
+          expandWindow={expandWindow}
+          contractWindow={contractWindow}
+        />
+      )}
+    </Modal>
+  );
 };

+ 0 - 6
apps/app/src/client/services/AdminAppContainer.js

@@ -41,9 +41,6 @@ export default class AdminAppContainer extends Container {
       sesSecretAccessKey: '',
 
       isMaintenanceMode: false,
-
-      // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
-      isBulkExportDisabledForCloud: false,
     };
 
   }
@@ -84,9 +81,6 @@ export default class AdminAppContainer extends Container {
       sesSecretAccessKey: appSettingsParams.sesSecretAccessKey,
 
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
-
-      // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
-      isBulkExportDisabledForCloud: appSettingsParams.isBulkExportDisabledForCloud,
     });
   }
 

+ 10 - 0
apps/app/src/client/services/AdminUsersContainer.js

@@ -34,6 +34,7 @@ export default class AdminUsersContainer extends Container {
       pagingLimit: Infinity,
       selectedStatusList: new Set(['all']),
       searchText: '',
+      userStatistics: null,
     };
 
     this.showPasswordResetModal = this.showPasswordResetModal.bind(this);
@@ -158,6 +159,15 @@ export default class AdminUsersContainer extends Container {
 
   }
 
+  /**
+ * retrieve user statistics
+ */
+  async retrieveUserStatistics() {
+    const statsRes = await apiv3Get('/statistics/user');
+    const userStatistics = statsRes.data.data;
+    this.setState({ userStatistics });
+  }
+
   /**
    * create user invited
    * @memberOf AdminUsersContainer

+ 1 - 1
apps/app/src/client/services/side-effects/page-updated.ts

@@ -45,7 +45,7 @@ export const usePageUpdatedEffect = (): void => {
 
       // !!CAUTION!! Timing of calling openPageStatusAlert may clash with components/PageEditor/conflict.tsx
       if (isRevisionOutdated && editorMode === EditorMode.View) {
-        openPageStatusAlert({ hideEditorMode: EditorMode.Editor, onRefleshPage: fetchCurrentPage });
+        openPageStatusAlert({ hideEditorMode: EditorMode.Editor, onRefleshPage: () => fetchCurrentPage({ force: true }) });
       }
 
       // Clear cache

+ 1 - 10
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -5,21 +5,12 @@ import { pagePathUtils } from '@growi/core/dist/utils';
 import LinkedPagePath from '~/models/linked-page-path';
 
 import { PagePathHierarchicalLink } from '../PagePathHierarchicalLink';
+import { Separator } from '.';
 import type { PagePathNavLayoutProps } from './PagePathNavLayout';
 import { PagePathNavLayout } from './PagePathNavLayout';
 
-import styles from './PagePathNav.module.scss';
-
 const { isTrashPage } = pagePathUtils;
 
-const Separator = ({ className }: { className?: string }): JSX.Element => {
-  return (
-    <span className={`separator ${className ?? ''} ${styles['grw-mx-02em']}`}>
-      /
-    </span>
-  );
-};
-
 export const PagePathNav = (props: PagePathNavLayoutProps): JSX.Element => {
   const { pagePath } = props;
 

+ 0 - 0
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss → apps/app/src/components/Common/PagePathNav/PagePathNavLayout.module.scss


+ 1 - 1
apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx

@@ -3,7 +3,7 @@ import dynamic from 'next/dynamic';
 
 import { usePageNotFound } from '~/states/page';
 
-import styles from './PagePathNav.module.scss';
+import styles from './PagePathNavLayout.module.scss';
 
 const moduleClass = styles['grw-page-path-nav-layout'] ?? '';
 

+ 16 - 2
apps/app/src/components/Layout/RawLayout.tsx

@@ -29,9 +29,23 @@ type Props = {
 
 export const RawLayout = ({ children, className }: Props): JSX.Element => {
   const classNames: string[] = ['layout-root', 'growi'];
-  if (className != null) {
-    classNames.push(className);
+
+  // Use state to handle SSR/CSR className mismatch
+  // Using state ensures React properly updates the DOM after hydration
+  const [dynamicClassName, setDynamicClassName] = useState<string | undefined>(
+    undefined,
+  );
+
+  useIsomorphicLayoutEffect(() => {
+    if (className !== dynamicClassName) {
+      setDynamicClassName(className);
+    }
+  }, [className, dynamicClassName]);
+
+  if (dynamicClassName != null) {
+    classNames.push(dynamicClassName);
   }
+
   // get color scheme from next-themes
   const { resolvedTheme, resolvedThemeByAttributes } = useNextThemes();
 

+ 8 - 5
apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx

@@ -3,14 +3,17 @@ import { useRouter } from 'next/router';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'react-i18next';
 
-import { useCurrentPageData, useFetchCurrentPage } from '~/states/page';
-import { useSWRxIsLatestRevision } from '~/stores/page';
+import {
+  useCurrentPageData,
+  useFetchCurrentPage,
+  useRevisionIdFromUrl,
+} from '~/states/page';
 
 export const OldRevisionAlert = (): JSX.Element => {
   const router = useRouter();
   const { t } = useTranslation();
 
-  const { data: isLatestRevision } = useSWRxIsLatestRevision();
+  const revisionIdFromUrl = useRevisionIdFromUrl();
   const page = useCurrentPageData();
   const { fetchCurrentPage } = useFetchCurrentPage();
 
@@ -24,8 +27,8 @@ export const OldRevisionAlert = (): JSX.Element => {
     fetchCurrentPage({ force: true });
   }, [fetchCurrentPage, page, router]);
 
-  // Show alert only when viewing an old revision (isLatestRevision === false)
-  if (isLatestRevision !== false) {
+  // Show alert only when intentionally viewing a specific (past) revision (revisionIdFromUrl != null)
+  if (revisionIdFromUrl == null) {
     // biome-ignore lint/complexity/noUselessFragments: ignore
     return <></>;
   }

+ 20 - 13
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -1,4 +1,4 @@
-import type { JSX } from 'react';
+import type { AnchorHTMLAttributes, JSX } from 'react';
 import type { LinkProps } from 'next/link';
 import Link from 'next/link';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -8,7 +8,7 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:components:NextLink');
 
-const isAnchorLink = (href: string): boolean => {
+const hasAnchorLink = (href: string): boolean => {
   return href.toString().length > 0 && href[0] === '#';
 };
 
@@ -34,15 +34,16 @@ const isCreatablePage = (href: string) => {
   }
 };
 
-type Props = Omit<LinkProps, 'href'> & {
-  children: React.ReactNode;
-  id?: string;
-  href?: string;
-  className?: string;
-};
+type Props = AnchorHTMLAttributes<HTMLAnchorElement> &
+  Omit<LinkProps, 'href'> & {
+    children: React.ReactNode;
+    id?: string;
+    href?: string;
+    className?: string;
+  };
 
 export const NextLink = (props: Props): JSX.Element => {
-  const { id, href, children, className, onClick, ...rest } = props;
+  const { id, href, children, className, target, onClick, ...rest } = props;
 
   const siteUrl = useSiteUrl();
 
@@ -56,7 +57,7 @@ export const NextLink = (props: Props): JSX.Element => {
     Object.entries(rest).filter(([key]) => key.startsWith('data-')),
   );
 
-  if (isExternalLink(href, siteUrl)) {
+  if (isExternalLink(href, siteUrl) || target === '_blank') {
     return (
       <a
         id={id}
@@ -67,19 +68,25 @@ export const NextLink = (props: Props): JSX.Element => {
         rel="noopener noreferrer"
         {...dataAttributes}
       >
-        {children}&nbsp;
-        <span className="growi-custom-icons">external_link</span>
+        {children}
+        {target === '_blank' && (
+          <span style={{ userSelect: 'none' }}>
+            &nbsp;
+            <span className="growi-custom-icons">external_link</span>
+          </span>
+        )}
       </a>
     );
   }
 
   // when href is an anchor link or not-creatable path
-  if (isAnchorLink(href) || !isCreatablePage(href)) {
+  if (hasAnchorLink(href) || !isCreatablePage(href) || target != null) {
     return (
       <a
         id={id}
         href={href}
         className={className}
+        target={target}
         onClick={onClick}
         {...dataAttributes}
       >

+ 3 - 4
apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts

@@ -22,10 +22,9 @@ class CheckPageBulkExportJobInProgressCronService extends CronService {
   }
 
   override async executeJob(): Promise<void> {
-    // TODO: remove growiCloudUri condition when bulk export can be relased for GROWI.cloud (https://redmine.weseek.co.jp/issues/163220)
-    const isBulkExportPagesEnabled =
-      configManager.getConfig('app:isBulkExportPagesEnabled') &&
-      configManager.getConfig('app:growiCloudUri') == null;
+    const isBulkExportPagesEnabled = configManager.getConfig(
+      'app:isBulkExportPagesEnabled',
+    );
     if (!isBulkExportPagesEnabled) return;
 
     const pageBulkExportJobInProgress = await PageBulkExportJob.findOne({

+ 2 - 1
apps/app/src/pages/[[...path]]/index.page.tsx

@@ -111,7 +111,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   const pageMeta = isInitialProps(props) ? props.pageWithMeta?.meta : undefined;
 
   useHydratePageAtoms(pageData, pageMeta, {
-    redirectFrom: props.redirectFrom ?? undefined,
+    redirectFrom: props.redirectFrom,
+    isIdenticalPath: props.isIdenticalPathPage,
     templateTags: props.templateTagData,
     templateBody: props.templateBodyData,
   });

+ 7 - 2
apps/app/src/pages/[[...path]]/use-same-route-navigation.ts

@@ -1,7 +1,7 @@
 import { useEffect } from 'react';
 import { useRouter } from 'next/router';
 
-import { useFetchCurrentPage } from '~/states/page';
+import { useFetchCurrentPage, useIsIdenticalPath } from '~/states/page';
 import { useSetEditingMarkdown } from '~/states/ui/editor';
 
 /**
@@ -12,11 +12,16 @@ import { useSetEditingMarkdown } from '~/states/ui/editor';
  */
 export const useSameRouteNavigation = (): void => {
   const router = useRouter();
+
+  const isIdenticalPath = useIsIdenticalPath();
   const { fetchCurrentPage } = useFetchCurrentPage();
   const setEditingMarkdown = useSetEditingMarkdown();
 
   // useEffect to trigger data fetching when the path changes
   useEffect(() => {
+    // If the path is identical, do not fetch
+    if (isIdenticalPath) return;
+
     const fetch = async () => {
       const pageData = await fetchCurrentPage({ path: router.asPath });
       if (pageData?.revision?.body != null) {
@@ -24,5 +29,5 @@ export const useSameRouteNavigation = (): void => {
       }
     };
     fetch();
-  }, [router.asPath, fetchCurrentPage, setEditingMarkdown]);
+  }, [router.asPath, isIdenticalPath, fetchCurrentPage, setEditingMarkdown]);
 };

+ 3 - 4
apps/app/src/pages/general-page/configuration-props.ts

@@ -102,10 +102,9 @@ export const getServerSideGeneralPageProps: GetServerSideProps<
         isUploadAllFileAllowed: fileUploadService.getFileUploadEnabled(),
         isUploadEnabled: fileUploadService.getIsUploadable(),
 
-        // TODO: remove growiCloudUri condition when bulk export can be relased for GROWI.cloud (https://redmine.weseek.co.jp/issues/163220)
-        isBulkExportPagesEnabled:
-          configManager.getConfig('app:isBulkExportPagesEnabled') &&
-          configManager.getConfig('app:growiCloudUri') == null,
+        isBulkExportPagesEnabled: configManager.getConfig(
+          'app:isBulkExportPagesEnabled',
+        ),
         isPdfBulkExportEnabled:
           configManager.getConfig('app:pageBulkExportPdfConverterUri') != null,
         isLocalAccountRegistrationEnabled:

+ 72 - 37
apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.integ.ts

@@ -17,25 +17,34 @@ const mockActivityId = '507f1f77bcf86cd799439011';
 mockRequire.stopAll();
 
 mockRequire('~/server/middlewares/access-token-parser', {
-  accessTokenParser: () => (_req: Request, _res: ApiV3Response, next: () => void) => next(),
+  accessTokenParser:
+    () => (_req: Request, _res: ApiV3Response, next: () => void) =>
+      next(),
 });
 
-mockRequire('../../../middlewares/login-required', () => (_req: Request, _res: ApiV3Response, next: () => void) => next());
-mockRequire('../../../middlewares/admin-required', () => (_req: Request, _res: ApiV3Response, next: () => void) => next());
+mockRequire(
+  '../../../middlewares/login-required',
+  () => (_req: Request, _res: ApiV3Response, next: () => void) => next(),
+);
+mockRequire(
+  '../../../middlewares/admin-required',
+  () => (_req: Request, _res: ApiV3Response, next: () => void) => next(),
+);
 
 mockRequire('../../../middlewares/add-activity', {
-  generateAddActivityMiddleware: () => (_req: Request, res: ApiV3Response, next: () => void) => {
-    res.locals = res.locals || {};
-    res.locals.activity = { _id: mockActivityId };
-    next();
-  },
+  generateAddActivityMiddleware:
+    () => (_req: Request, res: ApiV3Response, next: () => void) => {
+      res.locals = res.locals || {};
+      res.locals.activity = { _id: mockActivityId };
+      next();
+    },
 });
 
 describe('file-upload-setting route', () => {
   let app: express.Application;
   let crowiMock: Crowi;
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     // Initialize configManager for each test
     const s2sMessagingServiceMock = mock<S2sMessagingService>();
     configManager.setS2sMessagingService(s2sMessagingServiceMock);
@@ -59,14 +68,16 @@ describe('file-upload-setting route', () => {
     // Mock apiv3 response methods
     app.use((_req, res, next) => {
       const apiRes = res as ApiV3Response;
-      apiRes.apiv3 = data => res.json(data);
-      apiRes.apiv3Err = (error, statusCode = 500) => res.status(statusCode).json({ error });
+      apiRes.apiv3 = (data) => res.json(data);
+      apiRes.apiv3Err = (error, statusCode = 500) =>
+        res.status(statusCode).json({ error });
       next();
     });
 
     // Import and mount the actual router using dynamic import
     const fileUploadSettingModule = await import('./file-upload-setting');
-    const fileUploadSettingRouterFactory = (fileUploadSettingModule as any).default || fileUploadSettingModule;
+    const fileUploadSettingRouterFactory =
+      (fileUploadSettingModule as any).default || fileUploadSettingModule;
     const fileUploadSettingRouter = fileUploadSettingRouterFactory(crowiMock);
     app.use('/', fileUploadSettingRouter);
   });
@@ -75,7 +86,7 @@ describe('file-upload-setting route', () => {
     mockRequire.stopAll();
   });
 
-  it('should update file upload type to local', async() => {
+  it('should update file upload type to local', async () => {
     const response = await request(app)
       .put('/')
       .send({
@@ -89,7 +100,7 @@ describe('file-upload-setting route', () => {
   });
 
   describe('AWS settings', () => {
-    const setupAwsSecret = async(secret: string) => {
+    const setupAwsSecret = async (secret: string) => {
       await configManager.updateConfigs({
         'app:fileUploadType': 'aws',
         'aws:s3SecretAccessKey': toNonBlankString(secret),
@@ -99,11 +110,13 @@ describe('file-upload-setting route', () => {
       await configManager.loadConfigs();
     };
 
-    it('should preserve existing s3SecretAccessKey when not included in request', async() => {
+    it('should preserve existing s3SecretAccessKey when not included in request', async () => {
       const existingSecret = 'existing-secret-key-12345';
       await setupAwsSecret(existingSecret);
 
-      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(
+        existingSecret,
+      );
 
       const response = await request(app)
         .put('/')
@@ -117,15 +130,19 @@ describe('file-upload-setting route', () => {
 
       await configManager.loadConfigs();
 
-      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(
+        existingSecret,
+      );
       expect(response.body.responseParams.fileUploadType).toBe('aws');
     });
 
-    it('should update s3SecretAccessKey when new value is provided in request', async() => {
+    it('should update s3SecretAccessKey when new value is provided in request', async () => {
       const existingSecret = 'existing-secret-key-12345';
       await setupAwsSecret(existingSecret);
 
-      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(
+        existingSecret,
+      );
 
       const newSecret = 'new-secret-key-67890';
       const response = await request(app)
@@ -145,11 +162,13 @@ describe('file-upload-setting route', () => {
       expect(response.body.responseParams.fileUploadType).toBe('aws');
     });
 
-    it('should remove s3SecretAccessKey when empty string is provided in request', async() => {
+    it('should remove s3SecretAccessKey when empty string is provided in request', async () => {
       const existingSecret = 'existing-secret-key-12345';
       await setupAwsSecret(existingSecret);
 
-      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(
+        existingSecret,
+      );
 
       const response = await request(app)
         .put('/')
@@ -170,7 +189,7 @@ describe('file-upload-setting route', () => {
   });
 
   describe('GCS settings', () => {
-    const setupGcsSecret = async(apiKeyPath: string) => {
+    const setupGcsSecret = async (apiKeyPath: string) => {
       await configManager.updateConfigs({
         'app:fileUploadType': 'gcs',
         'gcs:apiKeyJsonPath': toNonBlankString(apiKeyPath),
@@ -179,11 +198,13 @@ describe('file-upload-setting route', () => {
       await configManager.loadConfigs();
     };
 
-    it('should preserve existing gcsApiKeyJsonPath when not included in request', async() => {
+    it('should preserve existing gcsApiKeyJsonPath when not included in request', async () => {
       const existingApiKeyPath = '/path/to/existing-api-key.json';
       await setupGcsSecret(existingApiKeyPath);
 
-      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(
+        existingApiKeyPath,
+      );
 
       const response = await request(app)
         .put('/')
@@ -196,15 +217,19 @@ describe('file-upload-setting route', () => {
 
       await configManager.loadConfigs();
 
-      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(
+        existingApiKeyPath,
+      );
       expect(response.body.responseParams.fileUploadType).toBe('gcs');
     });
 
-    it('should update gcsApiKeyJsonPath when new value is provided in request', async() => {
+    it('should update gcsApiKeyJsonPath when new value is provided in request', async () => {
       const existingApiKeyPath = '/path/to/existing-api-key.json';
       await setupGcsSecret(existingApiKeyPath);
 
-      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(
+        existingApiKeyPath,
+      );
 
       const newApiKeyPath = '/path/to/new-api-key.json';
       const response = await request(app)
@@ -223,11 +248,13 @@ describe('file-upload-setting route', () => {
       expect(response.body.responseParams.fileUploadType).toBe('gcs');
     });
 
-    it('should remove gcsApiKeyJsonPath when empty string is provided in request', async() => {
+    it('should remove gcsApiKeyJsonPath when empty string is provided in request', async () => {
       const existingApiKeyPath = '/path/to/existing-api-key.json';
       await setupGcsSecret(existingApiKeyPath);
 
-      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(
+        existingApiKeyPath,
+      );
 
       const response = await request(app)
         .put('/')
@@ -247,7 +274,7 @@ describe('file-upload-setting route', () => {
   });
 
   describe('Azure settings', () => {
-    const setupAzureSecret = async(secret: string) => {
+    const setupAzureSecret = async (secret: string) => {
       await configManager.updateConfigs({
         'app:fileUploadType': 'azure',
         'azure:clientSecret': toNonBlankString(secret),
@@ -259,11 +286,13 @@ describe('file-upload-setting route', () => {
       await configManager.loadConfigs();
     };
 
-    it('should preserve existing azureClientSecret when not included in request', async() => {
+    it('should preserve existing azureClientSecret when not included in request', async () => {
       const existingSecret = 'existing-azure-secret-12345';
       await setupAzureSecret(existingSecret);
 
-      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+      expect(configManager.getConfig('azure:clientSecret')).toBe(
+        existingSecret,
+      );
 
       const response = await request(app)
         .put('/')
@@ -279,15 +308,19 @@ describe('file-upload-setting route', () => {
 
       await configManager.loadConfigs();
 
-      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+      expect(configManager.getConfig('azure:clientSecret')).toBe(
+        existingSecret,
+      );
       expect(response.body.responseParams.fileUploadType).toBe('azure');
     });
 
-    it('should update azureClientSecret when new value is provided in request', async() => {
+    it('should update azureClientSecret when new value is provided in request', async () => {
       const existingSecret = 'existing-azure-secret-12345';
       await setupAzureSecret(existingSecret);
 
-      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+      expect(configManager.getConfig('azure:clientSecret')).toBe(
+        existingSecret,
+      );
 
       const newSecret = 'new-azure-secret-67890';
       const response = await request(app)
@@ -309,11 +342,13 @@ describe('file-upload-setting route', () => {
       expect(response.body.responseParams.fileUploadType).toBe('azure');
     });
 
-    it('should remove azureClientSecret when empty string is provided in request', async() => {
+    it('should remove azureClientSecret when empty string is provided in request', async () => {
       const existingSecret = 'existing-azure-secret-12345';
       await setupAzureSecret(existingSecret);
 
-      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+      expect(configManager.getConfig('azure:clientSecret')).toBe(
+        existingSecret,
+      );
 
       const response = await request(app)
         .put('/')

+ 173 - 94
apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.ts

@@ -1,5 +1,7 @@
 import {
-  toNonBlankString, toNonBlankStringOrUndefined, SCOPE,
+  SCOPE,
+  toNonBlankString,
+  toNonBlankStringOrUndefined,
 } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
@@ -14,7 +16,9 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 
-const logger = loggerFactory('growi:routes:apiv3:app-settings:file-upload-setting');
+const logger = loggerFactory(
+  'growi:routes:apiv3:app-settings:file-upload-setting',
+);
 
 const router = express.Router();
 
@@ -46,7 +50,11 @@ type AzureResponseParams = BaseResponseParams & {
   azureReferenceFileWithRelayMode?: boolean;
 };
 
-type ResponseParams = BaseResponseParams | GcsResponseParams | AwsResponseParams | AzureResponseParams;
+type ResponseParams =
+  | BaseResponseParams
+  | GcsResponseParams
+  | AwsResponseParams
+  | AzureResponseParams;
 
 const validator = {
   fileUploadSetting: [
@@ -54,12 +62,14 @@ const validator = {
     body('gcsApiKeyJsonPath').optional(),
     body('gcsBucket').optional(),
     body('gcsUploadNamespace').optional(),
-    body('gcsReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+    body('gcsReferenceFileWithRelayMode')
+      .if((value) => value != null)
+      .isBoolean(),
     body('s3Bucket').optional(),
     body('s3Region')
       .optional()
-      .if(value => value !== '' && value != null)
-      .custom(async(value) => {
+      .if((value) => value !== '' && value != null)
+      .custom(async (value) => {
         const { t } = await getTranslation();
         if (!/^[a-z]+-[a-z]+-\d+$/.test(value)) {
           throw new Error(t('validation.aws_region'));
@@ -68,23 +78,30 @@ const validator = {
       }),
     body('s3CustomEndpoint')
       .optional()
-      .if(value => value !== '' && value != null)
-      .custom(async(value) => {
+      .if((value) => value !== '' && value != null)
+      .custom(async (value) => {
         const { t } = await getTranslation();
         if (!/^(https?:\/\/[^/]+|)$/.test(value)) {
           throw new Error(t('validation.aws_custom_endpoint'));
         }
         return true;
       }),
-    body('s3AccessKeyId').optional().if(value => value !== '' && value != null).matches(/^[\da-zA-Z]+$/),
+    body('s3AccessKeyId')
+      .optional()
+      .if((value) => value !== '' && value != null)
+      .matches(/^[\da-zA-Z]+$/),
     body('s3SecretAccessKey').optional(),
-    body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+    body('s3ReferenceFileWithRelayMode')
+      .if((value) => value != null)
+      .isBoolean(),
     body('azureTenantId').optional(),
     body('azureClientId').optional(),
     body('azureClientSecret').optional(),
     body('azureStorageAccountName').optional(),
     body('azureStorageStorageName').optional(),
-    body('azureReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+    body('azureReferenceFileWithRelayMode')
+      .if((value) => value != null)
+      .isBoolean(),
   ],
 };
 
@@ -118,24 +135,35 @@ const validator = {
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('../../../middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware();
 
   const activityEvent = crowi.event('activity');
 
   //  eslint-disable-next-line max-len
-  router.put('/', accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
-    loginRequiredStrictly, adminRequired, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
+  router.put(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.fileUploadSetting,
+    apiV3FormValidator,
+    async (req, res) => {
       const { fileUploadType } = req.body;
 
       if (fileUploadType === 'local' || fileUploadType === 'gridfs') {
         try {
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-          }, { skipPubsub: true });
-        }
-        catch (err) {
+          await configManager.updateConfigs(
+            {
+              'app:fileUploadType': fileUploadType,
+            },
+            { skipPubsub: true },
+          );
+        } catch (err) {
           const msg = `Error occurred in updating ${fileUploadType} settings: ${err.message}`;
           logger.error('Error', err);
           return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
@@ -146,55 +174,67 @@ module.exports = (crowi) => {
         try {
           try {
             toNonBlankString(req.body.s3Bucket);
-          }
-          catch (err) {
+          } catch (err) {
             throw new Error('S3 Bucket name is required');
           }
           try {
             toNonBlankString(req.body.s3Region);
-          }
-          catch (err) {
+          } catch (err) {
             throw new Error('S3 Region is required');
           }
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-            'aws:s3Region': toNonBlankString(req.body.s3Region),
-            'aws:s3Bucket': toNonBlankString(req.body.s3Bucket),
-            'aws:referenceFileWithRelayMode': req.body.s3ReferenceFileWithRelayMode,
-          },
-          { skipPubsub: true });
+          await configManager.updateConfigs(
+            {
+              'app:fileUploadType': fileUploadType,
+              'aws:s3Region': toNonBlankString(req.body.s3Region),
+              'aws:s3Bucket': toNonBlankString(req.body.s3Bucket),
+              'aws:referenceFileWithRelayMode':
+                req.body.s3ReferenceFileWithRelayMode,
+            },
+            { skipPubsub: true },
+          );
 
           // Update optional non-secret fields (can be removed if undefined)
-          await configManager.updateConfigs({
-            'aws:s3CustomEndpoint': toNonBlankStringOrUndefined(req.body.s3CustomEndpoint),
-          },
-          {
-            skipPubsub: true,
-            removeIfUndefined: true,
-          });
-
-          // Update secret fields only if explicitly provided in request
-          if (req.body.s3AccessKeyId !== undefined) {
-            await configManager.updateConfigs({
-              'aws:s3AccessKeyId': toNonBlankStringOrUndefined(req.body.s3AccessKeyId),
+          await configManager.updateConfigs(
+            {
+              'aws:s3CustomEndpoint': toNonBlankStringOrUndefined(
+                req.body.s3CustomEndpoint,
+              ),
             },
             {
               skipPubsub: true,
               removeIfUndefined: true,
-            });
+            },
+          );
+
+          // Update secret fields only if explicitly provided in request
+          if (req.body.s3AccessKeyId !== undefined) {
+            await configManager.updateConfigs(
+              {
+                'aws:s3AccessKeyId': toNonBlankStringOrUndefined(
+                  req.body.s3AccessKeyId,
+                ),
+              },
+              {
+                skipPubsub: true,
+                removeIfUndefined: true,
+              },
+            );
           }
 
           if (req.body.s3SecretAccessKey !== undefined) {
-            await configManager.updateConfigs({
-              'aws:s3SecretAccessKey': toNonBlankStringOrUndefined(req.body.s3SecretAccessKey),
-            },
-            {
-              skipPubsub: true,
-              removeIfUndefined: true,
-            });
+            await configManager.updateConfigs(
+              {
+                'aws:s3SecretAccessKey': toNonBlankStringOrUndefined(
+                  req.body.s3SecretAccessKey,
+                ),
+              },
+              {
+                skipPubsub: true,
+                removeIfUndefined: true,
+              },
+            );
           }
-        }
-        catch (err) {
+        } catch (err) {
           const msg = `Error occurred in updating AWS S3 settings: ${err.message}`;
           logger.error('Error', err);
           return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
@@ -203,28 +243,38 @@ module.exports = (crowi) => {
 
       if (fileUploadType === 'gcs') {
         try {
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-            'gcs:referenceFileWithRelayMode': req.body.gcsReferenceFileWithRelayMode,
-          },
-          { skipPubsub: true });
+          await configManager.updateConfigs(
+            {
+              'app:fileUploadType': fileUploadType,
+              'gcs:referenceFileWithRelayMode':
+                req.body.gcsReferenceFileWithRelayMode,
+            },
+            { skipPubsub: true },
+          );
 
           // Update optional non-secret fields (can be removed if undefined)
-          await configManager.updateConfigs({
-            'gcs:bucket': toNonBlankStringOrUndefined(req.body.gcsBucket),
-            'gcs:uploadNamespace': toNonBlankStringOrUndefined(req.body.gcsUploadNamespace),
-          },
-          { skipPubsub: true, removeIfUndefined: true });
+          await configManager.updateConfigs(
+            {
+              'gcs:bucket': toNonBlankStringOrUndefined(req.body.gcsBucket),
+              'gcs:uploadNamespace': toNonBlankStringOrUndefined(
+                req.body.gcsUploadNamespace,
+              ),
+            },
+            { skipPubsub: true, removeIfUndefined: true },
+          );
 
           // Update secret fields only if explicitly provided in request
           if (req.body.gcsApiKeyJsonPath !== undefined) {
-            await configManager.updateConfigs({
-              'gcs:apiKeyJsonPath': toNonBlankStringOrUndefined(req.body.gcsApiKeyJsonPath),
-            },
-            { skipPubsub: true, removeIfUndefined: true });
+            await configManager.updateConfigs(
+              {
+                'gcs:apiKeyJsonPath': toNonBlankStringOrUndefined(
+                  req.body.gcsApiKeyJsonPath,
+                ),
+              },
+              { skipPubsub: true, removeIfUndefined: true },
+            );
           }
-        }
-        catch (err) {
+        } catch (err) {
           const msg = `Error occurred in updating GCS settings: ${err.message}`;
           logger.error('Error', err);
           return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
@@ -233,28 +283,46 @@ module.exports = (crowi) => {
 
       if (fileUploadType === 'azure') {
         try {
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-            'azure:referenceFileWithRelayMode': req.body.azureReferenceFileWithRelayMode,
-          },
-          { skipPubsub: true });
+          await configManager.updateConfigs(
+            {
+              'app:fileUploadType': fileUploadType,
+              'azure:referenceFileWithRelayMode':
+                req.body.azureReferenceFileWithRelayMode,
+            },
+            { skipPubsub: true },
+          );
 
           // Update optional non-secret fields (can be removed if undefined)
-          await configManager.updateConfigs({
-            'azure:tenantId': toNonBlankStringOrUndefined(req.body.azureTenantId),
-            'azure:clientId': toNonBlankStringOrUndefined(req.body.azureClientId),
-            'azure:storageAccountName': toNonBlankStringOrUndefined(req.body.azureStorageAccountName),
-            'azure:storageContainerName': toNonBlankStringOrUndefined(req.body.azureStorageContainerName),
-          }, { skipPubsub: true, removeIfUndefined: true });
+          await configManager.updateConfigs(
+            {
+              'azure:tenantId': toNonBlankStringOrUndefined(
+                req.body.azureTenantId,
+              ),
+              'azure:clientId': toNonBlankStringOrUndefined(
+                req.body.azureClientId,
+              ),
+              'azure:storageAccountName': toNonBlankStringOrUndefined(
+                req.body.azureStorageAccountName,
+              ),
+              'azure:storageContainerName': toNonBlankStringOrUndefined(
+                req.body.azureStorageContainerName,
+              ),
+            },
+            { skipPubsub: true, removeIfUndefined: true },
+          );
 
           // Update secret fields only if explicitly provided in request
           if (req.body.azureClientSecret !== undefined) {
-            await configManager.updateConfigs({
-              'azure:clientSecret': toNonBlankStringOrUndefined(req.body.azureClientSecret),
-            }, { skipPubsub: true, removeIfUndefined: true });
+            await configManager.updateConfigs(
+              {
+                'azure:clientSecret': toNonBlankStringOrUndefined(
+                  req.body.azureClientSecret,
+                ),
+              },
+              { skipPubsub: true, removeIfUndefined: true },
+            );
           }
-        }
-        catch (err) {
+        } catch (err) {
           const msg = `Error occurred in updating Azure settings: ${err.message}`;
           logger.error('Error', err);
           return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
@@ -275,7 +343,9 @@ module.exports = (crowi) => {
             gcsApiKeyJsonPath: configManager.getConfig('gcs:apiKeyJsonPath'),
             gcsBucket: configManager.getConfig('gcs:bucket'),
             gcsUploadNamespace: configManager.getConfig('gcs:uploadNamespace'),
-            gcsReferenceFileWithRelayMode: configManager.getConfig('gcs:referenceFileWithRelayMode'),
+            gcsReferenceFileWithRelayMode: configManager.getConfig(
+              'gcs:referenceFileWithRelayMode',
+            ),
           };
         }
 
@@ -286,7 +356,9 @@ module.exports = (crowi) => {
             s3CustomEndpoint: configManager.getConfig('aws:s3CustomEndpoint'),
             s3Bucket: configManager.getConfig('aws:s3Bucket'),
             s3AccessKeyId: configManager.getConfig('aws:s3AccessKeyId'),
-            s3ReferenceFileWithRelayMode: configManager.getConfig('aws:referenceFileWithRelayMode'),
+            s3ReferenceFileWithRelayMode: configManager.getConfig(
+              'aws:referenceFileWithRelayMode',
+            ),
           };
         }
 
@@ -296,23 +368,30 @@ module.exports = (crowi) => {
             azureTenantId: configManager.getConfig('azure:tenantId'),
             azureClientId: configManager.getConfig('azure:clientId'),
             azureClientSecret: configManager.getConfig('azure:clientSecret'),
-            azureStorageAccountName: configManager.getConfig('azure:storageAccountName'),
-            azureStorageContainerName: configManager.getConfig('azure:storageContainerName'),
-            azureReferenceFileWithRelayMode: configManager.getConfig('azure:referenceFileWithRelayMode'),
+            azureStorageAccountName: configManager.getConfig(
+              'azure:storageAccountName',
+            ),
+            azureStorageContainerName: configManager.getConfig(
+              'azure:storageContainerName',
+            ),
+            azureReferenceFileWithRelayMode: configManager.getConfig(
+              'azure:referenceFileWithRelayMode',
+            ),
           };
         }
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ responseParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in retrieving file upload configurations';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
       }
-
-    });
+    },
+  );
 
   return router;
 };

+ 339 - 175
apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -1,6 +1,4 @@
-import {
-  ConfigSource, SCOPE,
-} from '@growi/core/dist/interfaces';
+import { ConfigSource, SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 
@@ -15,7 +13,6 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 
-
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
 const { pathUtils } = require('@growi/core/dist/utils');
@@ -23,7 +20,6 @@ const express = require('express');
 
 const router = express.Router();
 
-
 /**
  * @swagger
  *
@@ -317,7 +313,9 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('../../../middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware();
 
@@ -333,29 +331,39 @@ module.exports = (crowi) => {
     ],
     siteUrlSetting: [
       // https://regex101.com/r/5Xef8V/1
-      body('siteUrl').trim().matches(/^(https?:\/\/)/).isURL({ require_tld: false }),
+      body('siteUrl')
+        .trim()
+        .matches(/^(https?:\/\/)/)
+        .isURL({ require_tld: false }),
     ],
     mailSetting: [
-      body('fromAddress').trim().if(value => value !== '').isEmail(),
+      body('fromAddress')
+        .trim()
+        .if((value) => value !== '')
+        .isEmail(),
       body('transmissionMethod').isIn(['smtp', 'ses']),
     ],
     smtpSetting: [
       body('smtpHost').trim(),
-      body('smtpPort').trim().if(value => value !== '').isPort(),
+      body('smtpPort')
+        .trim()
+        .if((value) => value !== '')
+        .isPort(),
       body('smtpUser').trim(),
       body('smtpPassword').trim(),
     ],
     sesSetting: [
-      body('sesAccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
+      body('sesAccessKeyId')
+        .trim()
+        .if((value) => value !== '')
+        .matches(/^[\da-zA-Z]+$/),
       body('sesSecretAccessKey').trim(),
     ],
     pageBulkExportSettings: [
       body('isBulkExportPagesEnabled').isBoolean(),
       body('bulkExportDownloadExpirationSeconds').isInt(),
     ],
-    maintenanceMode: [
-      body('flag').isBoolean(),
-    ],
+    maintenanceMode: [body('flag').isBoolean()],
   };
 
   /**
@@ -380,74 +388,138 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/AppSettingParams'
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.APP], { acceptLegacy: true }), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const appSettingsParams = {
-      title: configManager.getConfig('app:title'),
-      confidential: configManager.getConfig('app:confidential'),
-      globalLang: configManager.getConfig('app:globalLang'),
-      isEmailPublishedForNewUser: configManager.getConfig('customize:isEmailPublishedForNewUser'),
-      fileUpload: configManager.getConfig('app:fileUpload'),
-      useOnlyEnvVarsForIsBulkExportPagesEnabled: configManager.getConfig('env:useOnlyEnvVars:app:isBulkExportPagesEnabled'),
-      isV5Compatible: configManager.getConfig('app:isV5Compatible'),
-      siteUrl: configManager.getConfig('app:siteUrl'),
-      siteUrlUseOnlyEnvVars: configManager.getConfig('env:useOnlyEnvVars:app:siteUrl'),
-      envSiteUrl: configManager.getConfig('app:siteUrl', ConfigSource.env),
-      isMailerSetup: crowi.mailService.isMailerSetup,
-      fromAddress: configManager.getConfig('mail:from'),
-
-      transmissionMethod: configManager.getConfig('mail:transmissionMethod'),
-      smtpHost: configManager.getConfig('mail:smtpHost'),
-      smtpPort: configManager.getConfig('mail:smtpPort'),
-      smtpUser: configManager.getConfig('mail:smtpUser'),
-      smtpPassword: configManager.getConfig('mail:smtpPassword'),
-      sesAccessKeyId: configManager.getConfig('mail:sesAccessKeyId'),
-      sesSecretAccessKey: configManager.getConfig('mail:sesSecretAccessKey'),
-
-      fileUploadType: configManager.getConfig('app:fileUploadType'),
-      envFileUploadType: configManager.getConfig('app:fileUploadType', ConfigSource.env),
-      useOnlyEnvVarForFileUploadType: configManager.getConfig('env:useOnlyEnvVars:app:fileUploadType'),
-
-      s3Region: configManager.getConfig('aws:s3Region'),
-      s3CustomEndpoint: configManager.getConfig('aws:s3CustomEndpoint'),
-      s3Bucket: configManager.getConfig('aws:s3Bucket'),
-      s3AccessKeyId: configManager.getConfig('aws:s3AccessKeyId'),
-      s3ReferenceFileWithRelayMode: configManager.getConfig('aws:referenceFileWithRelayMode'),
-
-      gcsUseOnlyEnvVars: configManager.getConfig('env:useOnlyEnvVars:gcs'),
-      gcsApiKeyJsonPath: configManager.getConfig('gcs:apiKeyJsonPath'),
-      gcsBucket: configManager.getConfig('gcs:bucket'),
-      gcsUploadNamespace: configManager.getConfig('gcs:uploadNamespace'),
-      gcsReferenceFileWithRelayMode: configManager.getConfig('gcs:referenceFileWithRelayMode'),
-
-      envGcsApiKeyJsonPath: configManager.getConfig('gcs:apiKeyJsonPath', ConfigSource.env),
-      envGcsBucket: configManager.getConfig('gcs:bucket', ConfigSource.env),
-      envGcsUploadNamespace: configManager.getConfig('gcs:uploadNamespace', ConfigSource.env),
-
-      azureUseOnlyEnvVars: configManager.getConfig('env:useOnlyEnvVars:azure'),
-      azureTenantId: configManager.getConfig('azure:tenantId', ConfigSource.db),
-      azureClientId: configManager.getConfig('azure:clientId', ConfigSource.db),
-      azureClientSecret: configManager.getConfig('azure:clientSecret', ConfigSource.db),
-      azureStorageAccountName: configManager.getConfig('azure:storageAccountName', ConfigSource.db),
-      azureStorageContainerName: configManager.getConfig('azure:storageContainerName', ConfigSource.db),
-      azureReferenceFileWithRelayMode: configManager.getConfig('azure:referenceFileWithRelayMode'),
-
-      envAzureTenantId: configManager.getConfig('azure:tenantId', ConfigSource.env),
-      envAzureClientId: configManager.getConfig('azure:clientId', ConfigSource.env),
-      envAzureClientSecret: configManager.getConfig('azure:clientSecret', ConfigSource.env),
-      envAzureStorageAccountName: configManager.getConfig('azure:storageAccountName', ConfigSource.env),
-      envAzureStorageContainerName: configManager.getConfig('azure:storageContainerName', ConfigSource.env),
-
-      isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
-
-      isBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled'),
-      envIsBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled'),
-      bulkExportDownloadExpirationSeconds: configManager.getConfig('app:bulkExportDownloadExpirationSeconds'),
-      // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
-      isBulkExportDisabledForCloud: configManager.getConfig('app:growiCloudUri') != null,
-    };
-    return res.apiv3({ appSettingsParams });
-
-  });
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.APP], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const appSettingsParams = {
+        title: configManager.getConfig('app:title'),
+        confidential: configManager.getConfig('app:confidential'),
+        globalLang: configManager.getConfig('app:globalLang'),
+        isEmailPublishedForNewUser: configManager.getConfig(
+          'customize:isEmailPublishedForNewUser',
+        ),
+        fileUpload: configManager.getConfig('app:fileUpload'),
+        useOnlyEnvVarsForIsBulkExportPagesEnabled: configManager.getConfig(
+          'env:useOnlyEnvVars:app:isBulkExportPagesEnabled',
+        ),
+        isV5Compatible: configManager.getConfig('app:isV5Compatible'),
+        siteUrl: configManager.getConfig('app:siteUrl'),
+        siteUrlUseOnlyEnvVars: configManager.getConfig(
+          'env:useOnlyEnvVars:app:siteUrl',
+        ),
+        envSiteUrl: configManager.getConfig('app:siteUrl', ConfigSource.env),
+        isMailerSetup: crowi.mailService.isMailerSetup,
+        fromAddress: configManager.getConfig('mail:from'),
+
+        transmissionMethod: configManager.getConfig('mail:transmissionMethod'),
+        smtpHost: configManager.getConfig('mail:smtpHost'),
+        smtpPort: configManager.getConfig('mail:smtpPort'),
+        smtpUser: configManager.getConfig('mail:smtpUser'),
+        smtpPassword: configManager.getConfig('mail:smtpPassword'),
+        sesAccessKeyId: configManager.getConfig('mail:sesAccessKeyId'),
+        sesSecretAccessKey: configManager.getConfig('mail:sesSecretAccessKey'),
+
+        fileUploadType: configManager.getConfig('app:fileUploadType'),
+        envFileUploadType: configManager.getConfig(
+          'app:fileUploadType',
+          ConfigSource.env,
+        ),
+        useOnlyEnvVarForFileUploadType: configManager.getConfig(
+          'env:useOnlyEnvVars:app:fileUploadType',
+        ),
+
+        s3Region: configManager.getConfig('aws:s3Region'),
+        s3CustomEndpoint: configManager.getConfig('aws:s3CustomEndpoint'),
+        s3Bucket: configManager.getConfig('aws:s3Bucket'),
+        s3AccessKeyId: configManager.getConfig('aws:s3AccessKeyId'),
+        s3ReferenceFileWithRelayMode: configManager.getConfig(
+          'aws:referenceFileWithRelayMode',
+        ),
+
+        gcsUseOnlyEnvVars: configManager.getConfig('env:useOnlyEnvVars:gcs'),
+        gcsApiKeyJsonPath: configManager.getConfig('gcs:apiKeyJsonPath'),
+        gcsBucket: configManager.getConfig('gcs:bucket'),
+        gcsUploadNamespace: configManager.getConfig('gcs:uploadNamespace'),
+        gcsReferenceFileWithRelayMode: configManager.getConfig(
+          'gcs:referenceFileWithRelayMode',
+        ),
+
+        envGcsApiKeyJsonPath: configManager.getConfig(
+          'gcs:apiKeyJsonPath',
+          ConfigSource.env,
+        ),
+        envGcsBucket: configManager.getConfig('gcs:bucket', ConfigSource.env),
+        envGcsUploadNamespace: configManager.getConfig(
+          'gcs:uploadNamespace',
+          ConfigSource.env,
+        ),
+
+        azureUseOnlyEnvVars: configManager.getConfig(
+          'env:useOnlyEnvVars:azure',
+        ),
+        azureTenantId: configManager.getConfig(
+          'azure:tenantId',
+          ConfigSource.db,
+        ),
+        azureClientId: configManager.getConfig(
+          'azure:clientId',
+          ConfigSource.db,
+        ),
+        azureClientSecret: configManager.getConfig(
+          'azure:clientSecret',
+          ConfigSource.db,
+        ),
+        azureStorageAccountName: configManager.getConfig(
+          'azure:storageAccountName',
+          ConfigSource.db,
+        ),
+        azureStorageContainerName: configManager.getConfig(
+          'azure:storageContainerName',
+          ConfigSource.db,
+        ),
+        azureReferenceFileWithRelayMode: configManager.getConfig(
+          'azure:referenceFileWithRelayMode',
+        ),
+
+        envAzureTenantId: configManager.getConfig(
+          'azure:tenantId',
+          ConfigSource.env,
+        ),
+        envAzureClientId: configManager.getConfig(
+          'azure:clientId',
+          ConfigSource.env,
+        ),
+        envAzureClientSecret: configManager.getConfig(
+          'azure:clientSecret',
+          ConfigSource.env,
+        ),
+        envAzureStorageAccountName: configManager.getConfig(
+          'azure:storageAccountName',
+          ConfigSource.env,
+        ),
+        envAzureStorageContainerName: configManager.getConfig(
+          'azure:storageContainerName',
+          ConfigSource.env,
+        ),
+
+        isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
+
+        isBulkExportPagesEnabled: configManager.getConfig(
+          'app:isBulkExportPagesEnabled',
+        ),
+        envIsBulkExportPagesEnabled: configManager.getConfig(
+          'app:isBulkExportPagesEnabled',
+        ),
+        bulkExportDownloadExpirationSeconds: configManager.getConfig(
+          'app:bulkExportDownloadExpirationSeconds',
+        ),
+      };
+      return res.apiv3({ appSettingsParams });
+    },
+  );
 
   /**
    * @swagger
@@ -477,14 +549,21 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/AppSettingPutParams'
    */
-  router.put('/app-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.appSetting, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/app-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.appSetting,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestAppSettingParams = {
         'app:title': req.body.title,
         'app:confidential': req.body.confidential,
         'app:globalLang': req.body.globalLang,
-        'customize:isEmailPublishedForNewUser': req.body.isEmailPublishedForNewUser,
+        'customize:isEmailPublishedForNewUser':
+          req.body.isEmailPublishedForNewUser,
         'app:fileUpload': req.body.fileUpload,
       };
 
@@ -494,22 +573,25 @@ module.exports = (crowi) => {
           title: configManager.getConfig('app:title'),
           confidential: configManager.getConfig('app:confidential'),
           globalLang: configManager.getConfig('app:globalLang'),
-          isEmailPublishedForNewUser: configManager.getConfig('customize:isEmailPublishedForNewUser'),
+          isEmailPublishedForNewUser: configManager.getConfig(
+            'customize:isEmailPublishedForNewUser',
+          ),
           fileUpload: configManager.getConfig('app:fileUpload'),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ appSettingParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating app setting';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-appSetting-failed'));
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -543,14 +625,24 @@ module.exports = (crowi) => {
    *                          description: Site URL. e.g. https://example.com, https://example.com:3000
    *                          example: 'http://localhost:3000'
    */
-  router.put('/site-url-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.siteUrlSetting, apiV3FormValidator,
-    async(req, res) => {
-      const useOnlyEnvVars = configManager.getConfig('env:useOnlyEnvVars:app:siteUrl');
+  router.put(
+    '/site-url-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.siteUrlSetting,
+    apiV3FormValidator,
+    async (req, res) => {
+      const useOnlyEnvVars = configManager.getConfig(
+        'env:useOnlyEnvVars:app:siteUrl',
+      );
 
       if (useOnlyEnvVars) {
         const msg = 'Updating the Site URL is prohibited on this system.';
-        return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-prohibited'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-siteUrlSetting-prohibited'),
+        );
       }
 
       const requestSiteUrlSettingParams = {
@@ -563,17 +655,18 @@ module.exports = (crowi) => {
           siteUrl: configManager.getConfig('app:siteUrl'),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_SITE_URL_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_SITE_URL_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ siteUrlSettingParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating site url setting';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-failed'));
       }
-
-    });
+    },
+  );
 
   /**
    * send mail (Promise wrapper)
@@ -583,8 +676,7 @@ module.exports = (crowi) => {
       smtpClient.sendMail(options, (err, res) => {
         if (err) {
           reject(err);
-        }
-        else {
+        } else {
           resolve(res);
         }
       });
@@ -595,7 +687,6 @@ module.exports = (crowi) => {
    * validate mail setting send test mail
    */
   async function sendTestEmail(destinationAddress) {
-
     const { mailService } = crowi;
 
     if (!mailService.isMailerSetup) {
@@ -645,13 +736,13 @@ module.exports = (crowi) => {
     await sendMailPromiseWrapper(smtpClient, mailOptions);
   }
 
-  const updateMailSettinConfig = async function(requestMailSettingParams) {
-    const {
-      mailService,
-    } = crowi;
+  const updateMailSettinConfig = async (requestMailSettingParams) => {
+    const { mailService } = crowi;
 
     // update config without publishing S2sMessage
-    await configManager.updateConfigs(requestMailSettingParams, { skipPubsub: true });
+    await configManager.updateConfigs(requestMailSettingParams, {
+      skipPubsub: true,
+    });
 
     await mailService.initialize();
     mailService.publishUpdatedMessage();
@@ -696,9 +787,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/SmtpSettingResponseParams'
    */
-  router.put('/smtp-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.smtpSetting, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/smtp-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.smtpSetting,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestMailSettingParams = {
         'mail:from': req.body.fromAddress,
         'mail:transmissionMethod': req.body.transmissionMethod,
@@ -709,17 +806,21 @@ module.exports = (crowi) => {
       };
 
       try {
-        const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
-        const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SMTP_UPDATE };
+        const mailSettingParams = await updateMailSettinConfig(
+          requestMailSettingParams,
+        );
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_MAIL_SMTP_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ mailSettingParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating smtp setting';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-smtpSetting-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -740,22 +841,30 @@ module.exports = (crowi) => {
    *                  type: object
    *                  description: Empty object
    */
-  router.post('/smtp-test', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const { t } = await getTranslation({ lang: req.user.lang });
+  router.post(
+    '/smtp-test',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    async (req, res) => {
+      const { t } = await getTranslation({ lang: req.user.lang });
 
-    try {
-      await sendTestEmail(req.user.email);
-      const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_TEST_SUBMIT };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({});
-    }
-    catch (err) {
-      const msg = t('validation.failed_to_send_a_test_email');
-      logger.error('Error', err);
-      logger.debug('Error validate mail setting: ', err);
-      return res.apiv3Err(new ErrorV3(msg, 'send-email-with-smtp-failed'));
-    }
-  });
+      try {
+        await sendTestEmail(req.user.email);
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_MAIL_TEST_SUBMIT,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({});
+      } catch (err) {
+        const msg = t('validation.failed_to_send_a_test_email');
+        logger.error('Error', err);
+        logger.debug('Error validate mail setting: ', err);
+        return res.apiv3Err(new ErrorV3(msg, 'send-email-with-smtp-failed'));
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -781,9 +890,15 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/SesSettingResponseParams'
    */
-  router.put('/ses-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.sesSetting, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/ses-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.sesSetting,
+    apiV3FormValidator,
+    async (req, res) => {
       const { mailService } = crowi;
 
       const requestSesSettingParams = {
@@ -793,11 +908,12 @@ module.exports = (crowi) => {
         'mail:sesSecretAccessKey': req.body.sesSecretAccessKey,
       };
 
-      let mailSettingParams;
+      let mailSettingParams: Awaited<ReturnType<typeof updateMailSettinConfig>>;
       try {
-        mailSettingParams = await updateMailSettinConfig(requestSesSettingParams);
-      }
-      catch (err) {
+        mailSettingParams = await updateMailSettinConfig(
+          requestSesSettingParams,
+        );
+      } catch (err) {
         const msg = 'Error occurred in updating ses setting';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-ses-setting-failed'));
@@ -805,41 +921,57 @@ module.exports = (crowi) => {
 
       await mailService.initialize();
       mailService.publishUpdatedMessage();
-      const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SES_UPDATE };
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_MAIL_SES_UPDATE,
+      };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ mailSettingParams });
-    });
+    },
+  );
 
   router.use('/file-upload-setting', require('./file-upload-setting')(crowi));
 
-
-  router.put('/page-bulk-export-settings',
-    accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity, validator.pageBulkExportSettings, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/page-bulk-export-settings',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.pageBulkExportSettings,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'app:isBulkExportPagesEnabled': req.body.isBulkExportPagesEnabled,
-        'app:bulkExportDownloadExpirationSeconds': req.body.bulkExportDownloadExpirationSeconds,
+        'app:bulkExportDownloadExpirationSeconds':
+          req.body.bulkExportDownloadExpirationSeconds,
       };
 
       try {
         await configManager.updateConfigs(requestParams, { skipPubsub: true });
         const responseParams = {
-          isBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled'),
-          bulkExportDownloadExpirationSeconds: configManager.getConfig('app:bulkExportDownloadExpirationSeconds'),
+          isBulkExportPagesEnabled: configManager.getConfig(
+            'app:isBulkExportPagesEnabled',
+          ),
+          bulkExportDownloadExpirationSeconds: configManager.getConfig(
+            'app:bulkExportDownloadExpirationSeconds',
+          ),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ responseParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating page bulk export settings';
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-page-bulk-export-settings-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-page-bulk-export-settings-failed'),
+        );
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -865,27 +997,39 @@ module.exports = (crowi) => {
    *                      description: is V5 compatible, or not
    *                      example: true
    */
-  router.post('/v5-schema-migration',
-    accessTokenParser([SCOPE.WRITE.ADMIN.APP], { acceptLegacy: true }), loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.post(
+    '/v5-schema-migration',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
       const isMaintenanceMode = crowi.appService.isMaintenanceMode();
       if (!isMaintenanceMode) {
-        return res.apiv3Err(new ErrorV3('GROWI is not maintenance mode. To import data, please activate the maintenance mode first.', 'not_maintenance_mode'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'GROWI is not maintenance mode. To import data, please activate the maintenance mode first.',
+            'not_maintenance_mode',
+          ),
+        );
       }
 
       const isV5Compatible = configManager.getConfig('app:isV5Compatible');
 
       try {
         if (!isV5Compatible) {
-        // This method throws and emit socketIo event when error occurs
+          // This method throws and emit socketIo event when error occurs
           crowi.pageService.normalizeAllPublicPages();
         }
-      }
-      catch (err) {
-        return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
+      } catch (err) {
+        return res.apiv3Err(
+          new ErrorV3(`Failed to migrate pages: ${err.message}`),
+          500,
+        );
       }
 
       return res.apiv3({ isV5Compatible });
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -920,28 +1064,47 @@ module.exports = (crowi) => {
    *                      description: true if maintenance mode is enabled
    *                      example: true
    */
-  router.post('/maintenance-mode',
+  router.post(
+    '/maintenance-mode',
     accessTokenParser([SCOPE.WRITE.ADMIN.APP], { acceptLegacy: true }),
-    loginRequiredStrictly, adminRequired, addActivity, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.maintenanceMode,
+    apiV3FormValidator,
+    async (req, res) => {
       const { flag } = req.body;
       const parameters = {};
       try {
         if (flag) {
           await crowi.appService.startMaintenanceMode();
-          Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_ENABLED });
-        }
-        else {
+          Object.assign(parameters, {
+            action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
+          });
+        } else {
           await crowi.appService.endMaintenanceMode();
-          Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_DISABLED });
+          Object.assign(parameters, {
+            action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
+          });
         }
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         if (flag) {
-          res.apiv3Err(new ErrorV3('Failed to start maintenance mode', 'failed_to_start_maintenance_mode'), 500);
-        }
-        else {
-          res.apiv3Err(new ErrorV3('Failed to end maintenance mode', 'failed_to_end_maintenance_mode'), 500);
+          res.apiv3Err(
+            new ErrorV3(
+              'Failed to start maintenance mode',
+              'failed_to_start_maintenance_mode',
+            ),
+            500,
+          );
+        } else {
+          res.apiv3Err(
+            new ErrorV3(
+              'Failed to end maintenance mode',
+              'failed_to_end_maintenance_mode',
+            ),
+            500,
+          );
         }
       }
 
@@ -950,7 +1113,8 @@ module.exports = (crowi) => {
       }
 
       res.apiv3({ flag });
-    });
+    },
+  );
 
   return router;
 };

+ 16 - 11
apps/app/src/server/routes/apiv3/page/check-page-existence.ts

@@ -1,4 +1,5 @@
 import type { IPage, IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { Request, RequestHandler } from 'express';
@@ -6,7 +7,6 @@ import type { ValidationChain } from 'express-validator';
 import { query } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -15,24 +15,27 @@ import loggerFactory from '~/utils/logger';
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:page:check-page-existence');
 
-
 type ReqQuery = {
-  path: string,
-}
+  path: string;
+};
 
 interface Req extends Request<ReqQuery, ApiV3Response> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 
 type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
-export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (crowi) => {
+export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (
+  crowi,
+) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
 
-  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+  const loginRequired = require('../../../middlewares/login-required')(
+    crowi,
+    true,
+  );
 
   // define validators for req.body
   const validator: ValidationChain[] = [
@@ -40,9 +43,11 @@ export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (cro
   ];
 
   return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequired,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    validator,
+    apiV3FormValidator,
+    async (req: Req, res: ApiV3Response) => {
       const { path } = req.query;
 
       if (path == null || Array.isArray(path)) {

+ 154 - 65
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -1,10 +1,16 @@
 import { allOrigin } from '@growi/core';
-import type {
-  IPage, IUser, IUserHasId,
-} from '@growi/core/dist/interfaces';
+import type { IPage, IUser, IUserHasId } from '@growi/core/dist/interfaces';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
-import { isCreatablePage, isUserPage, isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
-import { attachTitleHeader, normalizePath } from '@growi/core/dist/utils/path-utils';
+import {
+  isCreatablePage,
+  isUserPage,
+  isUsersHomepage,
+} from '@growi/core/dist/utils/page-path-utils';
+import {
+  attachTitleHeader,
+  normalizePath,
+} from '@growi/core/dist/utils/path-utils';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
@@ -16,14 +22,16 @@ import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import type { IOptionsForCreate } from '~/interfaces/page';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import PageTagRelation from '~/server/models/page-tag-relation';
-import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
+import {
+  serializePageSecurely,
+  serializeRevisionSecurely,
+} from '~/server/models/serializers';
 import { configManager } from '~/server/service/config-manager';
 import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';
@@ -32,21 +40,29 @@ import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:page:create-page');
 
-
-async function generateUntitledPath(parentPath: string, basePathname: string, index = 1): Promise<string> {
+async function generateUntitledPath(
+  parentPath: string,
+  basePathname: string,
+  index = 1,
+): Promise<string> {
   const Page = mongoose.model<IPage>('Page');
 
-  const path = normalizePath(`${normalizePath(parentPath)}/${basePathname}-${index}`);
-  if (await Page.exists({ path, isEmpty: false }) != null) {
+  const path = normalizePath(
+    `${normalizePath(parentPath)}/${basePathname}-${index}`,
+  );
+  if ((await Page.exists({ path, isEmpty: false })) != null) {
     return generateUntitledPath(parentPath, basePathname, index + 1);
   }
   return path;
 }
 
-async function determinePath(_parentPath?: string, _path?: string, optionalParentPath?: string): Promise<string> {
+async function determinePath(
+  _parentPath?: string,
+  _path?: string,
+  optionalParentPath?: string,
+): Promise<string> {
   const { t } = await getTranslation();
 
   const basePathname = t?.('create_page.untitled') || 'Untitled';
@@ -90,53 +106,85 @@ async function determinePath(_parentPath?: string, _path?: string, optionalParen
   return generateUntitledPath('/', basePathname);
 }
 
-
-type ReqBody = IApiv3PageCreateParams
+type ReqBody = IApiv3PageCreateParams;
 
 interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 
 type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
 export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
-  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>('User');
-
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>(
+    'User',
+  );
 
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
 
   // define validators for req.body
   const validator: ValidationChain[] = [
-    body('path').optional().not().isEmpty({ ignore_whitespace: true })
+    body('path')
+      .optional()
+      .not()
+      .isEmpty({ ignore_whitespace: true })
       .withMessage("Empty value is not allowed for 'path'"),
-    body('parentPath').optional().not().isEmpty({ ignore_whitespace: true })
+    body('parentPath')
+      .optional()
+      .not()
+      .isEmpty({ ignore_whitespace: true })
       .withMessage("Empty value is not allowed for 'parentPath'"),
-    body('optionalParentPath').optional().not().isEmpty({ ignore_whitespace: true })
+    body('optionalParentPath')
+      .optional()
+      .not()
+      .isEmpty({ ignore_whitespace: true })
       .withMessage("Empty value is not allowed for 'optionalParentPath'"),
-    body('body').optional().isString()
+    body('body')
+      .optional()
+      .isString()
       .withMessage('body must be string or undefined'),
-    body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
-    body('onlyInheritUserRelatedGrantedGroups').optional().isBoolean().withMessage('onlyInheritUserRelatedGrantedGroups must be boolean'),
-    body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('grant')
+      .optional()
+      .isInt({ min: 0, max: 5 })
+      .withMessage('grant must be integer from 1 to 5'),
+    body('onlyInheritUserRelatedGrantedGroups')
+      .optional()
+      .isBoolean()
+      .withMessage('onlyInheritUserRelatedGrantedGroups must be boolean'),
+    body('overwriteScopesOfDescendants')
+      .optional()
+      .isBoolean()
+      .withMessage('overwriteScopesOfDescendants must be boolean'),
     body('pageTags').optional().isArray().withMessage('pageTags must be array'),
-    body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
-    body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+    body('isSlackEnabled')
+      .optional()
+      .isBoolean()
+      .withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels')
+      .optional()
+      .isString()
+      .withMessage('slackChannels must be string'),
     body('wip').optional().isBoolean().withMessage('wip must be boolean'),
-    body('origin').optional().isIn(allOrigin).withMessage('origin must be "view" or "editor"'),
+    body('origin')
+      .optional()
+      .isIn(allOrigin)
+      .withMessage('origin must be "view" or "editor"'),
   ];
 
-
   async function determineBodyAndTags(
-      path: string,
-      _body: string | null | undefined, _tags: string[] | null | undefined,
-  ): Promise<{ body: string, tags: string[] }> {
-
+    path: string,
+    _body: string | null | undefined,
+    _tags: string[] | null | undefined,
+  ): Promise<{ body: string; tags: string[] }> {
     let body: string = _body ?? '';
     let tags: string[] = _tags ?? [];
 
     if (_body == null) {
-      const isEnabledAttachTitleHeader = await configManager.getConfig('customize:isEnabledAttachTitleHeader');
+      const isEnabledAttachTitleHeader = await configManager.getConfig(
+        'customize:isEnabledAttachTitleHeader',
+      );
       if (isEnabledAttachTitleHeader) {
         body += `${attachTitleHeader(path)}\n`;
       }
@@ -153,14 +201,24 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
     return { body, tags };
   }
 
-  async function saveTags({ createdPage, pageTags }: { createdPage: PageDocument, pageTags: string[] }) {
+  async function saveTags({
+    createdPage,
+    pageTags,
+  }: {
+    createdPage: PageDocument;
+    pageTags: string[];
+  }) {
     const tagEvent = crowi.event('tag');
     await PageTagRelation.updatePageTags(createdPage.id, pageTags);
     tagEvent.emit('update', createdPage, pageTags);
     return PageTagRelation.listTagNamesByPage(createdPage.id);
   }
 
-  async function postAction(req: CreatePageRequest, res: ApiV3Response, createdPage: HydratedDocument<PageDocument>) {
+  async function postAction(
+    req: CreatePageRequest,
+    res: ApiV3Response,
+    createdPage: HydratedDocument<PageDocument>,
+  ) {
     // persist activity
     const parameters = {
       targetModel: SupportedTargetModel.MODEL_PAGE,
@@ -172,9 +230,12 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
 
     // global notification
     try {
-      await crowi.globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, createdPage, req.user);
-    }
-    catch (err) {
+      await crowi.globalNotificationService.fire(
+        GlobalNotificationSettingEvent.PAGE_CREATE,
+        createdPage,
+        req.user,
+      );
+    } catch (err) {
       logger.error('Create grobal notification failed', err);
     }
 
@@ -182,34 +243,42 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
     const { isSlackEnabled, slackChannels } = req.body;
     if (isSlackEnabled) {
       try {
-        const results = await crowi.userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
+        const results = await crowi.userNotificationService.fire(
+          createdPage,
+          req.user,
+          slackChannels,
+          'create',
+        );
         results.forEach((result) => {
           if (result.status === 'rejected') {
             logger.error('Create user notification failed', result.reason);
           }
         });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Create user notification failed', err);
       }
     }
 
     // create subscription
     try {
-      await crowi.inAppNotificationService.createSubscription(req.user._id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
-    }
-    catch (err) {
+      await crowi.inAppNotificationService.createSubscription(
+        req.user._id,
+        createdPage._id,
+        subscribeRuleNames.PAGE_CREATE,
+      );
+    } catch (err) {
       logger.error('Failed to create subscription document', err);
     }
 
     // Rebuild vector store file
     if (isAiEnabled()) {
-      const { getOpenaiService } = await import('~/features/openai/server/services/openai');
+      const { getOpenaiService } = await import(
+        '~/features/openai/server/services/openai'
+      );
       try {
         const openaiService = getOpenaiService();
         await openaiService?.createVectorStoreFileOnPageCreate([createdPage]);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Rebuild vector store failed', err);
       }
     }
@@ -218,39 +287,60 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
   const addActivity = generateAddActivityMiddleware();
 
   return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly, excludeReadOnlyUser, addActivity,
-    validator, apiV3FormValidator,
-    async(req: CreatePageRequest, res: ApiV3Response) => {
-      const {
-        body: bodyByParam, pageTags: tagsByParam,
-      } = req.body;
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    validator,
+    apiV3FormValidator,
+    async (req: CreatePageRequest, res: ApiV3Response) => {
+      const { body: bodyByParam, pageTags: tagsByParam } = req.body;
 
       let pathToCreate: string;
       try {
         const { path, parentPath, optionalParentPath } = req.body;
-        pathToCreate = await determinePath(parentPath, path, optionalParentPath);
-      }
-      catch (err) {
-        return res.apiv3Err(new ErrorV3(err.toString(), 'could_not_create_page'));
+        pathToCreate = await determinePath(
+          parentPath,
+          path,
+          optionalParentPath,
+        );
+      } catch (err) {
+        return res.apiv3Err(
+          new ErrorV3(err.toString(), 'could_not_create_page'),
+        );
       }
 
       if (isUserPage(pathToCreate)) {
         const isExistUser = await User.isExistUserByUserPagePath(pathToCreate);
         if (!isExistUser) {
-          return res.apiv3Err("Unable to create a page under a non-existent user's user page");
+          return res.apiv3Err(
+            "Unable to create a page under a non-existent user's user page",
+          );
         }
       }
 
-      const { body, tags } = await determineBodyAndTags(pathToCreate, bodyByParam, tagsByParam);
+      const { body, tags } = await determineBodyAndTags(
+        pathToCreate,
+        bodyByParam,
+        tagsByParam,
+      );
 
       let createdPage: HydratedDocument<PageDocument>;
       try {
         const {
-          grant, grantUserGroupIds, onlyInheritUserRelatedGrantedGroups, overwriteScopesOfDescendants, wip, origin,
+          grant,
+          grantUserGroupIds,
+          onlyInheritUserRelatedGrantedGroups,
+          overwriteScopesOfDescendants,
+          wip,
+          origin,
         } = req.body;
 
         const options: IOptionsForCreate = {
-          onlyInheritUserRelatedGrantedGroups, overwriteScopesOfDescendants, wip, origin,
+          onlyInheritUserRelatedGrantedGroups,
+          overwriteScopesOfDescendants,
+          wip,
+          origin,
         };
         if (grant != null) {
           options.grant = grant;
@@ -262,8 +352,7 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
           req.user,
           options,
         );
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Error occurred while creating a page.', err);
         return res.apiv3Err(err);
       }

+ 71 - 50
apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts

@@ -1,10 +1,10 @@
 import type { IPage, IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { query } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -13,65 +13,86 @@ import loggerFactory from '~/utils/logger';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:page:get-pages-by-page-paths');
 
-type GetPagePathsWithDescendantCountFactory = (crowi: Crowi) => RequestHandler[];
+type GetPagePathsWithDescendantCountFactory = (
+  crowi: Crowi,
+) => RequestHandler[];
 
 type ReqQuery = {
-  paths: string[],
-  userGroups?: string[],
-  isIncludeEmpty?: boolean,
-  includeAnyoneWithTheLink?: boolean,
-}
+  paths: string[];
+  userGroups?: string[];
+  isIncludeEmpty?: boolean;
+  includeAnyoneWithTheLink?: boolean;
+};
 
 interface Req extends Request<undefined, ApiV3Response, undefined, ReqQuery> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
-export const getPagePathsWithDescendantCountFactory: GetPagePathsWithDescendantCountFactory = (crowi) => {
-  const Page = mongoose.model<IPage, PageModel>('Page');
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+export const getPagePathsWithDescendantCountFactory: GetPagePathsWithDescendantCountFactory =
+  (crowi) => {
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const loginRequiredStrictly =
+      require('../../../middlewares/login-required')(crowi);
 
-  const validator: ValidationChain[] = [
-    query('paths').isArray().withMessage('paths must be an array of strings'),
-    query('paths').custom((paths: string[]) => {
-      if (paths.length > 300) {
-        throw new Error('paths must be an array of strings with a maximum length of 300');
-      }
-      return true;
-    }),
-    query('paths.*') // each item of paths
-      .isString()
-      .withMessage('paths must be an array of strings'),
+    const validator: ValidationChain[] = [
+      query('paths').isArray().withMessage('paths must be an array of strings'),
+      query('paths').custom((paths: string[]) => {
+        if (paths.length > 300) {
+          throw new Error(
+            'paths must be an array of strings with a maximum length of 300',
+          );
+        }
+        return true;
+      }),
+      query('paths.*') // each item of paths
+        .isString()
+        .withMessage('paths must be an array of strings'),
 
-    query('userGroups').optional().isArray().withMessage('userGroups must be an array of strings'),
-    query('userGroups.*') // each item of userGroups
-      .isMongoId()
-      .withMessage('userGroups must be an array of strings'),
+      query('userGroups')
+        .optional()
+        .isArray()
+        .withMessage('userGroups must be an array of strings'),
+      query('userGroups.*') // each item of userGroups
+        .isMongoId()
+        .withMessage('userGroups must be an array of strings'),
 
-    query('isIncludeEmpty').optional().isBoolean().withMessage('isIncludeEmpty must be a boolean'),
-    query('isIncludeEmpty').toBoolean(),
+      query('isIncludeEmpty')
+        .optional()
+        .isBoolean()
+        .withMessage('isIncludeEmpty must be a boolean'),
+      query('isIncludeEmpty').toBoolean(),
 
-    query('includeAnyoneWithTheLink').optional().isBoolean().withMessage('includeAnyoneWithTheLink must be a boolean'),
-    query('includeAnyoneWithTheLink').toBoolean(),
-  ];
+      query('includeAnyoneWithTheLink')
+        .optional()
+        .isBoolean()
+        .withMessage('includeAnyoneWithTheLink must be a boolean'),
+      query('includeAnyoneWithTheLink').toBoolean(),
+    ];
 
-  return [
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
-      const {
-        paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink,
-      } = req.query;
+    return [
+      accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+      loginRequiredStrictly,
+      validator,
+      apiV3FormValidator,
+      async (req: Req, res: ApiV3Response) => {
+        const { paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink } =
+          req.query;
 
-      try {
-        const pagePathsWithDescendantCount = await Page.descendantCountByPaths(paths, req.user, userGroups, isIncludeEmpty, includeAnyoneWithTheLink);
-        return res.apiv3({ pagePathsWithDescendantCount });
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(err);
-      }
-    },
-  ];
-};
+        try {
+          const pagePathsWithDescendantCount =
+            await Page.descendantCountByPaths(
+              paths,
+              req.user,
+              userGroups,
+              isIncludeEmpty,
+              includeAnyoneWithTheLink,
+            );
+          return res.apiv3({ pagePathsWithDescendantCount });
+        } catch (err) {
+          logger.error(err);
+          return res.apiv3Err(err);
+        }
+      },
+    ];
+  };

+ 23 - 13
apps/app/src/server/routes/apiv3/page/get-yjs-data.ts

@@ -1,11 +1,11 @@
 import type { IPage, IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -14,42 +14,52 @@ import loggerFactory from '~/utils/logger';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:page:get-yjs-data');
 
 type GetYjsDataHandlerFactory = (crowi: Crowi) => RequestHandler[];
 
 type ReqParams = {
-  pageId: string,
-}
+  pageId: string;
+};
 interface Req extends Request<ReqParams, ApiV3Response> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 export const getYjsDataHandlerFactory: GetYjsDataHandlerFactory = (crowi) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
 
   // define validators for req.params
   const validator: ValidationChain[] = [
-    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+    param('pageId')
+      .isMongoId()
+      .withMessage('The param "pageId" must be specified'),
   ];
 
   return [
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator,
+    apiV3FormValidator,
+    async (req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;
 
       // check whether accessible
       if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Current user is not accessible to this page.',
+            'forbidden-page',
+          ),
+          403,
+        );
       }
 
       try {
         const yjsData = await crowi.pageService.getYjsData(pageId);
         return res.apiv3({ yjsData });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err);
       }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 501 - 296
apps/app/src/server/routes/apiv3/page/index.ts


+ 19 - 14
apps/app/src/server/routes/apiv3/page/publish-page.ts

@@ -1,11 +1,11 @@
 import type { IPage, IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -14,34 +14,40 @@ import loggerFactory from '~/utils/logger';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:page:unpublish-page');
 
-
 type ReqParams = {
-  pageId: string,
-}
+  pageId: string;
+};
 
 interface Req extends Request<ReqParams, ApiV3Response> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 
 type PublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
-export const publishPageHandlersFactory: PublishPageHandlersFactory = (crowi) => {
+export const publishPageHandlersFactory: PublishPageHandlersFactory = (
+  crowi,
+) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
 
   // define validators for req.body
   const validator: ValidationChain[] = [
-    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+    param('pageId')
+      .isMongoId()
+      .withMessage('The param "pageId" must be specified'),
   ];
 
   return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator,
+    apiV3FormValidator,
+    async (req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;
 
       try {
@@ -53,8 +59,7 @@ export const publishPageHandlersFactory: PublishPageHandlersFactory = (crowi) =>
         page.publish();
         const updatedPage = await page.save();
         return res.apiv3(updatedPage);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err);
       }

+ 60 - 40
apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts

@@ -1,11 +1,11 @@
 import type { IPage, IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
-import { param, body } from 'express-validator';
+import { body, param } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -15,51 +15,71 @@ import loggerFactory from '~/utils/logger';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
+const logger = loggerFactory(
+  'growi:routes:apiv3:page:sync-latest-revision-body-to-yjs-draft',
+);
 
-const logger = loggerFactory('growi:routes:apiv3:page:sync-latest-revision-body-to-yjs-draft');
-
-type SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi: Crowi) => RequestHandler[];
+type SyncLatestRevisionBodyToYjsDraftHandlerFactory = (
+  crowi: Crowi,
+) => RequestHandler[];
 
 type ReqParams = {
-  pageId: string,
-}
+  pageId: string;
+};
 type ReqBody = {
-  editingMarkdownLength?: number,
-}
+  editingMarkdownLength?: number;
+};
 interface Req extends Request<ReqParams, ApiV3Response, ReqBody> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
-export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi) => {
-  const Page = mongoose.model<IPage, PageModel>('Page');
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory =
+  (crowi) => {
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const loginRequiredStrictly =
+      require('../../../middlewares/login-required')(crowi);
 
-  // define validators for req.params
-  const validator: ValidationChain[] = [
-    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
-    body('editingMarkdownLength').optional().isInt().withMessage('The body "editingMarkdownLength" must be integer'),
-  ];
+    // define validators for req.params
+    const validator: ValidationChain[] = [
+      param('pageId')
+        .isMongoId()
+        .withMessage('The param "pageId" must be specified'),
+      body('editingMarkdownLength')
+        .optional()
+        .isInt()
+        .withMessage('The body "editingMarkdownLength" must be integer'),
+    ];
 
-  return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
-      const { pageId } = req.params;
-      const { editingMarkdownLength } = req.body;
+    return [
+      accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+      loginRequiredStrictly,
+      validator,
+      apiV3FormValidator,
+      async (req: Req, res: ApiV3Response) => {
+        const { pageId } = req.params;
+        const { editingMarkdownLength } = req.body;
 
-      // check whether accessible
-      if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
-      }
+        // check whether accessible
+        if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+          return res.apiv3Err(
+            new ErrorV3(
+              'Current user is not accessible to this page.',
+              'forbidden-page',
+            ),
+            403,
+          );
+        }
 
-      try {
-        const yjsService = getYjsService();
-        const result = await yjsService.syncWithTheLatestRevisionForce(pageId, editingMarkdownLength);
-        return res.apiv3(result);
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(err);
-      }
-    },
-  ];
-};
+        try {
+          const yjsService = getYjsService();
+          const result = await yjsService.syncWithTheLatestRevisionForce(
+            pageId,
+            editingMarkdownLength,
+          );
+          return res.apiv3(result);
+        } catch (err) {
+          logger.error(err);
+          return res.apiv3Err(err);
+        }
+      },
+    ];
+  };

+ 19 - 14
apps/app/src/server/routes/apiv3/page/unpublish-page.ts

@@ -1,11 +1,11 @@
 import type { IPage, IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -14,34 +14,40 @@ import loggerFactory from '~/utils/logger';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:page:unpublish-page');
 
-
 type ReqParams = {
-  pageId: string,
-}
+  pageId: string;
+};
 
 interface Req extends Request<ReqParams, ApiV3Response> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 
 type UnpublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
-export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (crowi) => {
+export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (
+  crowi,
+) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
 
   // define validators for req.body
   const validator: ValidationChain[] = [
-    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+    param('pageId')
+      .isMongoId()
+      .withMessage('The param "pageId" must be specified'),
   ];
 
   return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator,
+    apiV3FormValidator,
+    async (req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;
 
       try {
@@ -54,8 +60,7 @@ export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (crowi
         const updatedPage = await page.save();
 
         return res.apiv3(updatedPage);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err);
       }

+ 153 - 56
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -1,11 +1,12 @@
-import { Origin, allOrigin, getIdForRef } from '@growi/core';
-import type {
-  IPage, IRevisionHasId, IUserHasId,
-} from '@growi/core';
+import type { IPage, IRevisionHasId, IUserHasId } from '@growi/core';
+import { allOrigin, getIdForRef, Origin } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
-import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-path-utils';
+import {
+  isTopPage,
+  isUsersProtectedPages,
+} from '@growi/core/dist/utils/page-path-utils';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
@@ -14,14 +15,20 @@ import mongoose from 'mongoose';
 
 import { isAiEnabled } from '~/features/openai/server/services';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
-import { type IApiv3PageUpdateParams, PageUpdateErrorCode } from '~/interfaces/apiv3';
+import {
+  type IApiv3PageUpdateParams,
+  PageUpdateErrorCode,
+} from '~/interfaces/apiv3';
 import type { IOptionsForUpdate } from '~/interfaces/page';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
-import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
+import {
+  serializePageSecurely,
+  serializeRevisionSecurely,
+} from '~/server/models/serializers';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import { getYjsService } from '~/server/service/yjs';
@@ -32,14 +39,12 @@ import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:page:update-page');
 
-
 type ReqBody = IApiv3PageUpdateParams;
 
 interface UpdatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 
 type UpdatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
@@ -48,31 +53,63 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
   const Revision = mongoose.model<IRevisionHasId>('Revision');
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
 
   // define validators for req.body
   const validator: ValidationChain[] = [
-    body('pageId').isMongoId().exists().not()
+    body('pageId')
+      .isMongoId()
+      .exists()
+      .not()
       .isEmpty({ ignore_whitespace: true })
       .withMessage("'pageId' must be specified"),
-    body('revisionId').optional().exists().not()
+    body('revisionId')
+      .optional()
+      .exists()
+      .not()
       .isEmpty({ ignore_whitespace: true })
       .withMessage("'revisionId' must be specified"),
-    body('body').exists().isString()
+    body('body')
+      .exists()
+      .isString()
       .withMessage("Empty value is not allowed for 'body'"),
-    body('grant').optional().not().isString()
+    body('grant')
+      .optional()
+      .not()
+      .isString()
       .isInt({ min: 0, max: 5 })
       .withMessage('grant must be an integer from 1 to 5'),
-    body('userRelatedGrantUserGroupIds').optional().isArray().withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
-    body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
-    body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
-    body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
-    body('origin').optional().isIn(allOrigin).withMessage('origin must be "view" or "editor"'),
+    body('userRelatedGrantUserGroupIds')
+      .optional()
+      .isArray()
+      .withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
+    body('overwriteScopesOfDescendants')
+      .optional()
+      .isBoolean()
+      .withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('isSlackEnabled')
+      .optional()
+      .isBoolean()
+      .withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels')
+      .optional()
+      .isString()
+      .withMessage('slackChannels must be string'),
+    body('origin')
+      .optional()
+      .isIn(allOrigin)
+      .withMessage('origin must be "view" or "editor"'),
     body('wip').optional().isBoolean().withMessage('wip must be boolean'),
   ];
 
-
-  async function postAction(req: UpdatePageRequest, res: ApiV3Response, updatedPage: HydratedDocument<PageDocument>, previousRevision: IRevisionHasId | null) {
+  async function postAction(
+    req: UpdatePageRequest,
+    res: ApiV3Response,
+    updatedPage: HydratedDocument<PageDocument>,
+    previousRevision: IRevisionHasId | null,
+  ) {
     // Reflect the updates in ydoc
     const origin = req.body.origin;
     if (origin === Origin.View || origin === undefined) {
@@ -81,7 +118,10 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     }
 
     // persist activity
-    const creator = updatedPage.creator != null ? getIdForRef(updatedPage.creator) : undefined;
+    const creator =
+      updatedPage.creator != null
+        ? getIdForRef(updatedPage.creator)
+        : undefined;
     const parameters = {
       targetModel: SupportedTargetModel.MODEL_PAGE,
       target: updatedPage,
@@ -89,16 +129,21 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     };
     const activityEvent = crowi.event('activity');
     activityEvent.emit(
-      'update', res.locals.activity._id, parameters,
+      'update',
+      res.locals.activity._id,
+      parameters,
       { path: updatedPage.path, creator },
       preNotifyService.generatePreNotify,
     );
 
     // global notification
     try {
-      await crowi.globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, updatedPage, req.user);
-    }
-    catch (err) {
+      await crowi.globalNotificationService.fire(
+        GlobalNotificationSettingEvent.PAGE_EDIT,
+        updatedPage,
+        req.user,
+      );
+    } catch (err) {
       logger.error('Edit notification failed', err);
     }
 
@@ -106,27 +151,34 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     const { isSlackEnabled, slackChannels } = req.body;
     if (isSlackEnabled) {
       try {
-        const option = previousRevision != null ? { previousRevision } : undefined;
-        const results = await crowi.userNotificationService.fire(updatedPage, req.user, slackChannels, 'update', option);
+        const option =
+          previousRevision != null ? { previousRevision } : undefined;
+        const results = await crowi.userNotificationService.fire(
+          updatedPage,
+          req.user,
+          slackChannels,
+          'update',
+          option,
+        );
         results.forEach((result) => {
           if (result.status === 'rejected') {
             logger.error('Create user notification failed', result.reason);
           }
         });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Create user notification failed', err);
       }
     }
 
     // Rebuild vector store file
     if (isAiEnabled()) {
-      const { getOpenaiService } = await import('~/features/openai/server/services/openai');
+      const { getOpenaiService } = await import(
+        '~/features/openai/server/services/openai'
+      );
       try {
         const openaiService = getOpenaiService();
         await openaiService?.updateVectorStoreFileOnPageUpdate(updatedPage);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Rebuild vector store failed', err);
       }
     }
@@ -135,62 +187,102 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
   const addActivity = generateAddActivityMiddleware();
 
   return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly, excludeReadOnlyUser, addActivity,
-    validator, apiV3FormValidator,
-    async(req: UpdatePageRequest, res: ApiV3Response) => {
-      const {
-        pageId, revisionId, body, origin, grant,
-      } = req.body;
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    validator,
+    apiV3FormValidator,
+    async (req: UpdatePageRequest, res: ApiV3Response) => {
+      const { pageId, revisionId, body, origin, grant } = req.body;
 
-      const sanitizeRevisionId = revisionId == null ? undefined : generalXssFilter.process(revisionId);
+      const sanitizeRevisionId =
+        revisionId == null ? undefined : generalXssFilter.process(revisionId);
 
       // check page existence
-      const isExist = await Page.count({ _id: { $eq: pageId } }) > 0;
+      const isExist = (await Page.count({ _id: { $eq: pageId } })) > 0;
       if (!isExist) {
-        return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
+        return res.apiv3Err(
+          new ErrorV3(
+            `Page('${pageId}' is not found or forbidden`,
+            'notfound_or_forbidden',
+          ),
+          400,
+        );
       }
 
       // check revision
       const currentPage = await Page.findByIdAndViewer(pageId, req.user);
       // check page existence (for type safety)
       if (currentPage == null) {
-        return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
+        return res.apiv3Err(
+          new ErrorV3(
+            `Page('${pageId}' is not found or forbidden`,
+            'notfound_or_forbidden',
+          ),
+          400,
+        );
       }
 
-      const isGrantImmutable = isTopPage(currentPage.path) || isUsersProtectedPages(currentPage.path);
+      const isGrantImmutable =
+        isTopPage(currentPage.path) || isUsersProtectedPages(currentPage.path);
 
       if (grant != null && grant !== currentPage.grant && isGrantImmutable) {
-        return res.apiv3Err(new ErrorV3('The grant settings for the specified page cannot be modified.', PageUpdateErrorCode.FORBIDDEN), 403);
+        return res.apiv3Err(
+          new ErrorV3(
+            'The grant settings for the specified page cannot be modified.',
+            PageUpdateErrorCode.FORBIDDEN,
+          ),
+          403,
+        );
       }
 
       if (currentPage != null) {
         // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
         try {
           await normalizeLatestRevisionIfBroken(pageId);
-        }
-        catch (err) {
+        } catch (err) {
           logger.error('Error occurred in normalizing the latest revision');
         }
       }
 
-      if (currentPage != null && !await currentPage.isUpdatable(sanitizeRevisionId, origin)) {
-        const latestRevision = await Revision.findById(currentPage.revision).populate('author');
+      if (
+        currentPage != null &&
+        !(await currentPage.isUpdatable(sanitizeRevisionId, origin))
+      ) {
+        const latestRevision = await Revision.findById(
+          currentPage.revision,
+        ).populate('author');
         const returnLatestRevision = {
           revisionId: latestRevision?._id.toString(),
           revisionBody: latestRevision?.body,
           createdAt: latestRevision?.createdAt,
           user: serializeUserSecurely(latestRevision?.author),
         };
-        return res.apiv3Err(new ErrorV3('Posted param "revisionId" is outdated.', PageUpdateErrorCode.CONFLICT, undefined, { returnLatestRevision }), 409);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Posted param "revisionId" is outdated.',
+            PageUpdateErrorCode.CONFLICT,
+            undefined,
+            { returnLatestRevision },
+          ),
+          409,
+        );
       }
 
       let updatedPage: HydratedDocument<PageDocument>;
       let previousRevision: IRevisionHasId | null;
       try {
         const {
-          userRelatedGrantUserGroupIds, overwriteScopesOfDescendants, wip,
+          userRelatedGrantUserGroupIds,
+          overwriteScopesOfDescendants,
+          wip,
         } = req.body;
-        const options: IOptionsForUpdate = { overwriteScopesOfDescendants, origin, wip };
+        const options: IOptionsForUpdate = {
+          overwriteScopesOfDescendants,
+          origin,
+          wip,
+        };
         if (grant != null) {
           options.grant = grant;
           options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
@@ -199,9 +291,14 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
 
         // There are cases where "revisionId" is not required for revision updates
         // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
-        updatedPage = await crowi.pageService.updatePage(currentPage, body, previousRevision?.body ?? null, req.user, options);
-      }
-      catch (err) {
+        updatedPage = await crowi.pageService.updatePage(
+          currentPage,
+          body,
+          previousRevision?.body ?? null,
+          req.user,
+          options,
+        );
+      } catch (err) {
         logger.error('Error occurred while updating a page.', err);
         return res.apiv3Err(err);
       }

+ 1 - 0
apps/app/src/services/renderer/recommended-whitelist.ts

@@ -59,6 +59,7 @@ export const tagNames: Array<string> = [
 ];
 
 export const attributes: Attributes = deepmerge(relaxedSchemaAttributes, {
+  a: ['target'],
   iframe: ['allow', 'referrerpolicy', 'sandbox', 'src'],
   video: ['controls', 'src', 'muted', 'preload', 'width', 'height', 'autoplay'],
   // The special value 'data*' as a property name can be used to allow all data properties.

+ 3 - 0
apps/app/src/states/page/hydrate.ts

@@ -11,6 +11,7 @@ import {
   currentPageDataAtom,
   currentPageIdAtom,
   isForbiddenAtom,
+  isIdenticalPathAtom,
   pageNotFoundAtom,
   redirectFromAtom,
   remoteRevisionBodyAtom,
@@ -50,6 +51,7 @@ export const useHydratePageAtoms = (
     redirectFrom?: string;
     templateTags?: string[];
     templateBody?: string;
+    isIdenticalPath?: boolean;
   },
 ): void => {
   useHydrateAtoms([
@@ -78,6 +80,7 @@ export const useHydratePageAtoms = (
       [shareLinkIdAtom, options?.shareLinkId],
 
       [redirectFromAtom, options?.redirectFrom ?? undefined],
+      [isIdenticalPathAtom, options?.isIdenticalPath ?? false],
 
       // Template data - from options (not auto-extracted from page)
       [templateTagsAtom, options?.templateTags ?? []],

+ 0 - 2
biome.json

@@ -30,8 +30,6 @@
       "!packages/pdf-converter-client/specs",
       "!apps/app/src/client",
       "!apps/app/src/server/middlewares",
-      "!apps/app/src/server/routes/apiv3/app-settings",
-      "!apps/app/src/server/routes/apiv3/page",
       "!apps/app/src/server/routes/apiv3/*.js",
       "!apps/app/src/server/service"
     ]

+ 1 - 1
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/TableButton.tsx

@@ -22,7 +22,7 @@ export const TableButton = (props: Props): JSX.Element => {
       className="btn btn-toolbar-button"
       onClick={onClickTableButton}
     >
-      <span className="material-symbols-outlined fs-5">table_chart</span>
+      <span className="material-symbols-outlined fs-5">table</span>
     </button>
   );
 };

+ 9 - 9
pnpm-lock.yaml

@@ -764,8 +764,8 @@ importers:
         specifier: ^11.0.3
         version: 11.1.0
       validator:
-        specifier: ^13.15.20
-        version: 13.15.20
+        specifier: ^13.15.22
+        version: 13.15.23
       ws:
         specifier: ^8.17.1
         version: 8.18.0
@@ -14959,8 +14959,8 @@ packages:
     resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
 
-  validator@13.15.20:
-    resolution: {integrity: sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==}
+  validator@13.15.23:
+    resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==}
     engines: {node: '>= 0.10'}
 
   vary@1.1.2:
@@ -17795,7 +17795,7 @@ snapshots:
       loglevel: 1.9.2
       loglevel-plugin-prefix: 0.8.4
       minimatch: 6.2.0
-      validator: 13.15.20
+      validator: 13.15.23
     transitivePeerDependencies:
       - encoding
 
@@ -24970,7 +24970,7 @@ snapshots:
   express-validator@6.15.0:
     dependencies:
       lodash: 4.17.21
-      validator: 13.15.20
+      validator: 13.15.23
 
   express@4.21.0:
     dependencies:
@@ -28301,7 +28301,7 @@ snapshots:
       '@lykmapipo/phone': 0.7.16
       lodash: 4.17.21
       mongoose: 6.13.8(@aws-sdk/client-sso-oidc@3.600.0)
-      validator: 13.15.20
+      validator: 13.15.23
 
   mongoose@6.13.8(@aws-sdk/client-sso-oidc@3.600.0):
     dependencies:
@@ -32250,7 +32250,7 @@ snapshots:
 
   validate-npm-package-name@5.0.1: {}
 
-  validator@13.15.20: {}
+  validator@13.15.23: {}
 
   vary@1.1.2: {}
 
@@ -32873,7 +32873,7 @@ snapshots:
     dependencies:
       lodash.get: 4.4.2
       lodash.isequal: 4.5.0
-      validator: 13.15.20
+      validator: 13.15.23
     optionalDependencies:
       commander: 10.0.1
 

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است