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

Merge branch 'dev/7.4.x' into fix/disable-logo-update-without-file

hikaruNAKANO 4 месяцев назад
Родитель
Сommit
7957f36233
92 измененных файлов с 7481 добавлено и 4026 удалено
  1. 4 0
      apps/app/.eslintrc.js
  2. 3 1
      apps/app/next.config.js
  3. 1 1
      apps/app/package.json
  4. 5 1
      apps/app/public/static/locales/en_US/admin.json
  5. 5 1
      apps/app/public/static/locales/fr_FR/admin.json
  6. 6 1
      apps/app/public/static/locales/ja_JP/admin.json
  7. 5 1
      apps/app/public/static/locales/ko_KR/admin.json
  8. 5 1
      apps/app/public/static/locales/zh_CN/admin.json
  9. 5 8
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  10. 6 1
      apps/app/src/client/components/Admin/UserManagement.tsx
  11. 39 0
      apps/app/src/client/components/Admin/Users/UserStatisticsTable.tsx
  12. 4 4
      apps/app/src/client/components/Page/DisplaySwitcher.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. 8 5
      apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx
  20. 20 13
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  21. 3 4
      apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts
  22. 5 0
      apps/app/src/features/search/client/components/PrivateLegacyPages.tsx
  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. 14 4
      apps/app/src/pages/[[...path]]/use-shallow-routing.ts
  26. 6 6
      apps/app/src/pages/_private-legacy-pages/index.page.tsx
  27. 3 4
      apps/app/src/pages/general-page/configuration-props.ts
  28. 1 1
      apps/app/src/pages/general-page/use-initial-skip-ssr-fetch.ts
  29. 72 37
      apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.integ.ts
  30. 173 94
      apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.ts
  31. 339 175
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  32. 101 45
      apps/app/src/server/routes/apiv3/attachment.js
  33. 85 55
      apps/app/src/server/routes/apiv3/bookmarks.js
  34. 424 221
      apps/app/src/server/routes/apiv3/customize-setting.js
  35. 64 36
      apps/app/src/server/routes/apiv3/export.js
  36. 110 62
      apps/app/src/server/routes/apiv3/forgot-password.js
  37. 79 24
      apps/app/src/server/routes/apiv3/index.js
  38. 1 2
      apps/app/src/server/routes/apiv3/logout.js
  39. 110 49
      apps/app/src/server/routes/apiv3/markdown-setting.js
  40. 23 11
      apps/app/src/server/routes/apiv3/mongo.js
  41. 243 176
      apps/app/src/server/routes/apiv3/notification-setting.js
  42. 16 11
      apps/app/src/server/routes/apiv3/page/check-page-existence.ts
  43. 154 65
      apps/app/src/server/routes/apiv3/page/create-page.ts
  44. 71 50
      apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts
  45. 23 13
      apps/app/src/server/routes/apiv3/page/get-yjs-data.ts
  46. 501 296
      apps/app/src/server/routes/apiv3/page/index.ts
  47. 19 14
      apps/app/src/server/routes/apiv3/page/publish-page.ts
  48. 60 40
      apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts
  49. 19 14
      apps/app/src/server/routes/apiv3/page/unpublish-page.ts
  50. 153 56
      apps/app/src/server/routes/apiv3/page/update-page.ts
  51. 4 3
      apps/app/src/server/routes/apiv3/response.js
  52. 74 35
      apps/app/src/server/routes/apiv3/revisions.js
  53. 80 27
      apps/app/src/server/routes/apiv3/search.js
  54. 167 119
      apps/app/src/server/routes/apiv3/share-links.js
  55. 62 28
      apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  56. 490 271
      apps/app/src/server/routes/apiv3/slack-integration-settings.js
  57. 315 153
      apps/app/src/server/routes/apiv3/slack-integration.js
  58. 10 12
      apps/app/src/server/routes/apiv3/staffs.js
  59. 45 39
      apps/app/src/server/routes/apiv3/statistics.js
  60. 44 21
      apps/app/src/server/routes/apiv3/user-group-relation.js
  61. 349 192
      apps/app/src/server/routes/apiv3/user-group.js
  62. 402 224
      apps/app/src/server/routes/apiv3/users.js
  63. 32 36
      apps/app/src/server/service/acl.integ.ts
  64. 5 7
      apps/app/src/server/service/acl.ts
  65. 99 57
      apps/app/src/server/service/activity.ts
  66. 5 7
      apps/app/src/server/service/app.ts
  67. 51 25
      apps/app/src/server/service/attachment.ts
  68. 22 19
      apps/app/src/server/service/comment.ts
  69. 2 5
      apps/app/src/server/service/cron.ts
  70. 36 22
      apps/app/src/server/service/customize.ts
  71. 55 30
      apps/app/src/server/service/export.ts
  72. 24 19
      apps/app/src/server/service/external-account.ts
  73. 12 8
      apps/app/src/server/service/file-uploader-switch.ts
  74. 217 112
      apps/app/src/server/service/g2g-transfer.ts
  75. 18 17
      apps/app/src/server/service/i18next.ts
  76. 92 58
      apps/app/src/server/service/in-app-notification.ts
  77. 74 47
      apps/app/src/server/service/installer.ts
  78. 81 51
      apps/app/src/server/service/ldap.ts
  79. 37 31
      apps/app/src/server/service/mail.ts
  80. 513 206
      apps/app/src/server/service/page-grant.ts
  81. 97 40
      apps/app/src/server/service/page-operation.ts
  82. 60 56
      apps/app/src/server/service/passport.spec.ts
  83. 344 174
      apps/app/src/server/service/passport.ts
  84. 27 22
      apps/app/src/server/service/pre-notify.ts
  85. 9 10
      apps/app/src/server/service/rest-qiita-API.js
  86. 266 119
      apps/app/src/server/service/search.ts
  87. 115 53
      apps/app/src/server/service/slack-integration.ts
  88. 101 35
      apps/app/src/server/service/user-group.ts
  89. 1 0
      apps/app/src/services/renderer/recommended-whitelist.ts
  90. 3 0
      apps/app/src/states/page/hydrate.ts
  91. 22 3
      biome.json
  92. 9 9
      pnpm-lock.yaml

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

@@ -69,7 +69,11 @@ 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',
+    'src/server/service/*.ts',
+    'src/server/service/*.js',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 3 - 1
apps/app/next.config.js

@@ -160,8 +160,10 @@ module.exports = async (phase) => {
   };
 
   // production server
+  // Skip withSuperjson() in production server phase because the pages directory
+  // doesn't exist in the production build and withSuperjson() tries to find it
   if (phase === PHASE_PRODUCTION_SERVER) {
-    return withSuperjson()(nextConfig);
+    return nextConfig;
   }
 
   const withBundleAnalyzer = require('@next/bundle-analyzer')({

+ 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 />
       }

+ 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'] ?? '';
 

+ 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({

+ 5 - 0
apps/app/src/features/search/client/components/PrivateLegacyPages.tsx

@@ -281,6 +281,11 @@ const PrivateLegacyPages = (): JSX.Element => {
     (ISelectableAll & IReturnSelectedPageIds) | null
   >(null);
 
+  // biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
+  useEffect(() => {
+    setSearchKeyword(initQ);
+  }, []);
+
   const { data, conditions, mutate } = useSWRxSearch(
     keyword,
     'PrivateLegacyPages',

+ 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]);
 };

+ 14 - 4
apps/app/src/pages/[[...path]]/use-shallow-routing.ts

@@ -11,16 +11,25 @@ import type { CommonEachProps } from '../common-props';
 export const useShallowRouting = (props: CommonEachProps): void => {
   const router = useRouter();
   const lastPathnameRef = useRef<string>();
+  const lastBrowserUrlRef = useRef<string>();
 
   // Sync pathname by Shallow Routing with performance optimization
   useEffect(() => {
     if (!isClient() || !props.currentPathname) return;
 
-    // Skip if pathname hasn't changed (prevents unnecessary operations)
-    if (lastPathnameRef.current === props.currentPathname) return;
-
     const currentURL = decodeURI(window.location.pathname);
 
+    // Skip if both props.currentPathname and browser URL haven't changed
+    // This handles the case where:
+    // 1. props.currentPathname is the same (e.g., /${pageId})
+    // 2. But browser URL changed via navigation (e.g., /path/to/page)
+    if (
+      lastPathnameRef.current === props.currentPathname &&
+      lastBrowserUrlRef.current === currentURL
+    ) {
+      return;
+    }
+
     // Only update if URLs actually differ
     if (currentURL !== props.currentPathname) {
       const { search, hash } = window.location;
@@ -29,7 +38,8 @@ export const useShallowRouting = (props: CommonEachProps): void => {
       });
     }
 
-    // Update reference for next comparison
+    // Update references for next comparison
     lastPathnameRef.current = props.currentPathname;
+    lastBrowserUrlRef.current = currentURL;
   }, [props.currentPathname, router]);
 };

+ 6 - 6
apps/app/src/pages/_private-legacy-pages/index.page.tsx

@@ -21,6 +21,12 @@ const SearchResultLayout = dynamic(
   { ssr: false },
 );
 
+const PrivateLegacyPages = dynamic(
+  // biome-ignore lint/style/noRestrictedImports: no-problem dynamic import
+  () => import('~/features/search/client/components/PrivateLegacyPages'),
+  { ssr: false },
+);
+
 type Props = CommonInitialProps &
   CommonEachProps &
   BasicLayoutConfigurationProps &
@@ -30,12 +36,6 @@ type Props = CommonInitialProps &
 const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
   const { t } = useTranslation();
 
-  const PrivateLegacyPages = dynamic(
-    // biome-ignore lint/style/noRestrictedImports: no-problem dynamic import
-    () => import('~/features/search/client/components/PrivateLegacyPages'),
-    { ssr: false },
-  );
-
   // clear the cache for the current page
   //  in order to fix https://redmine.weseek.co.jp/issues/135811
   // useHydratePageAtoms(undefined);

+ 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:

+ 1 - 1
apps/app/src/pages/general-page/use-initial-skip-ssr-fetch.ts

@@ -14,7 +14,7 @@ export const useInitialCSRFetch = (shouldFetch?: boolean): void => {
 
   useEffect(() => {
     if (shouldFetch) {
-      fetchCurrentPage();
+      fetchCurrentPage({ force: true });
     }
   }, [fetchCurrentPage, shouldFetch]);
 };

+ 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;
 };

+ 101 - 45
apps/app/src/server/routes/apiv3/attachment.js

@@ -9,7 +9,10 @@ import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Attachment } from '~/server/models/attachment';
-import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
+import {
+  serializePageSecurely,
+  serializeRevisionSecurely,
+} from '~/server/models/serializers';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -17,14 +20,10 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
 import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
 
-
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
 
 const router = express.Router();
-const {
-  query, param, body,
-} = require('express-validator');
-
+const { query, param, body } = require('express-validator');
 
 /**
  * @swagger
@@ -135,8 +134,13 @@ const {
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequired = require('../../middlewares/login-required')(
+    crowi,
+    true,
+  );
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const Page = crowi.model('Page');
   const User = crowi.model('User');
   const { attachmentService } = crowi;
@@ -151,14 +155,26 @@ module.exports = (crowi) => {
     ],
     retrieveAttachments: [
       query('pageId').isMongoId().withMessage('pageId is required'),
-      query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
-      query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
+      query('pageNumber')
+        .optional()
+        .isInt()
+        .withMessage('pageNumber must be a number'),
+      query('limit')
+        .optional()
+        .isInt({ max: 100 })
+        .withMessage('You should set less than 100 or not to set limit.'),
     ],
     retrieveFileLimit: [
-      query('fileSize').isNumeric().exists({ checkNull: true }).withMessage('fileSize is required'),
+      query('fileSize')
+        .isNumeric()
+        .exists({ checkNull: true })
+        .withMessage('fileSize is required'),
     ],
     retrieveAddAttachment: [
-      body('page_id').isMongoId().exists({ checkNull: true }).withMessage('page_id is required'),
+      body('page_id')
+        .isMongoId()
+        .exists({ checkNull: true })
+        .withMessage('page_id is required'),
     ],
   };
 
@@ -199,18 +215,29 @@ module.exports = (crowi) => {
    *                  type: object
    *                  $ref: '#/components/schemas/AttachmentPaginateResult'
    */
-  router.get('/list',
-    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }), loginRequired, validator.retrieveAttachments, apiV3FormValidator,
-    async(req, res) => {
-
-      const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
+  router.get(
+    '/list',
+    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }),
+    loginRequired,
+    validator.retrieveAttachments,
+    apiV3FormValidator,
+    async (req, res) => {
+      const limit =
+        req.query.limit ||
+        (await crowi.configManager.getConfig(
+          'customize:showPageLimitationS',
+        )) ||
+        10;
       const pageNumber = req.query.pageNumber || 1;
       const offset = (pageNumber - 1) * limit;
 
       try {
         const pageId = req.query.pageId;
         // check whether accessible
-        const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+        const isAccessible = await Page.isAccessiblePageByViewer(
+          pageId,
+          req.user,
+        );
         if (!isAccessible) {
           const msg = 'Current user is not accessible to this page.';
           return res.apiv3Err(new ErrorV3(msg, 'attachment-list-failed'), 403);
@@ -234,13 +261,12 @@ module.exports = (crowi) => {
         });
 
         return res.apiv3({ paginateResult });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Attachment not found', err);
         return res.apiv3Err(err, 500);
       }
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -274,19 +300,23 @@ module.exports = (crowi) => {
    *          500:
    *            $ref: '#/components/responses/InternalServerError'
    */
-  router.get('/limit',
-    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }), loginRequiredStrictly, validator.retrieveFileLimit, apiV3FormValidator,
-    async(req, res) => {
+  router.get(
+    '/limit',
+    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator.retrieveFileLimit,
+    apiV3FormValidator,
+    async (req, res) => {
       const { fileUploadService } = crowi;
       const fileSize = Number(req.query.fileSize);
       try {
         return res.apiv3(await fileUploadService.checkLimit(fileSize));
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('File limit retrieval failed', err);
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -342,11 +372,19 @@ module.exports = (crowi) => {
    *          500:
    *            $ref: '#/components/responses/InternalServerError'
    */
-  router.post('/', uploads.single('file'), accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT], { acceptLegacy: true }),
-    loginRequiredStrictly, excludeReadOnlyUser, validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
+  router.post(
+    '/',
+    uploads.single('file'),
+    accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    validator.retrieveAddAttachment,
+    apiV3FormValidator,
+    addActivity,
     // Removed autoReap middleware to use file data in asynchronous processes. Instead, implemented file deletion after asynchronous processes complete
-    async(req, res) => {
-
+    async (req, res) => {
       const pageId = req.body.page_id;
 
       // check params
@@ -359,12 +397,21 @@ module.exports = (crowi) => {
         const page = await Page.findOne({ _id: { $eq: pageId } });
 
         // check the user is accessible
-        const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);
+        const isAccessible = await Page.isAccessiblePageByViewer(
+          page.id,
+          req.user,
+        );
         if (!isAccessible) {
           return res.apiv3Err(`Forbidden to access to the page '${page.id}'`);
         }
 
-        const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE, () => autoReap(req, res, () => {}));
+        const attachment = await attachmentService.createAttachment(
+          file,
+          req.user,
+          pageId,
+          AttachmentType.WIKI_PAGE,
+          () => autoReap(req, res, () => {}),
+        );
 
         const result = {
           page: serializePageSecurely(page),
@@ -372,15 +419,17 @@ module.exports = (crowi) => {
           attachment: attachment.toObject({ virtuals: true }),
         };
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_ADD });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_ATTACHMENT_ADD,
+        });
 
         res.apiv3(result);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err.message);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -407,13 +456,20 @@ module.exports = (crowi) => {
    *            schema:
    *              type: string
    */
-  router.get('/:id', accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }), certifySharedPageAttachmentMiddleware, loginRequired,
-    validator.retrieveAttachment, apiV3FormValidator,
-    async(req, res) => {
+  router.get(
+    '/:id',
+    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }),
+    certifySharedPageAttachmentMiddleware,
+    loginRequired,
+    validator.retrieveAttachment,
+    apiV3FormValidator,
+    async (req, res) => {
       try {
         const attachmentId = req.params.id;
 
-        const attachment = await Attachment.findById(attachmentId).populate('creator').exec();
+        const attachment = await Attachment.findById(attachmentId)
+          .populate('creator')
+          .exec();
 
         if (attachment == null) {
           const message = 'Attachment not found';
@@ -425,12 +481,12 @@ module.exports = (crowi) => {
         }
 
         return res.apiv3({ attachment });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Attachment retrieval failed', err);
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
 
   return router;
 };

+ 85 - 55
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -1,7 +1,7 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
@@ -16,7 +16,6 @@ const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-
 const express = require('express');
 const { body, query, param } = require('express-validator');
 
-
 const router = express.Router();
 
 /**
@@ -85,8 +84,13 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
+  const loginRequired = require('../../middlewares/login-required')(
+    crowi,
+    true,
+  );
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const activityEvent = crowi.event('activity');
@@ -94,13 +98,8 @@ module.exports = (crowi) => {
   const { Page, Bookmark } = crowi.models;
 
   const validator = {
-    bookmarks: [
-      body('pageId').isString(),
-      body('bool').isBoolean(),
-    ],
-    bookmarkInfo: [
-      query('pageId').isMongoId(),
-    ],
+    bookmarks: [body('pageId').isString(), body('bool').isBoolean()],
+    bookmarkInfo: [query('pageId').isMongoId()],
   };
 
   /**
@@ -125,24 +124,32 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/BookmarkInfo'
    */
-  router.get('/info',
-    accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequired, validator.bookmarkInfo, apiV3FormValidator, async(req, res) => {
+  router.get(
+    '/info',
+    accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequired,
+    validator.bookmarkInfo,
+    apiV3FormValidator,
+    async (req, res) => {
       const { user } = req;
       const { pageId } = req.query;
 
       const responsesParams = {};
 
       try {
-        const bookmarks = await Bookmark.find({ page: pageId }).populate('user');
+        const bookmarks = await Bookmark.find({ page: pageId }).populate(
+          'user',
+        );
         let users = [];
         if (bookmarks.length > 0) {
-          users = bookmarks.map(bookmark => serializeUserSecurely(bookmark.user));
+          users = bookmarks.map((bookmark) =>
+            serializeUserSecurely(bookmark.user),
+          );
         }
         responsesParams.sumOfBookmarks = bookmarks.length;
         responsesParams.bookmarkedUsers = users;
         responsesParams.pageId = pageId;
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('get-bookmark-document-failed', err);
         return res.apiv3Err(err, 500);
       }
@@ -154,15 +161,14 @@ module.exports = (crowi) => {
 
       try {
         const bookmark = await Bookmark.findByPageIdAndUserId(pageId, user._id);
-        responsesParams.isBookmarked = (bookmark != null);
+        responsesParams.isBookmarked = bookmark != null;
         return res.apiv3(responsesParams);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('get-bookmark-state-failed', err);
         return res.apiv3Err(err, 500);
       }
-
-    });
+    },
+  );
 
   // select page from bookmark where userid = userid
   /**
@@ -192,39 +198,49 @@ module.exports = (crowi) => {
     param('userId').isMongoId().withMessage('userId is required'),
   ];
 
-  router.get('/:userId',
+  router.get(
+    '/:userId',
     accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
-    loginRequired, validator.userBookmarkList, apiV3FormValidator, async(req, res) => {
+    loginRequired,
+    validator.userBookmarkList,
+    apiV3FormValidator,
+    async (req, res) => {
       const { userId } = req.params;
 
       if (userId == null) {
         return res.apiv3Err('User id is not found or forbidden', 400);
       }
       try {
-        const bookmarkIdsInFolders = await BookmarkFolder.distinct('bookmarks', { owner: userId });
+        const bookmarkIdsInFolders = await BookmarkFolder.distinct(
+          'bookmarks',
+          { owner: userId },
+        );
         const userRootBookmarks = await Bookmark.find({
           _id: { $nin: bookmarkIdsInFolders },
           user: userId,
-        }).populate({
-          path: 'page',
-          model: 'Page',
-          populate: {
-            path: 'lastUpdateUser',
-            model: 'User',
-          },
-        }).exec();
+        })
+          .populate({
+            path: 'page',
+            model: 'Page',
+            populate: {
+              path: 'lastUpdateUser',
+              model: 'User',
+            },
+          })
+          .exec();
 
         // serialize Bookmark
-        const serializedUserRootBookmarks = userRootBookmarks.map(bookmark => serializeBookmarkSecurely(bookmark));
+        const serializedUserRootBookmarks = userRootBookmarks.map((bookmark) =>
+          serializeBookmarkSecurely(bookmark),
+        );
 
         return res.apiv3({ userRootBookmarks: serializedUserRootBookmarks });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('get-bookmark-failed', err);
         return res.apiv3Err(err, 500);
       }
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -250,9 +266,14 @@ module.exports = (crowi) => {
    *                    bookmark:
    *                      $ref: '#/components/schemas/Bookmark'
    */
-  router.put('/',
-    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, addActivity, validator.bookmarks, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    addActivity,
+    validator.bookmarks,
+    apiV3FormValidator,
+    async (req, res) => {
       const { pageId, bool } = req.body;
       const userId = req.user?._id;
 
@@ -273,22 +294,22 @@ module.exports = (crowi) => {
         if (bookmark == null) {
           if (bool) {
             bookmark = await Bookmark.add(page, req.user);
+          } else {
+            logger.warn(
+              `Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`,
+            );
           }
-          else {
-            logger.warn(`Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`);
-          }
-        }
-        else {
-        // eslint-disable-next-line no-lonely-if
+        } else {
+          // eslint-disable-next-line no-lonely-if
           if (bool) {
-            logger.warn(`Adding the bookmark for ${page._id} by ${req.user._id} failed because the bookmark has already exist.`);
-          }
-          else {
+            logger.warn(
+              `Adding the bookmark for ${page._id} by ${req.user._id} failed because the bookmark has already exist.`,
+            );
+          } else {
             bookmark = await Bookmark.removeBookmark(page, req.user);
           }
         }
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('update-bookmark-failed', err);
         return res.apiv3Err(err, 500);
       }
@@ -301,13 +322,22 @@ module.exports = (crowi) => {
       const parameters = {
         targetModel: SupportedTargetModel.MODEL_PAGE,
         target: page,
-        action: bool ? SupportedAction.ACTION_PAGE_BOOKMARK : SupportedAction.ACTION_PAGE_UNBOOKMARK,
+        action: bool
+          ? SupportedAction.ACTION_PAGE_BOOKMARK
+          : SupportedAction.ACTION_PAGE_UNBOOKMARK,
       };
 
-      activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
+      activityEvent.emit(
+        'update',
+        res.locals.activity._id,
+        parameters,
+        page,
+        preNotifyService.generatePreNotify,
+      );
 
       return res.apiv3({ bookmark });
-    });
+    },
+  );
 
   return router;
 };

+ 424 - 221
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -1,6 +1,7 @@
 /* eslint-disable no-unused-vars */
 
 import { GrowiPluginType } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
 import { body } from 'express-validator';
@@ -8,7 +9,6 @@ import multer from 'multer';
 
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Attachment } from '~/server/models/attachment';
@@ -18,12 +18,10 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
-
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 
 const router = express.Router();
 
-
 /**
  * @swagger
  *
@@ -195,7 +193,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(crowi);
 
@@ -204,12 +204,8 @@ module.exports = (crowi) => {
   const { customizeService, attachmentService } = crowi;
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const validator = {
-    layout: [
-      body('isContainerFluid').isBoolean(),
-    ],
-    theme: [
-      body('theme').isString(),
-    ],
+    layout: [body('isContainerFluid').isBoolean()],
+    theme: [body('theme').isString()],
     sidebar: [
       body('isSidebarCollapsedMode').isBoolean(),
       body('isSidebarClosedAtDockMode').optional().isBoolean(),
@@ -226,27 +222,28 @@ module.exports = (crowi) => {
       body('isSearchScopeChildrenAsDefault').isBoolean(),
       body('showPageSideAuthors').isBoolean(),
     ],
-    CustomizePresentation: [
-      body('isEnabledMarp').isBoolean(),
-    ],
-    customizeTitle: [
-      body('customizeTitle').isString(),
-    ],
+    CustomizePresentation: [body('isEnabledMarp').isBoolean()],
+    customizeTitle: [body('customizeTitle').isString()],
     highlight: [
-      body('highlightJsStyle').isString().isIn([
-        'github', 'github-gist', 'atom-one-light', 'xcode', 'vs', 'atom-one-dark', 'hybrid', 'monokai', 'tomorrow-night', 'vs2015',
-      ]),
+      body('highlightJsStyle')
+        .isString()
+        .isIn([
+          'github',
+          'github-gist',
+          'atom-one-light',
+          'xcode',
+          'vs',
+          'atom-one-dark',
+          'hybrid',
+          'monokai',
+          'tomorrow-night',
+          'vs2015',
+        ]),
       body('highlightJsStyleBorder').isBoolean(),
     ],
-    customizeScript: [
-      body('customizeScript').isString(),
-    ],
-    customizeCss: [
-      body('customizeCss').isString(),
-    ],
-    customizeNoscript: [
-      body('customizeNoscript').isString(),
-    ],
+    customizeScript: [body('customizeScript').isString()],
+    customizeCss: [body('customizeCss').isString()],
+    customizeNoscript: [body('customizeNoscript').isString()],
     logo: [
       body('isDefaultLogo').isBoolean().optional({ nullable: true }),
       body('customizedLogoSrc').isString().optional({ nullable: true }),
@@ -275,29 +272,57 @@ module.exports = (crowi) => {
    *                      description: customize params
    *                      $ref: '#/components/schemas/CustomizeSetting'
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const customizeParams = {
-      isEnabledTimeline: await configManager.getConfig('customize:isEnabledTimeline'),
-      isEnabledAttachTitleHeader: await configManager.getConfig('customize:isEnabledAttachTitleHeader'),
-      pageLimitationS: await configManager.getConfig('customize:showPageLimitationS'),
-      pageLimitationM: await configManager.getConfig('customize:showPageLimitationM'),
-      pageLimitationL: await configManager.getConfig('customize:showPageLimitationL'),
-      pageLimitationXL: await configManager.getConfig('customize:showPageLimitationXL'),
-      isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
-      isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
-      showPageSideAuthors: await configManager.getConfig('customize:showPageSideAuthors'),
-      isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
-      isEnabledMarp: await configManager.getConfig('customize:isEnabledMarp'),
-      styleName: await configManager.getConfig('customize:highlightJsStyle'),
-      styleBorder: await configManager.getConfig('customize:highlightJsStyleBorder'),
-      customizeTitle: await configManager.getConfig('customize:title'),
-      customizeScript: await configManager.getConfig('customize:script'),
-      customizeCss: await configManager.getConfig('customize:css'),
-      customizeNoscript: await configManager.getConfig('customize:noscript'),
-    };
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const customizeParams = {
+        isEnabledTimeline: await configManager.getConfig(
+          'customize:isEnabledTimeline',
+        ),
+        isEnabledAttachTitleHeader: await configManager.getConfig(
+          'customize:isEnabledAttachTitleHeader',
+        ),
+        pageLimitationS: await configManager.getConfig(
+          'customize:showPageLimitationS',
+        ),
+        pageLimitationM: await configManager.getConfig(
+          'customize:showPageLimitationM',
+        ),
+        pageLimitationL: await configManager.getConfig(
+          'customize:showPageLimitationL',
+        ),
+        pageLimitationXL: await configManager.getConfig(
+          'customize:showPageLimitationXL',
+        ),
+        isEnabledStaleNotification: await configManager.getConfig(
+          'customize:isEnabledStaleNotification',
+        ),
+        isAllReplyShown: await configManager.getConfig(
+          'customize:isAllReplyShown',
+        ),
+        showPageSideAuthors: await configManager.getConfig(
+          'customize:showPageSideAuthors',
+        ),
+        isSearchScopeChildrenAsDefault: await configManager.getConfig(
+          'customize:isSearchScopeChildrenAsDefault',
+        ),
+        isEnabledMarp: await configManager.getConfig('customize:isEnabledMarp'),
+        styleName: await configManager.getConfig('customize:highlightJsStyle'),
+        styleBorder: await configManager.getConfig(
+          'customize:highlightJsStyleBorder',
+        ),
+        customizeTitle: await configManager.getConfig('customize:title'),
+        customizeScript: await configManager.getConfig('customize:script'),
+        customizeCss: await configManager.getConfig('customize:css'),
+        customizeNoscript: await configManager.getConfig('customize:noscript'),
+      };
 
-    return res.apiv3({ customizeParams });
-  });
+      return res.apiv3({ customizeParams });
+    },
+  );
 
   /**
    * @swagger
@@ -317,17 +342,24 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeLayout'
    */
-  router.get('/layout', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    try {
-      const isContainerFluid = await configManager.getConfig('customize:isContainerFluid');
-      return res.apiv3({ isContainerFluid });
-    }
-    catch (err) {
-      const msg = 'Error occurred in getting layout';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'get-layout-failed'));
-    }
-  });
+  router.get(
+    '/layout',
+    accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const isContainerFluid = await configManager.getConfig(
+          'customize:isContainerFluid',
+        );
+        return res.apiv3({ isContainerFluid });
+      } catch (err) {
+        const msg = 'Error occurred in getting layout';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'get-layout-failed'));
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -356,9 +388,15 @@ module.exports = (crowi) => {
    *                      description: customized params
    *                      $ref: '#/components/schemas/CustomizeLayout'
    */
-  router.put('/layout', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.layout, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/layout',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.layout,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'customize:isContainerFluid': req.body.isContainerFluid,
       };
@@ -366,20 +404,24 @@ module.exports = (crowi) => {
       try {
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
-          isContainerFluid: await configManager.getConfig('customize:isContainerFluid'),
+          isContainerFluid: await configManager.getConfig(
+            'customize:isContainerFluid',
+          ),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_LAYOUT_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_LAYOUT_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating layout';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-layout-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -408,26 +450,31 @@ module.exports = (crowi) => {
    *                      items:
    *                        $ref: '#/components/schemas/ThemesMetadata'
    */
-  router.get('/theme', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, async(req, res) => {
-
-    try {
-      const currentTheme = await configManager.getConfig('customize:theme');
+  router.get(
+    '/theme',
+    accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    async (req, res) => {
+      try {
+        const currentTheme = await configManager.getConfig('customize:theme');
 
-      // retrieve plugin manifests
-      const themePlugins = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Theme);
+        // retrieve plugin manifests
+        const themePlugins = await GrowiPlugin.findEnabledPluginsByType(
+          GrowiPluginType.Theme,
+        );
 
-      const pluginThemesMetadatas = themePlugins
-        .map(themePlugin => themePlugin.meta.themes)
-        .flat();
+        const pluginThemesMetadatas = themePlugins.flatMap(
+          (themePlugin) => themePlugin.meta.themes,
+        );
 
-      return res.apiv3({ currentTheme, pluginThemesMetadatas });
-    }
-    catch (err) {
-      const msg = 'Error occurred in getting theme';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'get-theme-failed'));
-    }
-  });
+        return res.apiv3({ currentTheme, pluginThemesMetadatas });
+      } catch (err) {
+        const msg = 'Error occurred in getting theme';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'get-theme-failed'));
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -456,8 +503,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeTheme'
    */
-  router.put('/theme', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity, validator.theme, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/theme',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.theme,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'customize:theme': req.body.theme,
       };
@@ -468,16 +522,18 @@ module.exports = (crowi) => {
           theme: await configManager.getConfig('customize:theme'),
         };
         customizeService.initGrowiTheme();
-        const parameters = { action: SupportedAction.ACTION_ADMIN_THEME_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_THEME_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating theme';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-theme-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -497,19 +553,27 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeSidebar'
    */
-  router.get('/sidebar', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
-
-    try {
-      const isSidebarCollapsedMode = await configManager.getConfig('customize:isSidebarCollapsedMode');
-      const isSidebarClosedAtDockMode = await configManager.getConfig('customize:isSidebarClosedAtDockMode');
-      return res.apiv3({ isSidebarCollapsedMode, isSidebarClosedAtDockMode });
-    }
-    catch (err) {
-      const msg = 'Error occurred in getting sidebar';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'get-sidebar-failed'));
-    }
-  });
+  router.get(
+    '/sidebar',
+    accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const isSidebarCollapsedMode = await configManager.getConfig(
+          'customize:isSidebarCollapsedMode',
+        );
+        const isSidebarClosedAtDockMode = await configManager.getConfig(
+          'customize:isSidebarClosedAtDockMode',
+        );
+        return res.apiv3({ isSidebarCollapsedMode, isSidebarClosedAtDockMode });
+      } catch (err) {
+        const msg = 'Error occurred in getting sidebar';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'get-sidebar-failed'));
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -538,31 +602,44 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeSidebar'
    */
-  router.put('/sidebar', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired,
-    validator.sidebar, apiV3FormValidator, addActivity,
-    async(req, res) => {
+  router.put(
+    '/sidebar',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.sidebar,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
       const requestParams = {
         'customize:isSidebarCollapsedMode': req.body.isSidebarCollapsedMode,
-        'customize:isSidebarClosedAtDockMode': req.body.isSidebarClosedAtDockMode,
+        'customize:isSidebarClosedAtDockMode':
+          req.body.isSidebarClosedAtDockMode,
       };
 
       try {
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
-          isSidebarCollapsedMode: await configManager.getConfig('customize:isSidebarCollapsedMode'),
-          isSidebarClosedAtDockMode: await configManager.getConfig('customize:isSidebarClosedAtDockMode'),
+          isSidebarCollapsedMode: await configManager.getConfig(
+            'customize:isSidebarCollapsedMode',
+          ),
+          isSidebarClosedAtDockMode: await configManager.getConfig(
+            'customize:isSidebarClosedAtDockMode',
+          ),
         };
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SIDEBAR_UPDATE });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_ADMIN_SIDEBAR_UPDATE,
+        });
 
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating sidebar';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-sidebar-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -591,47 +668,77 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeFunction'
    */
-  router.put('/function', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.function, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/function',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.function,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'customize:isEnabledTimeline': req.body.isEnabledTimeline,
-        'customize:isEnabledAttachTitleHeader': req.body.isEnabledAttachTitleHeader,
+        'customize:isEnabledAttachTitleHeader':
+          req.body.isEnabledAttachTitleHeader,
         'customize:showPageLimitationS': req.body.pageLimitationS,
         'customize:showPageLimitationM': req.body.pageLimitationM,
         'customize:showPageLimitationL': req.body.pageLimitationL,
         'customize:showPageLimitationXL': req.body.pageLimitationXL,
-        'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
+        'customize:isEnabledStaleNotification':
+          req.body.isEnabledStaleNotification,
         'customize:isAllReplyShown': req.body.isAllReplyShown,
-        'customize:isSearchScopeChildrenAsDefault': req.body.isSearchScopeChildrenAsDefault,
+        'customize:isSearchScopeChildrenAsDefault':
+          req.body.isSearchScopeChildrenAsDefault,
         'customize:showPageSideAuthors': req.body.showPageSideAuthors,
       };
 
       try {
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
-          isEnabledTimeline: await configManager.getConfig('customize:isEnabledTimeline'),
-          isEnabledAttachTitleHeader: await configManager.getConfig('customize:isEnabledAttachTitleHeader'),
-          pageLimitationS: await configManager.getConfig('customize:showPageLimitationS'),
-          pageLimitationM: await configManager.getConfig('customize:showPageLimitationM'),
-          pageLimitationL: await configManager.getConfig('customize:showPageLimitationL'),
-          pageLimitationXL: await configManager.getConfig('customize:showPageLimitationXL'),
-          isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
-          isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
-          isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
-          showPageSideAuthors: await configManager.getConfig('customize:showPageSideAuthors'),
+          isEnabledTimeline: await configManager.getConfig(
+            'customize:isEnabledTimeline',
+          ),
+          isEnabledAttachTitleHeader: await configManager.getConfig(
+            'customize:isEnabledAttachTitleHeader',
+          ),
+          pageLimitationS: await configManager.getConfig(
+            'customize:showPageLimitationS',
+          ),
+          pageLimitationM: await configManager.getConfig(
+            'customize:showPageLimitationM',
+          ),
+          pageLimitationL: await configManager.getConfig(
+            'customize:showPageLimitationL',
+          ),
+          pageLimitationXL: await configManager.getConfig(
+            'customize:showPageLimitationXL',
+          ),
+          isEnabledStaleNotification: await configManager.getConfig(
+            'customize:isEnabledStaleNotification',
+          ),
+          isAllReplyShown: await configManager.getConfig(
+            'customize:isAllReplyShown',
+          ),
+          isSearchScopeChildrenAsDefault: await configManager.getConfig(
+            'customize:isSearchScopeChildrenAsDefault',
+          ),
+          showPageSideAuthors: await configManager.getConfig(
+            'customize:showPageSideAuthors',
+          ),
+        };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE,
         };
-        const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating function';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-function-failed'));
       }
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -660,9 +767,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizePresentation'
    */
-  router.put('/presentation', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.CustomizePresentation, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/presentation',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.CustomizePresentation,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'customize:isEnabledMarp': req.body.isEnabledMarp,
       };
@@ -670,18 +783,22 @@ module.exports = (crowi) => {
       try {
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
-          isEnabledMarp: await configManager.getConfig('customize:isEnabledMarp'),
+          isEnabledMarp: await configManager.getConfig(
+            'customize:isEnabledMarp',
+          ),
+        };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE,
         };
-        const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating presentaion';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-presentation-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -710,9 +827,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeHighlightResponse'
    */
-  router.put('/highlight', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.highlight, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/highlight',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.highlight,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'customize:highlightJsStyle': req.body.highlightJsStyle,
         'customize:highlightJsStyleBorder': req.body.highlightJsStyleBorder,
@@ -721,19 +844,25 @@ module.exports = (crowi) => {
       try {
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
-          styleName: await configManager.getConfig('customize:highlightJsStyle'),
-          styleBorder: await configManager.getConfig('customize:highlightJsStyleBorder'),
+          styleName: await configManager.getConfig(
+            'customize:highlightJsStyle',
+          ),
+          styleBorder: await configManager.getConfig(
+            'customize:highlightJsStyleBorder',
+          ),
+        };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
         };
-        const parameters = { action: SupportedAction.ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating highlight';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-highlight-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -762,9 +891,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeTitle'
    */
-  router.put('/customize-title', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.customizeTitle, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/customize-title',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.customizeTitle,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'customize:title': req.body.customizeTitle,
       };
@@ -777,16 +912,18 @@ module.exports = (crowi) => {
           customizeTitle: await configManager.getConfig('customize:title'),
         };
         customizeService.initCustomTitle();
-        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_TITLE_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating customizeTitle';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeTitle-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -815,27 +952,38 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeNoscript'
    */
-  router.put('/customize-noscript', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.customizeNoscript, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/customize-noscript',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.customizeNoscript,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'customize:noscript': req.body.customizeNoscript,
       };
       try {
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
-          customizeNoscript: await configManager.getConfig('customize:noscript'),
+          customizeNoscript:
+            await configManager.getConfig('customize:noscript'),
+        };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_CUSTOM_NOSCRIPT_UPDATE,
         };
-        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_NOSCRIPT_UPDATE };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating customizeNoscript';
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-customizeNoscript-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-customizeNoscript-failed'),
+        );
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -864,9 +1012,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeCss'
    */
-  router.put('/customize-css', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.customizeCss, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/customize-css',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.customizeCss,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'customize:css': req.body.customizeCss,
       };
@@ -878,16 +1032,18 @@ module.exports = (crowi) => {
           customizeCss: await configManager.getConfig('customize:css'),
         };
         customizeService.initCustomCss();
-        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_CSS_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_CUSTOM_CSS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating customizeCss';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeCss-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -916,9 +1072,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeScript'
    */
-  router.put('/customize-script', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.customizeScript, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/customize-script',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.customizeScript,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'customize:script': req.body.customizeScript,
       };
@@ -927,16 +1089,18 @@ module.exports = (crowi) => {
         const customizedParams = {
           customizeScript: await configManager.getConfig('customize:script'),
         };
-        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating customizeScript';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeScript-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -965,12 +1129,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeLogo'
    */
-  router.put('/customize-logo', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired,
-    validator.logo, apiV3FormValidator,
-    async(req, res) => {
-      const {
-        isDefaultLogo,
-      } = req.body;
+  router.put(
+    '/customize-logo',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.logo,
+    apiV3FormValidator,
+    async (req, res) => {
+      const { isDefaultLogo } = req.body;
 
       const requestParams = {
         'customize:isDefaultLogo': isDefaultLogo,
@@ -978,16 +1145,18 @@ module.exports = (crowi) => {
       try {
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
-          isDefaultLogo: await configManager.getConfig('customize:isDefaultLogo'),
+          isDefaultLogo: await configManager.getConfig(
+            'customize:isDefaultLogo',
+          ),
         };
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating customizeLogo';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeLogo-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -1028,15 +1197,24 @@ module.exports = (crowi) => {
    *                            temporaryUrlExpiredAt: {}
    *                            temporaryUrlCached: {}
    */
-  router.post('/upload-brand-logo',
-    uploads.single('file'), accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, uploads.single('file'), validator.logo, apiV3FormValidator,
-    async(req, res) => {
-
+  router.post(
+    '/upload-brand-logo',
+    uploads.single('file'),
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    uploads.single('file'),
+    validator.logo,
+    apiV3FormValidator,
+    async (req, res) => {
       if (req.file == null) {
-        return res.apiv3Err(new ErrorV3('File error.', 'upload-brand-logo-failed'));
+        return res.apiv3Err(
+          new ErrorV3('File error.', 'upload-brand-logo-failed'),
+        );
       }
       if (req.user == null) {
-        return res.apiv3Err(new ErrorV3('param "user" must be set.', 'upload-brand-logo-failed'));
+        return res.apiv3Err(
+          new ErrorV3('param "user" must be set.', 'upload-brand-logo-failed'),
+        );
       }
 
       const file = req.file;
@@ -1044,27 +1222,37 @@ module.exports = (crowi) => {
       // check type
       const acceptableFileType = /image\/.+/;
       if (!file.mimetype.match(acceptableFileType)) {
-        const msg = 'File type error. Only image files is allowed to set as user picture.';
+        const msg =
+          'File type error. Only image files is allowed to set as user picture.';
         return res.apiv3Err(new ErrorV3(msg, 'upload-brand-logo-failed'));
       }
 
       // Check if previous attachment exists and remove it
-      const attachments = await Attachment.find({ attachmentType: AttachmentType.BRAND_LOGO });
+      const attachments = await Attachment.find({
+        attachmentType: AttachmentType.BRAND_LOGO,
+      });
       if (attachments != null) {
         await attachmentService.removeAllAttachments(attachments);
       }
 
       let attachment;
       try {
-        attachment = await attachmentService.createAttachment(file, req.user, null, AttachmentType.BRAND_LOGO);
-      }
-      catch (err) {
+        attachment = await attachmentService.createAttachment(
+          file,
+          req.user,
+          null,
+          AttachmentType.BRAND_LOGO,
+        );
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3(err.message, 'upload-brand-logo-failed'));
+        return res.apiv3Err(
+          new ErrorV3(err.message, 'upload-brand-logo-failed'),
+        );
       }
       attachment.toObject({ virtuals: true });
       return res.apiv3({ attachment });
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -1084,24 +1272,39 @@ module.exports = (crowi) => {
    *                schema:
    *                  additionalProperties: false
    */
-  router.delete('/delete-brand-logo', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.delete(
+    '/delete-brand-logo',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const attachments = await Attachment.find({
+        attachmentType: AttachmentType.BRAND_LOGO,
+      });
 
-    const attachments = await Attachment.find({ attachmentType: AttachmentType.BRAND_LOGO });
-
-    if (attachments == null) {
-      return res.apiv3Err(new ErrorV3('attachment not found', 'delete-brand-logo-failed'));
-    }
+      if (attachments == null) {
+        return res.apiv3Err(
+          new ErrorV3('attachment not found', 'delete-brand-logo-failed'),
+        );
+      }
 
-    try {
-      await attachmentService.removeAllAttachments(attachments);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.status(500).apiv3Err(new ErrorV3('Error while deleting logo', 'delete-brand-logo-failed'));
-    }
+      try {
+        await attachmentService.removeAllAttachments(attachments);
+      } catch (err) {
+        logger.error(err);
+        return res
+          .status(500)
+          .apiv3Err(
+            new ErrorV3(
+              'Error while deleting logo',
+              'delete-brand-logo-failed',
+            ),
+          );
+      }
 
-    return res.apiv3({});
-  });
+      return res.apiv3({});
+    },
+  );
 
   return router;
 };

+ 64 - 36
apps/app/src/server/routes/apiv3/export.js

@@ -1,8 +1,7 @@
-import fs from 'fs';
-
 import { SCOPE } from '@growi/core/dist/interfaces';
 import express from 'express';
-import { param, body } from 'express-validator';
+import { body, param } from 'express-validator';
+import fs from 'fs';
 import mongoose from 'mongoose';
 import sanitize from 'sanitize-filename';
 
@@ -14,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:export');
 const router = express.Router();
 
@@ -138,7 +136,9 @@ module.exports = (crowi) => {
     socketIoService.getAdminSocket().emit('admin:onProgressForExport', data);
   });
   adminEvent.on('onStartZippingForExport', (data) => {
-    socketIoService.getAdminSocket().emit('admin:onStartZippingForExport', data);
+    socketIoService
+      .getAdminSocket()
+      .emit('admin:onStartZippingForExport', data);
   });
   adminEvent.on('onTerminateForExport', (data) => {
     socketIoService.getAdminSocket().emit('admin:onTerminateForExport', data);
@@ -155,11 +155,15 @@ module.exports = (crowi) => {
         .withMessage('"collections" array cannot be empty')
         .bail()
 
-        .custom(async(value) => {
+        .custom(async (value) => {
           // Check if all the collections in the request body exist in the database
-          const listCollectionsResult = await mongoose.connection.db.listCollections().toArray();
-          const allCollectionNames = listCollectionsResult.map(collectionObj => collectionObj.name);
-          if (!value.every(v => allCollectionNames.includes(v))) {
+          const listCollectionsResult = await mongoose.connection.db
+            .listCollections()
+            .toArray();
+          const allCollectionNames = listCollectionsResult.map(
+            (collectionObj) => collectionObj.name,
+          );
+          if (!value.every((v) => allCollectionNames.includes(v))) {
             throw new Error('Invalid collections');
           }
         }),
@@ -167,11 +171,12 @@ module.exports = (crowi) => {
     deleteFile: [
       // https://regex101.com/r/mD4eZs/6
       // prevent from unexpecting attack doing delete file (path traversal attack)
-      param('fileName').not().matches(/(\.\.\/|\.\.\\)/),
+      param('fileName')
+        .not()
+        .matches(/(\.\.\/|\.\.\\)/),
     ],
   };
 
-
   /**
    * @swagger
    *
@@ -193,15 +198,21 @@ module.exports = (crowi) => {
    *                  status:
    *                    $ref: '#/components/schemas/ExportStatus'
    */
-  router.get('/status', accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
-    const status = await exportService.getStatus();
+  router.get(
+    '/status',
+    accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    async (req, res) => {
+      const status = await exportService.getStatus();
 
-    // TODO: use res.apiv3
-    return res.json({
-      ok: true,
-      status,
-    });
-  });
+      // TODO: use res.apiv3
+      return res.json({
+        ok: true,
+        status,
+      });
+    },
+  );
 
   /**
    * @swagger
@@ -233,28 +244,37 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    description: whether the request is succeeded
    */
-  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired,
-    validator.generateZipFile, apiV3FormValidator, addActivity, async(req, res) => {
-    // TODO: add express validator
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    validator.generateZipFile,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
+      // TODO: add express validator
       try {
         const { collections } = req.body;
 
         exportService.export(collections);
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_CREATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_CREATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         // TODO: use res.apiv3
         return res.status(200).json({
           ok: true,
         });
-      }
-      catch (err) {
-      // TODO: use ApiV3Error
+      } catch (err) {
+        // TODO: use ApiV3Error
         logger.error(err);
         return res.status(500).send({ status: 'ERROR' });
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -283,28 +303,36 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    description: whether the request is succeeded
    */
-  router.delete('/:fileName', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired,
-    validator.deleteFile, apiV3FormValidator, addActivity,
-    async(req, res) => {
-    // TODO: add express validator
+  router.delete(
+    '/:fileName',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    validator.deleteFile,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
+      // TODO: add express validator
       const { fileName } = req.params;
 
       try {
         const sanitizedFileName = sanitize(fileName);
         const zipFile = exportService.getFile(sanitizedFileName);
         fs.unlinkSync(zipFile);
-        const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_DELETE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_DELETE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         // TODO: use res.apiv3
         return res.status(200).send({ ok: true });
-      }
-      catch (err) {
-      // TODO: use ApiV3Error
+      } catch (err) {
+        // TODO: use ApiV3Error
         logger.error(err);
         return res.status(500).send({ ok: false });
       }
-    });
+    },
+  );
 
   return router;
 };

+ 110 - 62
apps/app/src/server/routes/apiv3/forgot-password.js

@@ -37,7 +37,7 @@ const { body } = require('express-validator');
  *           type: string
  *         error:
  *           type: string
-*/
+ */
 
 const router = express.Router();
 
@@ -55,13 +55,21 @@ module.exports = (crowi) => {
 
   const validator = {
     password: [
-      body('newPassword').isString().not().isEmpty()
+      body('newPassword')
+        .isString()
+        .not()
+        .isEmpty()
         .isLength({ min: minPasswordLength })
-        .withMessage(`password must be at least ${minPasswordLength} characters long`),
+        .withMessage(
+          `password must be at least ${minPasswordLength} characters long`,
+        ),
       // checking if password confirmation matches password
-      body('newPasswordConfirm').isString().not().isEmpty()
+      body('newPasswordConfirm')
+        .isString()
+        .not()
+        .isEmpty()
         .custom((value, { req }) => {
-          return (value === req.body.newPassword);
+          return value === req.body.newPassword;
         }),
     ],
     email: [
@@ -74,13 +82,23 @@ module.exports = (crowi) => {
     ],
   };
 
-  const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
+  const checkPassportStrategyMiddleware =
+    checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
 
-  async function sendPasswordResetEmail(templateFileName, locale, email, url, expiredAt) {
+  async function sendPasswordResetEmail(
+    templateFileName,
+    locale,
+    email,
+    url,
+    expiredAt,
+  ) {
     return mailService.send({
       to: email,
       subject: '[GROWI] Password Reset',
-      template: path.join(crowi.localeDir, `${locale}/notifications/${templateFileName}.ejs`),
+      template: path.join(
+        crowi.localeDir,
+        `${locale}/notifications/${templateFileName}.ejs`,
+      ),
       vars: {
         appTitle: appService.getAppTitle(),
         email,
@@ -118,39 +136,60 @@ module.exports = (crowi) => {
    *              schema:
    *                type: object
    */
-  router.post('/', checkPassportStrategyMiddleware, validator.email, apiV3FormValidator, addActivity, async(req, res) => {
-    const { email } = req.body;
-    const locale = configManager.getConfig('app:globalLang');
-    const appUrl = growiInfoService.getSiteUrl();
+  router.post(
+    '/',
+    checkPassportStrategyMiddleware,
+    validator.email,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
+      const { email } = req.body;
+      const locale = configManager.getConfig('app:globalLang');
+      const appUrl = growiInfoService.getSiteUrl();
 
-    try {
-      const user = await User.findOne({ email });
+      try {
+        const user = await User.findOne({ email });
 
-      // when the user is not found or active
-      if (user == null || user.status !== 2) {
-        // Do not send emails to non GROWI user
-        // For security reason, do not use error messages like "Email does not exist"
-        return res.apiv3();
-      }
+        // when the user is not found or active
+        if (user == null || user.status !== 2) {
+          // Do not send emails to non GROWI user
+          // For security reason, do not use error messages like "Email does not exist"
+          return res.apiv3();
+        }
 
-      const passwordResetOrderData = await PasswordResetOrder.createPasswordResetOrder(email);
-      const url = new URL(`/forgot-password/${passwordResetOrderData.token}`, appUrl);
-      const oneTimeUrl = url.href;
-      const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
-      const expiredAt = subSeconds(passwordResetOrderData.expiredAt, grwTzoffsetSec);
-      const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
-      await sendPasswordResetEmail('passwordReset', locale, email, oneTimeUrl, formattedExpiredAt);
+        const passwordResetOrderData =
+          await PasswordResetOrder.createPasswordResetOrder(email);
+        const url = new URL(
+          `/forgot-password/${passwordResetOrderData.token}`,
+          appUrl,
+        );
+        const oneTimeUrl = url.href;
+        const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
+        const expiredAt = subSeconds(
+          passwordResetOrderData.expiredAt,
+          grwTzoffsetSec,
+        );
+        const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
+        await sendPasswordResetEmail(
+          'passwordReset',
+          locale,
+          email,
+          oneTimeUrl,
+          formattedExpiredAt,
+        );
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_FOGOT_PASSWORD });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_USER_FOGOT_PASSWORD,
+        });
 
-      return res.apiv3();
-    }
-    catch (err) {
-      const msg = 'Error occurred during password reset request procedure.';
-      logger.error(err);
-      return res.apiv3Err(`${msg} Cause: ${err}`);
-    }
-  });
+        return res.apiv3();
+      } catch (err) {
+        const msg = 'Error occurred during password reset request procedure.';
+        logger.error(err);
+        return res.apiv3Err(`${msg} Cause: ${err}`);
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -184,35 +223,44 @@ module.exports = (crowi) => {
    *                    $ref: '#/components/schemas/User'
    */
   // eslint-disable-next-line max-len
-  router.put('/', checkPassportStrategyMiddleware, injectResetOrderByTokenMiddleware, validator.password, apiV3FormValidator, addActivity, async(req, res) => {
-    const { passwordResetOrder } = req;
-    const { email } = passwordResetOrder;
-    const grobalLang = configManager.getConfig('app:globalLang');
-    const i18n = grobalLang || req.language;
-    const { newPassword } = req.body;
-
-    const user = await User.findOne({ email });
-
-    // when the user is not found or active
-    if (user == null || user.status !== 2) {
-      return res.apiv3Err('update-password-failed');
-    }
+  router.put(
+    '/',
+    checkPassportStrategyMiddleware,
+    injectResetOrderByTokenMiddleware,
+    validator.password,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
+      const { passwordResetOrder } = req;
+      const { email } = passwordResetOrder;
+      const grobalLang = configManager.getConfig('app:globalLang');
+      const i18n = grobalLang || req.language;
+      const { newPassword } = req.body;
 
-    try {
-      const userData = await user.updatePassword(newPassword);
-      const serializedUserData = serializeUserSecurely(userData);
-      passwordResetOrder.revokeOneTimeToken();
-      await sendPasswordResetEmail('passwordResetSuccessful', i18n, email);
+      const user = await User.findOne({ email });
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_RESET_PASSWORD });
+      // when the user is not found or active
+      if (user == null || user.status !== 2) {
+        return res.apiv3Err('update-password-failed');
+      }
 
-      return res.apiv3({ userData: serializedUserData });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-password-failed');
-    }
-  });
+      try {
+        const userData = await user.updatePassword(newPassword);
+        const serializedUserData = serializeUserSecurely(userData);
+        passwordResetOrder.revokeOneTimeToken();
+        await sendPasswordResetEmail('passwordResetSuccessful', i18n, email);
+
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_USER_RESET_PASSWORD,
+        });
+
+        return res.apiv3({ userData: serializedUserData });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-password-failed');
+      }
+    },
+  );
 
   // middleware to handle error
   router.use(httpErrorHandler);

+ 79 - 24
apps/app/src/server/routes/apiv3/index.js

@@ -7,7 +7,6 @@ import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inject-user-registration-order-by-token-middleware';
 import * as loginFormValidator from '../../middlewares/login-form-validator';
 import * as registerFormValidator from '../../middlewares/register-form-validator';
-
 import g2gTransfer from './g2g-transfer';
 import importRoute from './import';
 import pageListing from './page-listing';
@@ -26,7 +25,9 @@ const routerForAuth = express.Router();
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi, app) => {
   const isInstalled = crowi.configManager.getConfig('app:installed');
-  const minPasswordLength = crowi.configManager.getConfig('app:minPasswordLength');
+  const minPasswordLength = crowi.configManager.getConfig(
+    'app:minPasswordLength',
+  );
 
   // add custom functions to express response
   require('./response')(express, crowi);
@@ -37,45 +38,85 @@ module.exports = (crowi, app) => {
   routerForAdmin.use('/admin-home', require('./admin-home')(crowi));
   routerForAdmin.use('/markdown-setting', require('./markdown-setting')(crowi));
   routerForAdmin.use('/app-settings', require('./app-settings')(crowi));
-  routerForAdmin.use('/customize-setting', require('./customize-setting')(crowi));
-  routerForAdmin.use('/notification-setting', require('./notification-setting')(crowi));
+  routerForAdmin.use(
+    '/customize-setting',
+    require('./customize-setting')(crowi),
+  );
+  routerForAdmin.use(
+    '/notification-setting',
+    require('./notification-setting')(crowi),
+  );
   routerForAdmin.use('/users', require('./users')(crowi));
   routerForAdmin.use('/user-groups', require('./user-group')(crowi));
-  routerForAdmin.use('/external-user-groups', require('~/features/external-user-group/server/routes/apiv3/external-user-group')(crowi));
+  routerForAdmin.use(
+    '/external-user-groups',
+    require('~/features/external-user-group/server/routes/apiv3/external-user-group')(
+      crowi,
+    ),
+  );
   routerForAdmin.use('/export', require('./export')(crowi));
   routerForAdmin.use('/import', importRoute(crowi));
   routerForAdmin.use('/search', require('./search')(crowi));
   routerForAdmin.use('/security-setting', securitySettings(crowi));
   routerForAdmin.use('/mongo', require('./mongo')(crowi));
-  routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
-  routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
+  routerForAdmin.use(
+    '/slack-integration-settings',
+    require('./slack-integration-settings')(crowi),
+  );
+  routerForAdmin.use(
+    '/slack-integration-legacy-settings',
+    require('./slack-integration-legacy-settings')(crowi),
+  );
   routerForAdmin.use('/activity', require('./activity')(crowi));
   routerForAdmin.use('/g2g-transfer', g2gTransfer(crowi));
   routerForAdmin.use('/plugins', growiPlugin(crowi));
 
   // auth
-  const applicationInstalled = require('../../middlewares/application-installed')(crowi);
+  const applicationInstalled =
+    require('../../middlewares/application-installed')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
   const login = require('../login')(crowi, app);
   const loginPassport = require('../login-passport')(crowi, app);
 
-  routerForAuth.post('/login', applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation,
-    addActivity, loginPassport.injectRedirectTo, loginPassport.isEnableLoginWithLocalOrLdap, loginPassport.loginWithLocal, loginPassport.loginWithLdap,
-    loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
+  routerForAuth.post(
+    '/login',
+    applicationInstalled,
+    loginFormValidator.loginRules(),
+    loginFormValidator.loginValidation,
+    addActivity,
+    loginPassport.injectRedirectTo,
+    loginPassport.isEnableLoginWithLocalOrLdap,
+    loginPassport.loginWithLocal,
+    loginPassport.loginWithLdap,
+    loginPassport.cannotLoginErrorHadnler,
+    loginPassport.loginFailure,
+  );
 
   routerForAuth.use('/invited', require('./invited')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));
 
-  routerForAuth.post('/register',
-    applicationInstalled, registerFormValidator.registerRules(minPasswordLength), registerFormValidator.registerValidation, addActivity, login.register);
+  routerForAuth.post(
+    '/register',
+    applicationInstalled,
+    registerFormValidator.registerRules(minPasswordLength),
+    registerFormValidator.registerValidation,
+    addActivity,
+    login.register,
+  );
 
-  routerForAuth.post('/user-activation/register', applicationInstalled, userActivation.registerRules(minPasswordLength),
-    userActivation.validateRegisterForm, userActivation.registerAction(crowi));
+  routerForAuth.post(
+    '/user-activation/register',
+    applicationInstalled,
+    userActivation.registerRules(minPasswordLength),
+    userActivation.validateRegisterForm,
+    userActivation.registerAction(crowi),
+  );
 
   // installer
-  routerForAdmin.use('/installer', isInstalled
-    ? allreadyInstalledMiddleware
-    : require('./installer')(crowi));
+  routerForAdmin.use(
+    '/installer',
+    isInstalled ? allreadyInstalledMiddleware : require('./installer')(crowi),
+  );
 
   if (!isInstalled) {
     return [router, routerForAdmin, routerForAuth];
@@ -87,11 +128,15 @@ module.exports = (crowi, app) => {
   router.use('/user-activities', require('./user-activities')(crowi));
 
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
-  router.use('/external-user-group-relations', require('~/features/external-user-group/server/routes/apiv3/external-user-group-relation')(crowi));
+  router.use(
+    '/external-user-group-relations',
+    require('~/features/external-user-group/server/routes/apiv3/external-user-group-relation')(
+      crowi,
+    ),
+  );
 
   router.use('/statistics', require('./statistics')(crowi));
 
-
   router.use('/search', require('./search')(crowi));
 
   router.use('/page', require('./page')(crowi));
@@ -114,18 +159,28 @@ module.exports = (crowi, app) => {
   const user = require('../user')(crowi, null);
   router.get('/check-username', user.api.checkUsername);
 
-  router.post('/complete-registration',
+  router.post(
+    '/complete-registration',
     addActivity,
     injectUserRegistrationOrderByTokenMiddleware,
     userActivation.completeRegistrationRules(),
     userActivation.validateCompleteRegistration,
-    userActivation.completeRegistrationAction(crowi));
+    userActivation.completeRegistrationAction(crowi),
+  );
 
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
   router.use('/bookmark-folder', require('./bookmark-folder')(crowi));
-  router.use('/templates', require('~/features/templates/server/routes/apiv3')(crowi));
-  router.use('/page-bulk-export', require('~/features/page-bulk-export/server/routes/apiv3/page-bulk-export')(crowi));
+  router.use(
+    '/templates',
+    require('~/features/templates/server/routes/apiv3')(crowi),
+  );
+  router.use(
+    '/page-bulk-export',
+    require('~/features/page-bulk-export/server/routes/apiv3/page-bulk-export')(
+      crowi,
+    ),
+  );
 
   router.use('/openai', openaiRouteFactory(crowi));
 

+ 1 - 2
apps/app/src/server/routes/apiv3/logout.js

@@ -2,7 +2,6 @@ import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:routes:apiv3:logout'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -29,7 +28,7 @@ module.exports = (crowi) => {
    *        500:
    *          description: Internal server error
    */
-  router.post('/', addActivity, async(req, res) => {
+  router.post('/', addActivity, async (req, res) => {
     req.session.destroy();
 
     const activityId = res.locals.activity._id;

+ 110 - 49
apps/app/src/server/routes/apiv3/markdown-setting.js

@@ -33,7 +33,6 @@ const validator = {
   ],
 };
 
-
 /**
  * @swagger
  *
@@ -123,7 +122,9 @@ 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(crowi);
 
@@ -150,20 +151,42 @@ module.exports = (crowi) => {
    *                      description: markdown params
    *                      $ref: '#/components/schemas/MarkdownParams'
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.MARKDOWN]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const markdownParams = {
-      isEnabledLinebreaks: await crowi.configManager.getConfig('markdown:isEnabledLinebreaks'),
-      isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
-      adminPreferredIndentSize: await crowi.configManager.getConfig('markdown:adminPreferredIndentSize'),
-      isIndentSizeForced: await crowi.configManager.getConfig('markdown:isIndentSizeForced'),
-      isEnabledXss: await crowi.configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
-      xssOption: await crowi.configManager.getConfig('markdown:rehypeSanitize:option'),
-      tagWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:tagNames'),
-      attrWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:attributes'),
-    };
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.MARKDOWN]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const markdownParams = {
+        isEnabledLinebreaks: await crowi.configManager.getConfig(
+          'markdown:isEnabledLinebreaks',
+        ),
+        isEnabledLinebreaksInComments: await crowi.configManager.getConfig(
+          'markdown:isEnabledLinebreaksInComments',
+        ),
+        adminPreferredIndentSize: await crowi.configManager.getConfig(
+          'markdown:adminPreferredIndentSize',
+        ),
+        isIndentSizeForced: await crowi.configManager.getConfig(
+          'markdown:isIndentSizeForced',
+        ),
+        isEnabledXss: await crowi.configManager.getConfig(
+          'markdown:rehypeSanitize:isEnabledPrevention',
+        ),
+        xssOption: await crowi.configManager.getConfig(
+          'markdown:rehypeSanitize:option',
+        ),
+        tagWhitelist: await crowi.configManager.getConfig(
+          'markdown:rehypeSanitize:tagNames',
+        ),
+        attrWhitelist: await crowi.configManager.getConfig(
+          'markdown:rehypeSanitize:attributes',
+        ),
+      };
 
-    return res.apiv3({ markdownParams });
-  });
+      return res.apiv3({ markdownParams });
+    },
+  );
 
   /**
    * @swagger
@@ -192,33 +215,45 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/LineBreakParams'
    */
-  router.put('/lineBreak', accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
-    loginRequiredStrictly, adminRequired, addActivity, validator.lineBreak, apiV3FormValidator, async(req, res) => {
-
+  router.put(
+    '/lineBreak',
+    accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.lineBreak,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestLineBreakParams = {
         'markdown:isEnabledLinebreaks': req.body.isEnabledLinebreaks,
-        'markdown:isEnabledLinebreaksInComments': req.body.isEnabledLinebreaksInComments,
+        'markdown:isEnabledLinebreaksInComments':
+          req.body.isEnabledLinebreaksInComments,
       };
 
       try {
         await configManager.updateConfigs(requestLineBreakParams);
         const lineBreaksParams = {
-          isEnabledLinebreaks: await crowi.configManager.getConfig('markdown:isEnabledLinebreaks'),
-          isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
+          isEnabledLinebreaks: await crowi.configManager.getConfig(
+            'markdown:isEnabledLinebreaks',
+          ),
+          isEnabledLinebreaksInComments: await crowi.configManager.getConfig(
+            'markdown:isEnabledLinebreaksInComments',
+          ),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ lineBreaksParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating lineBreak';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-lineBreak-failed'));
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -248,9 +283,15 @@ module.exports = (crowi) => {
    *                      description: indent params
    *                      $ref: '#/components/schemas/IndentParams'
    */
-  router.put('/indent', accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
-    loginRequiredStrictly, adminRequired, addActivity, validator.indent, apiV3FormValidator, async(req, res) => {
-
+  router.put(
+    '/indent',
+    accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.indent,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestIndentParams = {
         'markdown:adminPreferredIndentSize': req.body.adminPreferredIndentSize,
         'markdown:isIndentSizeForced': req.body.isIndentSizeForced,
@@ -259,22 +300,27 @@ module.exports = (crowi) => {
       try {
         await configManager.updateConfigs(requestIndentParams);
         const indentParams = {
-          adminPreferredIndentSize: await crowi.configManager.getConfig('markdown:adminPreferredIndentSize'),
-          isIndentSizeForced: await crowi.configManager.getConfig('markdown:isIndentSizeForced'),
+          adminPreferredIndentSize: await crowi.configManager.getConfig(
+            'markdown:adminPreferredIndentSize',
+          ),
+          isIndentSizeForced: await crowi.configManager.getConfig(
+            'markdown:isIndentSizeForced',
+          ),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_INDENT_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_MARKDOWN_INDENT_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ indentParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating indent';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-indent-failed'));
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -300,16 +346,22 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/XssParams'
    */
-  router.put('/xss', accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
-    loginRequiredStrictly, adminRequired, addActivity, validator.xssSetting, apiV3FormValidator, async(req, res) => {
+  router.put(
+    '/xss',
+    accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.xssSetting,
+    apiV3FormValidator,
+    async (req, res) => {
       if (req.body.isEnabledXss && req.body.xssOption == null) {
         return res.apiv3Err(new ErrorV3('xss option is required'));
       }
 
       try {
         JSON.parse(req.body.attrWhitelist);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating xss';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
@@ -325,24 +377,33 @@ module.exports = (crowi) => {
       try {
         await configManager.updateConfigs(reqestXssParams);
         const xssParams = {
-          isEnabledXss: await crowi.configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
-          xssOption: await crowi.configManager.getConfig('markdown:rehypeSanitize:option'),
-          tagWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:tagNames'),
-          attrWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:attributes'),
+          isEnabledXss: await crowi.configManager.getConfig(
+            'markdown:rehypeSanitize:isEnabledPrevention',
+          ),
+          xssOption: await crowi.configManager.getConfig(
+            'markdown:rehypeSanitize:option',
+          ),
+          tagWhitelist: await crowi.configManager.getConfig(
+            'markdown:rehypeSanitize:tagNames',
+          ),
+          attrWhitelist: await crowi.configManager.getConfig(
+            'markdown:rehypeSanitize:attributes',
+          ),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ xssParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating xss';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
       }
-
-    });
+    },
+  );
 
   return router;
 };

+ 23 - 11
apps/app/src/server/routes/apiv3/mongo.js

@@ -12,7 +12,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);
 
   /**
@@ -38,16 +40,26 @@ module.exports = (crowi) => {
    *                    items:
    *                      type: string
    */
-  router.get('/collections', accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const listCollectionsResult = await mongoose.connection.db.listCollections().toArray();
-    const collections = listCollectionsResult.map(collectionObj => collectionObj.name);
-
-    // TODO: use res.apiv3
-    return res.json({
-      ok: true,
-      collections,
-    });
-  });
+  router.get(
+    '/collections',
+    accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const listCollectionsResult = await mongoose.connection.db
+        .listCollections()
+        .toArray();
+      const collections = listCollectionsResult.map(
+        (collectionObj) => collectionObj.name,
+      );
+
+      // TODO: use res.apiv3
+      return res.json({
+        ok: true,
+        collections,
+      });
+    },
+  );
 
   return router;
 };

+ 243 - 176
apps/app/src/server/routes/apiv3/notification-setting.js

@@ -1,8 +1,8 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { GlobalNotificationSettingType } from '~/server/models/GlobalNotificationSetting';
 import { configManager } from '~/server/service/config-manager';
@@ -13,11 +13,9 @@ import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import UpdatePost from '../../models/update-post';
 
-
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
 
-
 const router = express.Router();
 
 const { body } = require('express-validator');
@@ -28,19 +26,28 @@ const validator = {
     body('channel').isString().trim(),
   ],
   globalNotification: [
-    body('triggerPath').isString().trim().not()
-      .isEmpty(),
+    body('triggerPath').isString().trim().not().isEmpty(),
     body('notifyType').isString().trim().isIn(['mail', 'slack']),
-    body('toEmail').trim().custom((value, { req }) => {
-      return (req.body.notifyType === 'mail') ? (!!value && value.match(/.+@.+\..+/)) : true;
-    }),
-    body('slackChannels').trim().custom((value, { req }) => {
-      return (req.body.notifyType === 'slack') ? !!value : true;
-    }),
+    body('toEmail')
+      .trim()
+      .custom((value, { req }) => {
+        return req.body.notifyType === 'mail'
+          ? !!value && value.match(/.+@.+\..+/)
+          : true;
+      }),
+    body('slackChannels')
+      .trim()
+      .custom((value, { req }) => {
+        return req.body.notifyType === 'slack' ? !!value : true;
+      }),
   ],
   notifyForPageGrant: [
-    body('isNotificationForOwnerPageEnabled').if(value => value != null).isBoolean(),
-    body('isNotificationForGroupPageEnabled').if(value => value != null).isBoolean(),
+    body('isNotificationForOwnerPageEnabled')
+      .if((value) => value != null)
+      .isBoolean(),
+    body('isNotificationForGroupPageEnabled')
+      .if((value) => value != null)
+      .isBoolean(),
   ],
 };
 
@@ -184,8 +191,10 @@ module.exports = (crowi) => {
 
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
-  const GlobalNotificationMailSetting = crowi.models.GlobalNotificationMailSetting;
-  const GlobalNotificationSlackSetting = crowi.models.GlobalNotificationSlackSetting;
+  const GlobalNotificationMailSetting =
+    crowi.models.GlobalNotificationMailSetting;
+  const GlobalNotificationSlackSetting =
+    crowi.models.GlobalNotificationSlackSetting;
 
   /**
    * @swagger
@@ -208,80 +217,106 @@ module.exports = (crowi) => {
    *                      description: notification params
    *                      $ref: '#/components/schemas/NotificationParams'
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.EXTERNAL_NOTIFICATION]), Strictly, adminRequired, async(req, res) => {
-
-    const notificationParams = {
-      // status of slack intagration
-      isSlackbotConfigured: crowi.slackIntegrationService.isSlackbotConfigured,
-      isSlackLegacyConfigured: crowi.slackIntegrationService.isSlackLegacyConfigured,
-      currentBotType: await crowi.configManager.getConfig('slackbot:currentBotType'),
-
-      userNotifications: await UpdatePost.findAll(),
-      isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification:owner-page:isEnabled'),
-      isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification:group-page:isEnabled'),
-      globalNotifications: await GlobalNotificationSetting.findAll(),
-    };
-    return res.apiv3({ notificationParams });
-  });
-
-  /**
-  * @swagger
-  *
-  *    /notification-setting/user-notification:
-  *      post:
-  *        tags: [NotificationSetting]
-  *        security:
-  *         - cookieAuth: []
-  *        description: add user notification setting
-  *        requestBody:
-  *          required: true
-  *          content:
-  *            application/json:
-  *              schema:
-  *                $ref: '#/components/schemas/UserNotificationParams'
-  *        responses:
-  *          200:
-  *            description: Succeeded to add user notification setting
-  *            content:
-  *              application/json:
-  *                schema:
-  *                  properties:
-  *                    responseParams:
-  *                      type: object
-  *                      description: response params
-  *                      properties:
-  *                        createdUser:
-  *                          $ref: '#/components/schemas/User'
-  *                          description: user who set notification
-  *                        userNotifications:
-  *                          type: array
-  *                          items:
-  *                            $ref: '#/components/schemas/UserNotification'
-  *                            description: user notification settings
-  */
-  // eslint-disable-next-line max-len
-  router.post('/user-notification', accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]), Strictly, adminRequired, addActivity, validator.userNotification, apiV3FormValidator, async(req, res) => {
-    const { pathPattern, channel } = req.body;
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.EXTERNAL_NOTIFICATION]),
+    Strictly,
+    adminRequired,
+    async (req, res) => {
+      const notificationParams = {
+        // status of slack intagration
+        isSlackbotConfigured:
+          crowi.slackIntegrationService.isSlackbotConfigured,
+        isSlackLegacyConfigured:
+          crowi.slackIntegrationService.isSlackLegacyConfigured,
+        currentBotType: await crowi.configManager.getConfig(
+          'slackbot:currentBotType',
+        ),
 
-    try {
-      logger.info('notification.add', pathPattern, channel);
-      const responseParams = {
-        createdUser: await UpdatePost.createUpdatePost(pathPattern, channel, req.user),
         userNotifications: await UpdatePost.findAll(),
+        isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig(
+          'notification:owner-page:isEnabled',
+        ),
+        isNotificationForGroupPageEnabled: await crowi.configManager.getConfig(
+          'notification:group-page:isEnabled',
+        ),
+        globalNotifications: await GlobalNotificationSetting.findAll(),
       };
+      return res.apiv3({ notificationParams });
+    },
+  );
+
+  /**
+   * @swagger
+   *
+   *    /notification-setting/user-notification:
+   *      post:
+   *        tags: [NotificationSetting]
+   *        security:
+   *         - cookieAuth: []
+   *        description: add user notification setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/UserNotificationParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to add user notification setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    responseParams:
+   *                      type: object
+   *                      description: response params
+   *                      properties:
+   *                        createdUser:
+   *                          $ref: '#/components/schemas/User'
+   *                          description: user who set notification
+   *                        userNotifications:
+   *                          type: array
+   *                          items:
+   *                            $ref: '#/components/schemas/UserNotification'
+   *                            description: user notification settings
+   */
+  // eslint-disable-next-line max-len
+  router.post(
+    '/user-notification',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
+    Strictly,
+    adminRequired,
+    addActivity,
+    validator.userNotification,
+    apiV3FormValidator,
+    async (req, res) => {
+      const { pathPattern, channel } = req.body;
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      try {
+        logger.info('notification.add', pathPattern, channel);
+        const responseParams = {
+          createdUser: await UpdatePost.createUpdatePost(
+            pathPattern,
+            channel,
+            req.user,
+          ),
+          userNotifications: await UpdatePost.findAll(),
+        };
 
-      return res.apiv3({ responseParams }, 201);
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating user notification';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-userNotification-failed'));
-    }
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-  });
+        return res.apiv3({ responseParams }, 201);
+      } catch (err) {
+        const msg = 'Error occurred in updating user notification';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-userNotification-failed'));
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -307,29 +342,36 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/UserNotification'
    */
-  router.delete('/user-notification/:id',
+  router.delete(
+    '/user-notification/:id',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     adminRequired,
     addActivity,
-    async(req, res) => {
+    async (req, res) => {
       const { id } = req.params;
 
       try {
-        const deletedNotificaton = await UpdatePost.findOneAndRemove({ _id: id });
+        const deletedNotificaton = await UpdatePost.findOneAndRemove({
+          _id: id,
+        });
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE };
+        const parameters = {
+          action:
+            SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3(deletedNotificaton);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in delete user trigger notification';
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'delete-userTriggerNotification-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'delete-userTriggerNotification-failed'),
+        );
       }
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -357,27 +399,31 @@ module.exports = (crowi) => {
    *                    globalNotification:
    *                      $ref: '#/components/schemas/GlobalNotification'
    */
-  router.get('/global-notification/:id',
+  router.get(
+    '/global-notification/:id',
     accessTokenParser([SCOPE.READ.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     adminRequired,
     validator.globalNotification,
-    async(req, res) => {
-
+    async (req, res) => {
       const notificationSettingId = req.params.id;
       let globalNotification;
 
       if (notificationSettingId) {
         try {
-          globalNotification = await GlobalNotificationSetting.findOne({ _id: notificationSettingId });
-        }
-        catch (err) {
-          logger.error(`Error in finding a global notification setting with {_id: ${notificationSettingId}}`);
+          globalNotification = await GlobalNotificationSetting.findOne({
+            _id: notificationSettingId,
+          });
+        } catch (err) {
+          logger.error(
+            `Error in finding a global notification setting with {_id: ${notificationSettingId}}`,
+          );
         }
       }
 
       return res.apiv3({ globalNotification });
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -407,17 +453,17 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/GlobalNotification'
    */
   // eslint-disable-next-line max-len
-  router.post('/global-notification',
+  router.post(
+    '/global-notification',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     adminRequired,
     addActivity,
     validator.globalNotification,
     apiV3FormValidator,
-    async(req, res) => {
-      const {
-        notifyType, toEmail, slackChannels, triggerPath, triggerEvents,
-      } = req.body;
+    async (req, res) => {
+      const { notifyType, toEmail, slackChannels, triggerPath, triggerEvents } =
+        req.body;
 
       let notification;
 
@@ -436,17 +482,19 @@ module.exports = (crowi) => {
       try {
         const createdNotification = await notification.save();
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ createdNotification }, 201);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating global notification';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'post-globalNotification-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -481,18 +529,18 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/GlobalNotification'
    */
   // eslint-disable-next-line max-len
-  router.put('/global-notification/:id',
+  router.put(
+    '/global-notification/:id',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     adminRequired,
     addActivity,
     validator.globalNotification,
     apiV3FormValidator,
-    async(req, res) => {
+    async (req, res) => {
       const { id } = req.params;
-      const {
-        notifyType, toEmail, slackChannels, triggerPath, triggerEvents,
-      } = req.body;
+      const { notifyType, toEmail, slackChannels, triggerPath, triggerEvents } =
+        req.body;
 
       const models = {
         [GlobalNotificationSettingType.MAIL]: GlobalNotificationMailSetting,
@@ -528,19 +576,20 @@ module.exports = (crowi) => {
 
         const createdNotification = await setting.save();
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE };
+        const parameters = {
+          action:
+            SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ createdNotification });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating global notification';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'post-globalNotification-failed'));
       }
-
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -566,18 +615,20 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/NotifyForPageGrant'
    */
   // eslint-disable-next-line max-len
-  router.put('/notify-for-page-grant',
+  router.put(
+    '/notify-for-page-grant',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     adminRequired,
     addActivity,
     validator.notifyForPageGrant,
     apiV3FormValidator,
-    async(req, res) => {
-
+    async (req, res) => {
       let requestParams = {
-        'notification:owner-page:isEnabled': req.body.isNotificationForOwnerPageEnabled,
-        'notification:group-page:isEnabled': req.body.isNotificationForGroupPageEnabled,
+        'notification:owner-page:isEnabled':
+          req.body.isNotificationForOwnerPageEnabled,
+        'notification:group-page:isEnabled':
+          req.body.isNotificationForGroupPageEnabled,
       };
 
       requestParams = removeNullPropertyFromObject(requestParams);
@@ -585,22 +636,32 @@ module.exports = (crowi) => {
       try {
         await configManager.updateConfigs(requestParams);
         const responseParams = {
-          isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification:owner-page:isEnabled'),
-          isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification:group-page:isEnabled'),
+          isNotificationForOwnerPageEnabled:
+            await crowi.configManager.getConfig(
+              'notification:owner-page:isEnabled',
+            ),
+          isNotificationForGroupPageEnabled:
+            await crowi.configManager.getConfig(
+              'notification:group-page:isEnabled',
+            ),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE };
+        const parameters = {
+          action:
+            SupportedAction.ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ responseParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating notify for page grant';
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-notify-for-page-grant-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-notify-for-page-grant-failed'),
+        );
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -638,20 +699,20 @@ module.exports = (crowi) => {
    *                      type: string
    *                      description: notification id
    */
-  router.put('/global-notification/:id/enabled',
+  router.put(
+    '/global-notification/:id/enabled',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     adminRequired,
     addActivity,
-    async(req, res) => {
+    async (req, res) => {
       const { id } = req.params;
       const { isEnabled } = req.body;
 
       try {
         if (isEnabled) {
           await GlobalNotificationSetting.enable(id);
-        }
-        else {
+        } else {
           await GlobalNotificationSetting.disable(id);
         }
 
@@ -663,64 +724,70 @@ module.exports = (crowi) => {
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ id });
-
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in toggle of global notification';
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'toggle-globalNotification-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'toggle-globalNotification-failed'),
+        );
       }
-
-    });
+    },
+  );
 
   /**
-  * @swagger
-  *
-  *    /notification-setting/global-notification/{id}:
-  *      delete:
-  *        tags: [NotificationSetting]
-  *        security:
-  *          - cookieAuth: []
-  *        description: delete global notification pattern
-  *        parameters:
-  *          - name: id
-  *            in: path
-  *            required: true
-  *            description: id of global notification
-  *            schema:
-  *              type: string
-  *        responses:
-  *          200:
-  *            description: Succeeded to delete global notification pattern
-  *            content:
-  *              application/json:
-  *                schema:
-  *                  description: deleted notification
-  *                  $ref: '#/components/schemas/GlobalNotification'
-  */
-  router.delete('/global-notification/:id',
+   * @swagger
+   *
+   *    /notification-setting/global-notification/{id}:
+   *      delete:
+   *        tags: [NotificationSetting]
+   *        security:
+   *          - cookieAuth: []
+   *        description: delete global notification pattern
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of global notification
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to delete global notification pattern
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  description: deleted notification
+   *                  $ref: '#/components/schemas/GlobalNotification'
+   */
+  router.delete(
+    '/global-notification/:id',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     adminRequired,
     addActivity,
-    async(req, res) => {
+    async (req, res) => {
       const { id } = req.params;
 
       try {
-        const deletedNotificaton = await GlobalNotificationSetting.findOneAndRemove({ _id: id });
+        const deletedNotificaton =
+          await GlobalNotificationSetting.findOneAndRemove({ _id: id });
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE };
+        const parameters = {
+          action:
+            SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3(deletedNotificaton);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in delete global notification';
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'delete-globalNotification-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'delete-globalNotification-failed'),
+        );
       }
-
-    });
+    },
+  );
 
   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);
       }

+ 4 - 3
apps/app/src/server/routes/apiv3/response.js

@@ -3,8 +3,8 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 const addCustomFunctionToResponse = (express) => {
-
-  express.response.apiv3 = function(obj = {}, status = 200) { // not arrow function
+  express.response.apiv3 = function (obj = {}, status = 200) {
+    // not arrow function
     // obj must be object
     if (typeof obj !== 'object' || obj instanceof Array) {
       throw new Error('invalid value supplied to res.apiv3');
@@ -13,7 +13,8 @@ const addCustomFunctionToResponse = (express) => {
     this.status(status).json(obj);
   };
 
-  express.response.apiv3Err = function(_err, status = 400, info) { // not arrow function
+  express.response.apiv3Err = function (_err, status = 400, info) {
+    // not arrow function
     if (!Number.isInteger(status)) {
       throw new Error('invalid status supplied to res.apiv3Err');
     }

+ 74 - 35
apps/app/src/server/routes/apiv3/revisions.js

@@ -1,9 +1,9 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import express from 'express';
 import { connection } from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Revision } from '~/server/models/revision';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
@@ -17,7 +17,8 @@ const { query, param } = require('express-validator');
 
 const router = express.Router();
 
-const MIGRATION_FILE_NAME = '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549';
+const MIGRATION_FILE_NAME =
+  '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549';
 
 /**
  * @swagger
@@ -56,20 +57,27 @@ const MIGRATION_FILE_NAME = '20211227060705-revision-path-to-page-id-schema-migr
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const certifySharedPage = require('../../middlewares/certify-shared-page')(
+    crowi,
+  );
+  const loginRequired = require('../../middlewares/login-required')(
+    crowi,
+    true,
+  );
 
-  const {
-    Page,
-    User,
-  } = crowi.models;
+  const { Page, User } = crowi.models;
 
   const validator = {
     retrieveRevisions: [
       query('pageId').isMongoId().withMessage('pageId is required'),
-      query('offset').if(value => value != null).isInt({ min: 0 }).withMessage('offset must be int'),
-      query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
-
+      query('offset')
+        .if((value) => value != null)
+        .isInt({ min: 0 })
+        .withMessage('offset must be int'),
+      query('limit')
+        .if((value) => value != null)
+        .isInt({ max: 100 })
+        .withMessage('You should set less than 100 or not to set limit.'),
     ],
     retrieveRevisionById: [
       query('pageId').isMongoId().withMessage('pageId is required'),
@@ -79,14 +87,15 @@ module.exports = (crowi) => {
 
   let cachedAppliedAt = null;
 
-  const getAppliedAtOfTheMigrationFile = async() => {
-
+  const getAppliedAtOfTheMigrationFile = async () => {
     if (cachedAppliedAt != null) {
       return cachedAppliedAt;
     }
 
     const migrationCollection = connection.collection('migrations');
-    const migration = await migrationCollection.findOne({ fileName: { $regex: `^${MIGRATION_FILE_NAME}` } });
+    const migration = await migrationCollection.findOne({
+      fileName: { $regex: `^${MIGRATION_FILE_NAME}` },
+    });
     const appliedAt = migration.appliedAt;
 
     cachedAppliedAt = appliedAt;
@@ -135,24 +144,42 @@ module.exports = (crowi) => {
    *                    type: number
    *                    description: offset of the revisions
    */
-  router.get('/list',
-    certifySharedPage, accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, validator.retrieveRevisions, apiV3FormValidator,
-    async(req, res) => {
+  router.get(
+    '/list',
+    certifySharedPage,
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    validator.retrieveRevisions,
+    apiV3FormValidator,
+    async (req, res) => {
       const pageId = req.query.pageId;
-      const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
+      const limit =
+        req.query.limit ||
+        (await crowi.configManager.getConfig(
+          'customize:showPageLimitationS',
+        )) ||
+        10;
       const { isSharedPage } = req;
       const offset = req.query.offset || 0;
 
       // check whether accessible
-      if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+      if (
+        !isSharedPage &&
+        !(await Page.isAccessiblePageByViewer(pageId, req.user))
+      ) {
+        return res.apiv3Err(
+          new ErrorV3(
+            'Current user is not accessible to this page.',
+            'forbidden-page',
+          ),
+          403,
+        );
       }
 
       // 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');
       }
 
@@ -197,14 +224,13 @@ module.exports = (crowi) => {
         };
 
         return res.apiv3(result);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in getting revisions by poge id';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revisions'), 500);
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -236,16 +262,30 @@ module.exports = (crowi) => {
    *                    revision:
    *                      $ref: '#/components/schemas/Revision'
    */
-  router.get('/:id',
-    certifySharedPage, accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, validator.retrieveRevisionById, apiV3FormValidator,
-    async(req, res) => {
+  router.get(
+    '/:id',
+    certifySharedPage,
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    validator.retrieveRevisionById,
+    apiV3FormValidator,
+    async (req, res) => {
       const revisionId = req.params.id;
       const pageId = req.query.pageId;
       const { isSharedPage } = req;
 
       // check whether accessible
-      if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+      if (
+        !isSharedPage &&
+        !(await Page.isAccessiblePageByViewer(pageId, req.user))
+      ) {
+        return res.apiv3Err(
+          new ErrorV3(
+            'Current user is not accessible to this page.',
+            'forbidden-page',
+          ),
+          403,
+        );
       }
 
       try {
@@ -256,14 +296,13 @@ module.exports = (crowi) => {
         }
 
         return res.apiv3({ revision });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in getting revision data by id';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revision'), 500);
       }
-
-    });
+    },
+  );
 
   return router;
 };

+ 80 - 27
apps/app/src/server/routes/apiv3/search.js

@@ -126,23 +126,36 @@ module.exports = (crowi) => {
    *                    description: Status of indices
    *                    $ref: '#/components/schemas/Indices'
    */
-  router.get('/indices',
-    noCache(), accessTokenParser([SCOPE.READ.ADMIN.FULL_TEXT_SEARCH], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
+  router.get(
+    '/indices',
+    noCache(),
+    accessTokenParser([SCOPE.READ.ADMIN.FULL_TEXT_SEARCH], {
+      acceptLegacy: true,
+    }),
+    loginRequired,
+    adminRequired,
+    async (req, res) => {
       const { searchService } = crowi;
 
       if (!searchService.isConfigured) {
-        return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'), 503);
+        return res.apiv3Err(
+          new ErrorV3(
+            'SearchService is not configured',
+            'search-service-unconfigured',
+          ),
+          503,
+        );
       }
 
       try {
         const info = await searchService.getInfoForAdmin();
         return res.status(200).send({ info });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err, 503);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -156,26 +169,40 @@ module.exports = (crowi) => {
    *        200:
    *          description: Successfully connected
    */
-  router.post('/connection',
-    accessTokenParser([SCOPE.WRITE.ADMIN.FULL_TEXT_SEARCH], { acceptLegacy: true }), loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post(
+    '/connection',
+    accessTokenParser([SCOPE.WRITE.ADMIN.FULL_TEXT_SEARCH], {
+      acceptLegacy: true,
+    }),
+    loginRequired,
+    adminRequired,
+    addActivity,
+    async (req, res) => {
       const { searchService } = crowi;
 
       if (!searchService.isConfigured) {
-        return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'SearchService is not configured',
+            'search-service-unconfigured',
+          ),
+        );
       }
 
       try {
         await searchService.reconnectClient();
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_CONNECTION });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_ADMIN_SEARCH_CONNECTION,
+        });
 
         return res.status(200).send();
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err, 503);
       }
-    });
+    },
+  );
 
   const validatorForPutIndices = [
     body('operation').isString().isIn(['rebuild', 'normalize']),
@@ -212,44 +239,70 @@ module.exports = (crowi) => {
    *                    type: string
    *                    description: Operation is successfully processed, or requested
    */
-  router.put('/indices', accessTokenParser([SCOPE.WRITE.ADMIN.FULL_TEXT_SEARCH], { acceptLegacy: true }), loginRequired, adminRequired, addActivity,
-    validatorForPutIndices, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/indices',
+    accessTokenParser([SCOPE.WRITE.ADMIN.FULL_TEXT_SEARCH], {
+      acceptLegacy: true,
+    }),
+    loginRequired,
+    adminRequired,
+    addActivity,
+    validatorForPutIndices,
+    apiV3FormValidator,
+    async (req, res) => {
       const operation = req.body.operation;
 
       const { searchService } = crowi;
 
       if (!searchService.isConfigured) {
-        return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'SearchService is not configured',
+            'search-service-unconfigured',
+          ),
+        );
       }
       if (!searchService.isReachable) {
-        return res.apiv3Err(new ErrorV3('SearchService is not reachable', 'search-service-unreachable'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'SearchService is not reachable',
+            'search-service-unreachable',
+          ),
+        );
       }
 
       try {
         switch (operation) {
           case 'normalize':
-          // wait the processing is terminated
+            // wait the processing is terminated
             await searchService.normalizeIndices();
 
-            activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_NORMALIZE });
+            activityEvent.emit('update', res.locals.activity._id, {
+              action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
+            });
 
-            return res.status(200).send({ message: 'Operation is successfully processed.' });
+            return res
+              .status(200)
+              .send({ message: 'Operation is successfully processed.' });
           case 'rebuild':
-          // NOT wait the processing is terminated
+            // NOT wait the processing is terminated
             searchService.rebuildIndex();
 
-            activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_REBUILD });
+            activityEvent.emit('update', res.locals.activity._id, {
+              action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_REBUILD,
+            });
 
-            return res.status(200).send({ message: 'Operation is successfully requested.' });
+            return res
+              .status(200)
+              .send({ message: 'Operation is successfully requested.' });
           default:
             throw new Error(`Unimplemented operation: ${operation}`);
         }
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err, 503);
       }
-    });
+    },
+  );
 
   return router;
 };

+ 167 - 119
apps/app/src/server/routes/apiv3/share-links.js

@@ -95,18 +95,24 @@ module.exports = (crowi) => {
    * middleware to limit link sharing
    */
   const linkSharingRequired = (req, res, next) => {
-    const isLinkSharingDisabled = crowi.configManager.getConfig('security:disableLinkSharing');
+    const isLinkSharingDisabled = crowi.configManager.getConfig(
+      'security:disableLinkSharing',
+    );
     logger.debug(`isLinkSharingDisabled: ${isLinkSharingDisabled}`);
 
     if (isLinkSharingDisabled) {
-      return res.apiv3Err(new ErrorV3('Link sharing is disabled', 'link-sharing-disabled'));
+      return res.apiv3Err(
+        new ErrorV3('Link sharing is disabled', 'link-sharing-disabled'),
+      );
     }
     next();
   };
 
   validator.getShareLinks = [
     // validate the page id is MongoId
-    query('relatedPage').isMongoId().withMessage('Page Id is required'),
+    query('relatedPage')
+      .isMongoId()
+      .withMessage('Page Id is required'),
   ];
 
   /**
@@ -138,13 +144,14 @@ module.exports = (crowi) => {
    *                      items:
    *                        $ref: '#/components/schemas/ShareLink'
    */
-  router.get('/',
+  router.get(
+    '/',
     accessTokenParser([SCOPE.READ.FEATURES.SHARE_LINK]),
     loginRequired,
     linkSharingRequired,
     validator.getShareLinks,
     apiV3FormValidator,
-    async(req, res) => {
+    async (req, res) => {
       const { relatedPage } = req.query;
 
       const page = await Page.findByIdAndViewer(relatedPage, req.user);
@@ -156,23 +163,32 @@ module.exports = (crowi) => {
       }
 
       try {
-        const shareLinksResult = await ShareLink.find({ relatedPage: { $eq: relatedPage } }).populate({ path: 'relatedPage', select: 'path' });
+        const shareLinksResult = await ShareLink.find({
+          relatedPage: { $eq: relatedPage },
+        }).populate({ path: 'relatedPage', select: 'path' });
         return res.apiv3({ shareLinksResult });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in get share link';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'get-shareLink-failed'));
       }
-    });
+    },
+  );
 
   validator.shareLinkStatus = [
     // validate the page id is MongoId
-    body('relatedPage').isMongoId().withMessage('Page Id is required'),
+    body('relatedPage')
+      .isMongoId()
+      .withMessage('Page Id is required'),
     // validate expireation date is not empty, is not before today and is date.
-    body('expiredAt').if(value => value != null).isAfter(today.toString()).withMessage('Your Selected date is past'),
+    body('expiredAt')
+      .if((value) => value != null)
+      .isAfter(today.toString())
+      .withMessage('Your Selected date is past'),
     // validate the length of description is max 100.
-    body('description').isLength({ min: 0, max: 100 }).withMessage('Max length is 100'),
+    body('description')
+      .isLength({ min: 0, max: 100 })
+      .withMessage('Max length is 100'),
   ];
 
   /**
@@ -209,7 +225,8 @@ module.exports = (crowi) => {
    *                schema:
    *                 $ref: '#/components/schemas/ShareLinkSimple'
    */
-  router.post('/',
+  router.post(
+    '/',
     accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
     loginRequired,
     excludeReadOnlyUser,
@@ -217,7 +234,7 @@ module.exports = (crowi) => {
     addActivity,
     validator.shareLinkStatus,
     apiV3FormValidator,
-    async(req, res) => {
+    async (req, res) => {
       const { relatedPage, expiredAt, description } = req.body;
 
       const page = await Page.findByIdAndViewer(relatedPage, req.user);
@@ -229,146 +246,173 @@ module.exports = (crowi) => {
       }
 
       try {
-        const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
+        const postedShareLink = await ShareLink.create({
+          relatedPage,
+          expiredAt,
+          description,
+        });
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_CREATE });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_SHARE_LINK_CREATE,
+        });
 
         return res.apiv3(postedShareLink, 201);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occured in post share link';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'post-shareLink-failed'));
       }
-    });
-
+    },
+  );
 
   validator.deleteShareLinks = [
     // validate the page id is MongoId
-    query('relatedPage').isMongoId().withMessage('Page Id is required'),
+    query('relatedPage')
+      .isMongoId()
+      .withMessage('Page Id is required'),
   ];
 
   /**
-  * @swagger
-  *
-  *    /share-links/:
-  *      delete:
-  *        tags: [ShareLinks]
-  *        security:
-  *          - cookieAuth: []
-  *        summary: delete all share links related one page
-  *        description: delete all share links related one page
-  *        parameters:
-  *          - name: relatedPage
-  *            in: query
-  *            required: true
-  *            description: page id of share link
-  *            schema:
-  *              type: string
-  *        responses:
-  *          200:
-  *            description: Succeeded to delete o all share links related one page
-  *            content:
-  *              application/json:
-  *                schema:
-  *                 $ref: '#/components/schemas/ShareLinkSimple'
-  */
-  router.delete('/',
+   * @swagger
+   *
+   *    /share-links/:
+   *      delete:
+   *        tags: [ShareLinks]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: delete all share links related one page
+   *        description: delete all share links related one page
+   *        parameters:
+   *          - name: relatedPage
+   *            in: query
+   *            required: true
+   *            description: page id of share link
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to delete o all share links related one page
+   *            content:
+   *              application/json:
+   *                schema:
+   *                 $ref: '#/components/schemas/ShareLinkSimple'
+   */
+  router.delete(
+    '/',
     accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
     loginRequired,
     excludeReadOnlyUser,
     addActivity,
     validator.deleteShareLinks,
     apiV3FormValidator,
-    async(req, res) => {
+    async (req, res) => {
       const { relatedPage } = req.query;
       const page = await Page.findByIdAndViewer(relatedPage, req.user);
 
       if (page == null) {
         const msg = 'Page is not found or forbidden';
         logger.error('Error', msg);
-        return res.apiv3Err(new ErrorV3(msg, 'delete-shareLinks-for-page-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'delete-shareLinks-for-page-failed'),
+        );
       }
 
       try {
-        const deletedShareLink = await ShareLink.deleteMany({ relatedPage: { $eq: relatedPage } });
+        const deletedShareLink = await ShareLink.deleteMany({
+          relatedPage: { $eq: relatedPage },
+        });
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE_BY_PAGE });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_SHARE_LINK_DELETE_BY_PAGE,
+        });
 
         return res.apiv3(deletedShareLink);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occured in delete share link';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
       }
-    });
+    },
+  );
 
   /**
-  * @swagger
-  *
-  *    /share-links/all:
-  *      delete:
-  *        tags: [ShareLink Management]
-  *        security:
-  *         - cookieAuth: []
-  *        summary: delete all share links
-  *        description: delete all share links
-  *        responses:
-  *          200:
-  *            description: Succeeded to remove all share links
-  *            content:
-  *              application/json:
-  *                schema:
-  *                  properties:
-  *                    deletedCount:
-  *                      type: integer
-  *                      description: The number of share links deleted
-  */
-  router.delete('/all', accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]), loginRequired, adminRequired, addActivity, async(req, res) => {
-
-    try {
-      const deletedShareLink = await ShareLink.deleteMany({});
-      const { deletedCount } = deletedShareLink;
-
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_ALL_DELETE });
-
-      return res.apiv3({ deletedCount });
-    }
-    catch (err) {
-      const msg = 'Error occurred in delete all share link';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'delete-all-shareLink-failed'));
-    }
-  });
+   * @swagger
+   *
+   *    /share-links/all:
+   *      delete:
+   *        tags: [ShareLink Management]
+   *        security:
+   *         - cookieAuth: []
+   *        summary: delete all share links
+   *        description: delete all share links
+   *        responses:
+   *          200:
+   *            description: Succeeded to remove all share links
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    deletedCount:
+   *                      type: integer
+   *                      description: The number of share links deleted
+   */
+  router.delete(
+    '/all',
+    accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
+    loginRequired,
+    adminRequired,
+    addActivity,
+    async (req, res) => {
+      try {
+        const deletedShareLink = await ShareLink.deleteMany({});
+        const { deletedCount } = deletedShareLink;
+
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_SHARE_LINK_ALL_DELETE,
+        });
+
+        return res.apiv3({ deletedCount });
+      } catch (err) {
+        const msg = 'Error occurred in delete all share link';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'delete-all-shareLink-failed'));
+      }
+    },
+  );
 
   validator.deleteShareLink = [
     param('id').isMongoId().withMessage('ShareLink Id is required'),
   ];
 
   /**
-  * @swagger
-  *
-  *    /share-links/{id}:
-  *      delete:
-  *        tags: [ShareLinks]
-  *        security:
-  *          - cookieAuth: []
-  *        description: delete one share link related one page
-  *        parameters:
-  *          - name: id
-  *            in: path
-  *            required: true
-  *            description: id of share link
-  *            schema:
-  *              type: string
-  *        responses:
-  *          200:
-  *            description: Succeeded to delete one share link
-  */
-  router.delete('/:id', accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]), loginRequired, excludeReadOnlyUser, addActivity,
-    validator.deleteShareLink, apiV3FormValidator,
-    async(req, res) => {
+   * @swagger
+   *
+   *    /share-links/{id}:
+   *      delete:
+   *        tags: [ShareLinks]
+   *        security:
+   *          - cookieAuth: []
+   *        description: delete one share link related one page
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of share link
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to delete one share link
+   */
+  router.delete(
+    '/:id',
+    accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
+    loginRequired,
+    excludeReadOnlyUser,
+    addActivity,
+    validator.deleteShareLink,
+    apiV3FormValidator,
+    async (req, res) => {
       const { id } = req.params;
       const { user } = req;
 
@@ -377,8 +421,12 @@ module.exports = (crowi) => {
 
         // check permission
         if (!user.isAdmin) {
-          const page = await Page.findByIdAndViewer(shareLinkToDelete.relatedPage, user);
-          const isPageExists = (await Page.count({ _id: shareLinkToDelete.relatedPage }) > 0);
+          const page = await Page.findByIdAndViewer(
+            shareLinkToDelete.relatedPage,
+            user,
+          );
+          const isPageExists =
+            (await Page.count({ _id: shareLinkToDelete.relatedPage })) > 0;
           if (page == null && isPageExists) {
             const msg = 'Page is not found or forbidden';
             logger.error('Error', msg);
@@ -389,18 +437,18 @@ module.exports = (crowi) => {
         // remove
         await shareLinkToDelete.remove();
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_SHARE_LINK_DELETE,
+        });
 
         return res.apiv3({ deletedShareLink: shareLinkToDelete });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in delete share link';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
       }
-
-    });
-
+    },
+  );
 
   return router;
 };

+ 62 - 28
apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -1,9 +1,9 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
 import { body } from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
@@ -11,17 +11,24 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
-
 // eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:routes:apiv3:slack-integration-legacy-setting');
+const logger = loggerFactory(
+  'growi:routes:apiv3:slack-integration-legacy-setting',
+);
 
 const router = express.Router();
 
 const validator = {
   slackConfiguration: [
-    body('webhookUrl').if(value => value != null).isString().trim(),
+    body('webhookUrl')
+      .if((value) => value != null)
+      .isString()
+      .trim(),
     body('isIncomingWebhookPrioritized').isBoolean(),
-    body('slackToken').if(value => value != null).isString().trim(),
+    body('slackToken')
+      .if((value) => value != null)
+      .isString()
+      .trim(),
   ],
 };
 
@@ -45,7 +52,9 @@ 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(crowi);
 
@@ -77,16 +86,26 @@ module.exports = (crowi) => {
    *                              type: boolean
    *                              description: whether slackbot is configured
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.LEGACY_SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, async(req, res) => {
-
-    const slackIntegrationParams = {
-      isSlackbotConfigured: crowi.slackIntegrationService.isSlackbotConfigured,
-      webhookUrl: await crowi.configManager.getConfig('slack:incomingWebhookUrl'),
-      isIncomingWebhookPrioritized: await crowi.configManager.getConfig('slack:isIncomingWebhookPrioritized'),
-      slackToken: await crowi.configManager.getConfig('slack:token'),
-    };
-    return res.apiv3({ slackIntegrationParams });
-  });
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.LEGACY_SLACK_INTEGRATION]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const slackIntegrationParams = {
+        isSlackbotConfigured:
+          crowi.slackIntegrationService.isSlackbotConfigured,
+        webhookUrl: await crowi.configManager.getConfig(
+          'slack:incomingWebhookUrl',
+        ),
+        isIncomingWebhookPrioritized: await crowi.configManager.getConfig(
+          'slack:isIncomingWebhookPrioritized',
+        ),
+        slackToken: await crowi.configManager.getConfig('slack:token'),
+      };
+      return res.apiv3({ slackIntegrationParams });
+    },
+  );
 
   /**
    * @swagger
@@ -123,35 +142,50 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/SlackConfigurationParams'
    */
-  router.put('/', accessTokenParser([SCOPE.WRITE.ADMIN.LEGACY_SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
-
+  router.put(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.LEGACY_SLACK_INTEGRATION]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.slackConfiguration,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'slack:incomingWebhookUrl': req.body.webhookUrl,
-        'slack:isIncomingWebhookPrioritized': req.body.isIncomingWebhookPrioritized,
+        'slack:isIncomingWebhookPrioritized':
+          req.body.isIncomingWebhookPrioritized,
         'slack:token': req.body.slackToken,
       };
 
       try {
         await configManager.updateConfigs(requestParams);
         const responseParams = {
-          webhookUrl: await crowi.configManager.getConfig('slack:incomingWebhookUrl'),
-          isIncomingWebhookPrioritized: await crowi.configManager.getConfig('slack:isIncomingWebhookPrioritized'),
+          webhookUrl: await crowi.configManager.getConfig(
+            'slack:incomingWebhookUrl',
+          ),
+          isIncomingWebhookPrioritized: await crowi.configManager.getConfig(
+            'slack:isIncomingWebhookPrioritized',
+          ),
           slackToken: await crowi.configManager.getConfig('slack:token'),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE };
+        const parameters = {
+          action:
+            SupportedAction.ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ responseParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating slack configuration';
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-slackConfiguration-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-slackConfiguration-failed'),
+        );
       }
-
-    });
+    },
+  );
 
   return router;
 };

Разница между файлами не показана из-за своего большого размера
+ 490 - 271
apps/app/src/server/routes/apiv3/slack-integration-settings.js


+ 315 - 153
apps/app/src/server/routes/apiv3/slack-integration.js

@@ -20,14 +20,12 @@ import loggerFactory from '~/utils/logger';
 import { handleError } from '../../service/slack-command-handler/error-handler';
 import { checkPermission } from '../../util/slack-integration';
 
-
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-
   const { slackIntegrationService } = crowi;
 
   // Check if the access token is correct
@@ -35,12 +33,15 @@ module.exports = (crowi) => {
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
 
     if (tokenPtoG == null) {
-      const message = 'The value of header \'x-growi-ptog-tokens\' must not be empty.';
+      const message =
+        "The value of header 'x-growi-ptog-tokens' must not be empty.";
       logger.warn(message, { body: req.body });
       return next(createError(400, message));
     }
 
-    const SlackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
+    const SlackAppIntegrationCount = await SlackAppIntegration.countDocuments({
+      tokenPtoG,
+    });
 
     logger.debug('verifyAccessTokenFromProxy', {
       tokenPtoG,
@@ -49,9 +50,10 @@ module.exports = (crowi) => {
 
     if (SlackAppIntegrationCount === 0) {
       return res.status(403).send({
-        message: 'The access token that identifies the request source is slackbot-proxy is invalid. Did you setup with `/growi register`.\n'
-        + 'Or did you delete registration for GROWI ? if so, the link with GROWI has been disconnected. '
-        + 'Please unregister the information registered in the proxy and setup `/growi register` again.',
+        message:
+          'The access token that identifies the request source is slackbot-proxy is invalid. Did you setup with `/growi register`.\n' +
+          'Or did you delete registration for GROWI ? if so, the link with GROWI has been disconnected. ' +
+          'Please unregister the information registered in the proxy and setup `/growi register` again.',
       });
     }
 
@@ -59,12 +61,19 @@ module.exports = (crowi) => {
   }
 
   async function extractPermissionsCommands(tokenPtoG) {
-    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+    const slackAppIntegration = await SlackAppIntegration.findOne({
+      tokenPtoG,
+    });
     if (slackAppIntegration == null) return null;
-    const permissionsForBroadcastUseCommands = slackAppIntegration.permissionsForBroadcastUseCommands;
-    const permissionsForSingleUseCommands = slackAppIntegration.permissionsForSingleUseCommands;
-
-    return { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands };
+    const permissionsForBroadcastUseCommands =
+      slackAppIntegration.permissionsForBroadcastUseCommands;
+    const permissionsForSingleUseCommands =
+      slackAppIntegration.permissionsForSingleUseCommands;
+
+    return {
+      permissionsForBroadcastUseCommands,
+      permissionsForSingleUseCommands,
+    };
   }
 
   // TODO: move this middleware to each controller
@@ -78,8 +87,7 @@ module.exports = (crowi) => {
     let growiCommand;
     try {
       growiCommand = getGrowiCommand(req.body);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err.message);
       return next(err);
     }
@@ -92,11 +100,15 @@ module.exports = (crowi) => {
           blocks: [
             markdownSectionBlock('*Command is not supported*'),
             // eslint-disable-next-line max-len
-            markdownSectionBlock(`\`/growi ${growiCommand.growiCommandType}\` command is not supported in this version of GROWI bot. Run \`/growi help\` to see all supported commands.`),
+            markdownSectionBlock(
+              `\`/growi ${growiCommand.growiCommandType}\` command is not supported in this version of GROWI bot. Run \`/growi help\` to see all supported commands.`,
+            ),
           ],
         },
       };
-      return next(new SlackCommandHandlerError('Command type is not specified', options));
+      return next(
+        new SlackCommandHandlerError('Command type is not specified', options),
+      );
     }
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
@@ -108,19 +120,41 @@ module.exports = (crowi) => {
     const siteUrl = growiInfoService.getSiteUrl();
 
     let commandPermission;
-    if (extractPermissions != null) { // with proxy
-      const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
-      commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
-      const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
+    if (extractPermissions != null) {
+      // with proxy
+      const {
+        permissionsForBroadcastUseCommands,
+        permissionsForSingleUseCommands,
+      } = extractPermissions;
+      commandPermission = Object.fromEntries([
+        ...permissionsForBroadcastUseCommands,
+        ...permissionsForSingleUseCommands,
+      ]);
+      const isPermitted = checkPermission(
+        commandPermission,
+        growiCommand.growiCommandType,
+        fromChannel,
+      );
       if (isPermitted) return next();
 
-      return next(createError(403, `It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`));
+      return next(
+        createError(
+          403,
+          `It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`,
+        ),
+      );
     }
 
     // without proxy
-    commandPermission = configManager.getConfig('slackbot:withoutProxy:commandPermission');
-
-    const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
+    commandPermission = configManager.getConfig(
+      'slackbot:withoutProxy:commandPermission',
+    );
+
+    const isPermitted = checkPermission(
+      commandPermission,
+      growiCommand.growiCommandType,
+      fromChannel,
+    );
     if (isPermitted) {
       return next();
     }
@@ -130,11 +164,15 @@ module.exports = (crowi) => {
         text: 'Command forbidden',
         blocks: [
           markdownSectionBlock('*Command is not supported*'),
-          markdownSectionBlock(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`),
+          markdownSectionBlock(
+            `It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`,
+          ),
         ],
       },
     };
-    return next(new SlackCommandHandlerError('Command type is not specified', options));
+    return next(
+      new SlackCommandHandlerError('Command type is not specified', options),
+    );
   }
 
   // TODO: move this middleware to each controller
@@ -148,26 +186,49 @@ module.exports = (crowi) => {
     const { interactionPayloadAccessor } = req;
     const siteUrl = growiInfoService.getSiteUrl();
 
-    const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+    const { actionId, callbackId } =
+      interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const callbacIdkOrActionId = callbackId || actionId;
     const fromChannel = interactionPayloadAccessor.getChannel();
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
     let commandPermission;
-    if (extractPermissions != null) { // with proxy
-      const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
-      commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
-      const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
+    if (extractPermissions != null) {
+      // with proxy
+      const {
+        permissionsForBroadcastUseCommands,
+        permissionsForSingleUseCommands,
+      } = extractPermissions;
+      commandPermission = Object.fromEntries([
+        ...permissionsForBroadcastUseCommands,
+        ...permissionsForSingleUseCommands,
+      ]);
+      const isPermitted = checkPermission(
+        commandPermission,
+        callbacIdkOrActionId,
+        fromChannel,
+      );
       if (isPermitted) return next();
 
-      return next(createError(403, `This interaction is forbidden on this GROWI: ${siteUrl}`));
+      return next(
+        createError(
+          403,
+          `This interaction is forbidden on this GROWI: ${siteUrl}`,
+        ),
+      );
     }
 
     // without proxy
-    commandPermission = configManager.getConfig('slackbot:withoutProxy:commandPermission');
-
-    const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
+    commandPermission = configManager.getConfig(
+      'slackbot:withoutProxy:commandPermission',
+    );
+
+    const isPermitted = checkPermission(
+      commandPermission,
+      callbacIdkOrActionId,
+      fromChannel,
+    );
     if (isPermitted) {
       return next();
     }
@@ -177,7 +238,9 @@ module.exports = (crowi) => {
         text: 'Interaction forbidden',
         blocks: [
           markdownSectionBlock('*Interaction forbidden*'),
-          markdownSectionBlock(`This interaction is forbidden on this GROWI: ${siteUrl}`),
+          markdownSectionBlock(
+            `This interaction is forbidden on this GROWI: ${siteUrl}`,
+          ),
         ],
       },
     };
@@ -185,7 +248,9 @@ module.exports = (crowi) => {
   }
 
   const addSigningSecretToReq = (req, res, next) => {
-    req.slackSigningSecret = configManager.getConfig('slackbot:withoutProxy:signingSecret');
+    req.slackSigningSecret = configManager.getConfig(
+      'slackbot:withoutProxy:signingSecret',
+    );
     return next();
   };
 
@@ -203,11 +268,15 @@ module.exports = (crowi) => {
 
   const parseSlackInteractionRequest = (req, res, next) => {
     if (req.body.payload == null) {
-      return next(new Error('The payload is not in the request from slack or proxy.'));
+      return next(
+        new Error('The payload is not in the request from slack or proxy.'),
+      );
     }
 
     req.interactionPayload = JSON.parse(req.body.payload);
-    req.interactionPayloadAccessor = new InteractionPayloadAccessor(req.interactionPayload);
+    req.interactionPayloadAccessor = new InteractionPayloadAccessor(
+      req.interactionPayload,
+    );
 
     return next();
   };
@@ -229,19 +298,23 @@ module.exports = (crowi) => {
     if (growiCommand == null) {
       try {
         growiCommand = parseSlashCommand(body);
-      }
-      catch (err) {
+      } catch (err) {
         if (err instanceof InvalidGrowiCommandError) {
           const options = {
             respondBody: {
               text: 'Command type is not specified',
               blocks: [
                 markdownSectionBlock('*Command type is not specified.*'),
-                markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
+                markdownSectionBlock(
+                  'Run `/growi help` to check the commands you can use.',
+                ),
               ],
             },
           };
-          throw new SlackCommandHandlerError('Command type is not specified', options);
+          throw new SlackCommandHandlerError(
+            'Command type is not specified',
+            options,
+          );
         }
         throw err;
       }
@@ -255,8 +328,7 @@ module.exports = (crowi) => {
     try {
       growiCommand = getGrowiCommand(body);
       respondUtil = getRespondUtil(responseUrl);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err.message);
       return handleError(err, responseUrl);
     }
@@ -274,22 +346,26 @@ module.exports = (crowi) => {
       await respondUtil.respond({
         text: 'Processing your request ...',
         blocks: [
-          markdownSectionBlock(`Processing your request *"/growi ${growiCommand.growiCommandType}"* on GROWI at ${appSiteUrl} ...`),
+          markdownSectionBlock(
+            `Processing your request *"/growi ${growiCommand.growiCommandType}"* on GROWI at ${appSiteUrl} ...`,
+          ),
         ],
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error occurred while request via axios:', err);
     }
 
     try {
-      await slackIntegrationService.handleCommandRequest(growiCommand, client, body, respondUtil);
-    }
-    catch (err) {
+      await slackIntegrationService.handleCommandRequest(
+        growiCommand,
+        client,
+        body,
+        respondUtil,
+      );
+    } catch (err) {
       logger.error(err.message);
       return handleError(err, responseUrl);
     }
-
   }
 
   // TODO: this method will be a middleware when typescriptize in the future
@@ -335,21 +411,27 @@ module.exports = (crowi) => {
    *               type: string
    *               example: "No text."
    */
-  router.post('/commands', addSigningSecretToReq, verifySlackRequest, checkCommandsPermission, async(req, res) => {
-    const { body } = req;
-    const responseUrl = getResponseUrl(req);
-
-    let client;
-    try {
-      client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
-    }
-    catch (err) {
-      logger.error(err.message);
-      return handleError(err, responseUrl);
-    }
+  router.post(
+    '/commands',
+    addSigningSecretToReq,
+    verifySlackRequest,
+    checkCommandsPermission,
+    async (req, res) => {
+      const { body } = req;
+      const responseUrl = getResponseUrl(req);
+
+      let client;
+      try {
+        client =
+          await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+      } catch (err) {
+        logger.error(err.message);
+        return handleError(err, responseUrl);
+      }
 
-    return handleCommands(body, res, client, responseUrl);
-  });
+      return handleCommands(body, res, client, responseUrl);
+    },
+  );
 
   // when relation test
   /**
@@ -384,15 +466,19 @@ module.exports = (crowi) => {
    *                 challenge:
    *                   type: string
    */
-  router.post('/proxied/verify', verifyAccessTokenFromProxy, async(req, res) => {
-    const { body } = req;
-
-    // eslint-disable-next-line max-len
-    // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
-    if (body.type === 'url_verification') {
-      return res.send({ challenge: body.challenge });
-    }
-  });
+  router.post(
+    '/proxied/verify',
+    verifyAccessTokenFromProxy,
+    async (req, res) => {
+      const { body } = req;
+
+      // eslint-disable-next-line max-len
+      // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
+      if (body.type === 'url_verification') {
+        return res.send({ challenge: body.challenge });
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -419,26 +505,30 @@ module.exports = (crowi) => {
    *               type: string
    *               example: "No text."
    */
-  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandsPermission, async(req, res) => {
-    const { body } = req;
-    const responseUrl = getResponseUrl(req);
+  router.post(
+    '/proxied/commands',
+    verifyAccessTokenFromProxy,
+    checkCommandsPermission,
+    async (req, res) => {
+      const { body } = req;
+      const responseUrl = getResponseUrl(req);
 
-    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+      const tokenPtoG = req.headers['x-growi-ptog-tokens'];
 
-    let client;
-    try {
-      client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
-    }
-    catch (err) {
-      logger.error(err.message);
-      return handleError(err, responseUrl);
-    }
+      let client;
+      try {
+        client =
+          await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
+      } catch (err) {
+        logger.error(err.message);
+        return handleError(err, responseUrl);
+      }
 
-    return handleCommands(body, res, client, responseUrl);
-  });
+      return handleCommands(body, res, client, responseUrl);
+    },
+  );
 
   async function handleInteractionsRequest(req, res, client) {
-
     const { interactionPayload, interactionPayloadAccessor } = req;
     const { type } = interactionPayload;
     const responseUrl = interactionPayloadAccessor.getResponseUrl();
@@ -447,16 +537,25 @@ module.exports = (crowi) => {
       const respondUtil = getRespondUtil(responseUrl);
       switch (type) {
         case 'block_actions':
-          await slackIntegrationService.handleBlockActionsRequest(client, interactionPayload, interactionPayloadAccessor, respondUtil);
+          await slackIntegrationService.handleBlockActionsRequest(
+            client,
+            interactionPayload,
+            interactionPayloadAccessor,
+            respondUtil,
+          );
           break;
         case 'view_submission':
-          await slackIntegrationService.handleViewSubmissionRequest(client, interactionPayload, interactionPayloadAccessor, respondUtil);
+          await slackIntegrationService.handleViewSubmissionRequest(
+            client,
+            interactionPayload,
+            interactionPayloadAccessor,
+            respondUtil,
+          );
           break;
         default:
           break;
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       return handleError(err, responseUrl);
     }
@@ -482,10 +581,18 @@ module.exports = (crowi) => {
    *       200:
    *         description: OK
    */
-  router.post('/interactions', addSigningSecretToReq, verifySlackRequest, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
-    const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
-    return handleInteractionsRequest(req, res, client);
-  });
+  router.post(
+    '/interactions',
+    addSigningSecretToReq,
+    verifySlackRequest,
+    parseSlackInteractionRequest,
+    checkInteractionsPermission,
+    async (req, res) => {
+      const client =
+        await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+      return handleInteractionsRequest(req, res, client);
+    },
+  );
 
   /**
    * @swagger
@@ -507,11 +614,18 @@ module.exports = (crowi) => {
    *       200:
    *         description: OK
    */
-  router.post('/proxied/interactions', verifyAccessTokenFromProxy, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
-    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
-    const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
-    return handleInteractionsRequest(req, res, client);
-  });
+  router.post(
+    '/proxied/interactions',
+    verifyAccessTokenFromProxy,
+    parseSlackInteractionRequest,
+    checkInteractionsPermission,
+    async (req, res) => {
+      const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+      const client =
+        await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
+      return handleInteractionsRequest(req, res, client);
+    },
+  );
 
   /**
    * @swagger
@@ -540,13 +654,25 @@ module.exports = (crowi) => {
    *                   items:
    *                     type: object
    */
-  router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {
-    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
-    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
-    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = slackAppIntegration;
-
-    return res.apiv3({ permissionsForBroadcastUseCommands, permissionsForSingleUseCommands });
-  });
+  router.get(
+    '/supported-commands',
+    verifyAccessTokenFromProxy,
+    async (req, res) => {
+      const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+      const slackAppIntegration = await SlackAppIntegration.findOne({
+        tokenPtoG,
+      });
+      const {
+        permissionsForBroadcastUseCommands,
+        permissionsForSingleUseCommands,
+      } = slackAppIntegration;
+
+      return res.apiv3({
+        permissionsForBroadcastUseCommands,
+        permissionsForSingleUseCommands,
+      });
+    },
+  );
 
   /**
    * @swagger
@@ -575,28 +701,46 @@ module.exports = (crowi) => {
    *             schema:
    *               type: object
    */
-  router.post('/events', verifyUrlMiddleware, addSigningSecretToReq, verifySlackRequest, async(req, res) => {
-    const { event } = req.body;
-
-    const growiBotEvent = {
-      eventType: event.type,
-      event,
-    };
-
-    try {
-      const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
-      // convert permission object to map
-      const permission = new Map(Object.entries(crowi.configManager.getConfig('slackbot:withoutProxy:eventActionsPermission')));
-
-      await crowi.slackIntegrationService.handleEventsRequest(client, growiBotEvent, permission);
+  router.post(
+    '/events',
+    verifyUrlMiddleware,
+    addSigningSecretToReq,
+    verifySlackRequest,
+    async (req, res) => {
+      const { event } = req.body;
+
+      const growiBotEvent = {
+        eventType: event.type,
+        event,
+      };
 
-      return res.apiv3({});
-    }
-    catch (err) {
-      logger.error('Error occurred while handling event request.', err);
-      return res.apiv3Err(new ErrorV3('Error occurred while handling event request.'));
-    }
-  });
+      try {
+        const client =
+          await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+        // convert permission object to map
+        const permission = new Map(
+          Object.entries(
+            crowi.configManager.getConfig(
+              'slackbot:withoutProxy:eventActionsPermission',
+            ),
+          ),
+        );
+
+        await crowi.slackIntegrationService.handleEventsRequest(
+          client,
+          growiBotEvent,
+          permission,
+        );
+
+        return res.apiv3({});
+      } catch (err) {
+        logger.error('Error occurred while handling event request.', err);
+        return res.apiv3Err(
+          new ErrorV3('Error occurred while handling event request.'),
+        );
+      }
+    },
+  );
 
   const validator = {
     validateEventRequest: [
@@ -634,33 +778,51 @@ module.exports = (crowi) => {
    *             schema:
    *               type: object
    */
-  router.post('/proxied/events', verifyAccessTokenFromProxy, validator.validateEventRequest, async(req, res) => {
-    const { growiBotEvent, data } = req.body;
+  router.post(
+    '/proxied/events',
+    verifyAccessTokenFromProxy,
+    validator.validateEventRequest,
+    async (req, res) => {
+      const { growiBotEvent, data } = req.body;
 
-    try {
-      const tokenPtoG = req.headers['x-growi-ptog-tokens'];
-      const SlackAppIntegration = mongoose.model('SlackAppIntegration');
-      const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+      try {
+        const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+        const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+        const slackAppIntegration = await SlackAppIntegration.findOne({
+          tokenPtoG,
+        });
+
+        if (slackAppIntegration == null) {
+          throw new Error(
+            'No SlackAppIntegration exists that corresponds to the tokenPtoG specified.',
+          );
+        }
 
-      if (slackAppIntegration == null) {
-        throw new Error('No SlackAppIntegration exists that corresponds to the tokenPtoG specified.');
+        const client =
+          await slackIntegrationService.generateClientBySlackAppIntegration(
+            slackAppIntegration,
+          );
+        const { permissionsForSlackEventActions } = slackAppIntegration;
+
+        await slackIntegrationService.handleEventsRequest(
+          client,
+          growiBotEvent,
+          permissionsForSlackEventActions,
+          data,
+        );
+
+        return res.apiv3({});
+      } catch (err) {
+        logger.error('Error occurred while handling event request.', err);
+        return res.apiv3Err(
+          new ErrorV3('Error occurred while handling event request.'),
+        );
       }
-
-      const client = await slackIntegrationService.generateClientBySlackAppIntegration(slackAppIntegration);
-      const { permissionsForSlackEventActions } = slackAppIntegration;
-
-      await slackIntegrationService.handleEventsRequest(client, growiBotEvent, permissionsForSlackEventActions, data);
-
-      return res.apiv3({});
-    }
-    catch (err) {
-      logger.error('Error occurred while handling event request.', err);
-      return res.apiv3Err(new ErrorV3('Error occurred while handling event request.'));
-    }
-  });
+    },
+  );
 
   // error handler
-  router.use(async(err, req, res, next) => {
+  router.use(async (err, req, res, next) => {
     const responseUrl = getResponseUrl(req);
     if (responseUrl == null) {
       // pass err to global error handler

+ 10 - 12
apps/app/src/server/routes/apiv3/staffs.js

@@ -1,13 +1,12 @@
-import axios from 'axios';
 import { addHours } from 'date-fns/addHours';
 import { isAfter } from 'date-fns/isAfter';
 import { Router } from 'express';
 
+import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:staffs'); // eslint-disable-line no-unused-vars
 
-
 const router = Router();
 
 const contributors = require('^/resource/Contributor');
@@ -17,18 +16,19 @@ const contributorsCache = contributors;
 let gcContributors;
 
 // Sorting contributors by this method
-const compareFunction = function(a, b) {
-  return a.order - b.order;
-};
+const compareFunction = (a, b) => a.order - b.order;
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-
-  router.get('/', async(req, res) => {
+  router.get('/', async (req, res) => {
     const now = new Date();
-    const growiCloudUri = await crowi.configManager.getConfig('app:growiCloudUri');
+    const growiCloudUri =
+      await crowi.configManager.getConfig('app:growiCloudUri');
 
-    if (growiCloudUri != null && (expiredAt == null || isAfter(now, expiredAt))) {
+    if (
+      growiCloudUri != null &&
+      (expiredAt == null || isAfter(now, expiredAt))
+    ) {
       const url = new URL('_api/staffCredit', growiCloudUri);
       try {
         const gcContributorsRes = await axios.get(url.toString());
@@ -41,8 +41,7 @@ module.exports = (crowi) => {
         contributorsCache.sort(compareFunction);
         // caching 'expiredAt' for 1 hour
         expiredAt = addHours(now, 1);
-      }
-      catch (err) {
+      } catch (err) {
         logger.warn('Getting GROWI.cloud staffcredit is failed');
       }
     }
@@ -50,5 +49,4 @@ module.exports = (crowi) => {
   });
 
   return router;
-
 };

+ 45 - 39
apps/app/src/server/routes/apiv3/statistics.js

@@ -24,47 +24,46 @@ const USER_STATUS_MASTER = {
  *         type: object
  *         properties:
  *           data:
-*             type: object
-*             properties:
-*               total:
-*                 type: integer
-*                 example: 1
-*               active:
-*                 type: object
-*                 properties:
-*                   total:
-*                     type: integer
-*                     example: 1
-*                   admin:
-*                     type: integer
-*                     example: 1
-*               inactive:
-*                 type: object
-*                 properties:
-*                   total:
-*                     type: integer
-*                     example: 0
-*                   registered:
-*                     type: integer
-*                     example: 0
-*                   suspended:
-*                     type: integer
-*                     example: 0
-*                   deleted:
-*                     type: integer
-*                     example: 0
-*                   invited:
-*                     type: integer
-*                     example: 0
-*/
+ *             type: object
+ *             properties:
+ *               total:
+ *                 type: integer
+ *                 example: 1
+ *               active:
+ *                 type: object
+ *                 properties:
+ *                   total:
+ *                     type: integer
+ *                     example: 1
+ *                   admin:
+ *                     type: integer
+ *                     example: 1
+ *               inactive:
+ *                 type: object
+ *                 properties:
+ *                   total:
+ *                     type: integer
+ *                     example: 0
+ *                   registered:
+ *                     type: integer
+ *                     example: 0
+ *                   suspended:
+ *                     type: integer
+ *                     example: 0
+ *                   deleted:
+ *                     type: integer
+ *                     example: 0
+ *                   invited:
+ *                     type: integer
+ *                     example: 0
+ */
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-
   const models = crowi.models;
   const User = models.User;
 
-  const getUserStatistics = async() => {
+  const getUserStatistics = async () => {
     const userCountGroupByStatus = await User.aggregate().group({
       _id: '$status',
       totalCount: { $sum: 1 },
@@ -86,7 +85,11 @@ module.exports = (crowi) => {
     delete userCountResults.active;
 
     // Calculate the total number of inactive users
-    const inactiveUserTotal = userCountResults.invited + userCountResults.deleted + userCountResults.suspended + userCountResults.registered;
+    const inactiveUserTotal =
+      userCountResults.invited +
+      userCountResults.deleted +
+      userCountResults.suspended +
+      userCountResults.registered;
 
     // Get admin users
     const adminUsers = await User.findAdmins();
@@ -104,7 +107,7 @@ module.exports = (crowi) => {
     };
   };
 
-  const getUserStatisticsForNotLoggedIn = async() => {
+  const getUserStatisticsForNotLoggedIn = async () => {
     const data = await getUserStatistics();
     delete data.active.admin;
     delete data.inactive.invited;
@@ -132,8 +135,11 @@ module.exports = (crowi) => {
    *                description: Statistics for all user
    *                $ref: '#/components/schemas/StatisticsUserResponse'
    */
-  router.get('/user', noCache(), async(req, res) => {
-    const data = req.user == null ? await getUserStatisticsForNotLoggedIn() : await getUserStatistics();
+  router.get('/user', noCache(), async (req, res) => {
+    const data =
+      req.user == null
+        ? await getUserStatisticsForNotLoggedIn()
+        : await getUserStatistics();
     res.status(200).send({ data });
   });
 

+ 44 - 21
apps/app/src/server/routes/apiv3/user-group-relation.js

@@ -1,7 +1,7 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers';
 import UserGroupRelation from '~/server/models/user-group-relation';
@@ -17,12 +17,16 @@ 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);
 
   validator.list = [
     query('groupIds', 'groupIds is required and must be an array').isArray(),
-    query('childGroupIds', 'childGroupIds must be an array').optional().isArray(),
+    query('childGroupIds', 'childGroupIds must be an array')
+      .optional()
+      .isArray(),
   ];
 
   /**
@@ -55,28 +59,47 @@ module.exports = (crowi) => {
    *                          items:
    *                            type: object
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validator.list, async(req, res) => {
-    const { query } = req;
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.list,
+    async (req, res) => {
+      const { query } = req;
 
-    try {
-      const relations = await UserGroupRelation.find({ relatedGroup: { $in: query.groupIds } }).populate('relatedUser');
+      try {
+        const relations = await UserGroupRelation.find({
+          relatedGroup: { $in: query.groupIds },
+        }).populate('relatedUser');
 
-      let relationsOfChildGroups = null;
-      if (Array.isArray(query.childGroupIds)) {
-        const _relationsOfChildGroups = await UserGroupRelation.find({ relatedGroup: { $in: query.childGroupIds } }).populate('relatedUser');
-        relationsOfChildGroups = _relationsOfChildGroups.map(relation => serializeUserGroupRelationSecurely(relation)); // serialize
-      }
+        let relationsOfChildGroups = null;
+        if (Array.isArray(query.childGroupIds)) {
+          const _relationsOfChildGroups = await UserGroupRelation.find({
+            relatedGroup: { $in: query.childGroupIds },
+          }).populate('relatedUser');
+          relationsOfChildGroups = _relationsOfChildGroups.map((relation) =>
+            serializeUserGroupRelationSecurely(relation),
+          ); // serialize
+        }
 
-      const serialized = relations.map(relation => serializeUserGroupRelationSecurely(relation));
+        const serialized = relations.map((relation) =>
+          serializeUserGroupRelationSecurely(relation),
+        );
 
-      return res.apiv3({ userGroupRelations: serialized, relationsOfChildGroups });
-    }
-    catch (err) {
-      const msg = 'Error occurred in fetching user group relations';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-relation-list-fetch-failed'));
-    }
-  });
+        return res.apiv3({
+          userGroupRelations: serialized,
+          relationsOfChildGroups,
+        });
+      } catch (err) {
+        const msg = 'Error occurred in fetching user group relations';
+        logger.error('Error', err);
+        return res.apiv3Err(
+          new ErrorV3(msg, 'user-group-relation-list-fetch-failed'),
+        );
+      }
+    },
+  );
 
   return router;
 };

+ 349 - 192
apps/app/src/server/routes/apiv3/user-group.js

@@ -1,54 +1,61 @@
 import { GroupType } 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 express from 'express';
-import {
-  body, param, query, sanitizeQuery,
-} from 'express-validator';
+import { body, param, query, sanitizeQuery } from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import UserGroup from '~/server/models/user-group';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
-import { toPagingLimit, toPagingOffset } from '~/server/util/express-validator/sanitizer';
+import {
+  toPagingLimit,
+  toPagingOffset,
+} from '~/server/util/express-validator/sanitizer';
 import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
-
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 
 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(crowi);
 
   const activityEvent = crowi.event('activity');
 
-  const {
-    User,
-    Page,
-  } = crowi.models;
+  const { User, Page } = crowi.models;
 
   const validator = {
     create: [
-      body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+      body('name', 'Group name is required')
+        .trim()
+        .exists({ checkFalsy: true }),
       body('description', 'Description must be a string').optional().isString(),
       body('parentId', 'ParentId must be a string').optional().isString(),
     ],
     update: [
       body('name', 'Group name must be a string').optional().trim().isString(),
-      body('description', 'Group description must be a string').optional().isString(),
-      body('parentId', 'ParentId must be a string or null').optional({ nullable: true }).isString(),
-      body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),
+      body('description', 'Group description must be a string')
+        .optional()
+        .isString(),
+      body('parentId', 'ParentId must be a string or null')
+        .optional({ nullable: true })
+        .isString(),
+      body('forceUpdateParents', 'forceUpdateParents must be a boolean')
+        .optional()
+        .isBoolean(),
     ],
     delete: [
       param('id').trim().exists({ checkFalsy: true }),
@@ -57,7 +64,9 @@ module.exports = (crowi) => {
     ],
     listChildren: [
       query('parentIds', 'parentIds must be an array').optional().isArray(),
-      query('includeGrandChildren', 'parentIds must be boolean').optional().isBoolean(),
+      query('includeGrandChildren', 'parentIds must be boolean')
+        .optional()
+        .isBoolean(),
     ],
     ancestorGroup: [
       query('groupId', 'groupId must be a string').optional().isString(),
@@ -137,27 +146,41 @@ module.exports = (crowi) => {
    *                      type: number
    *                      description: the number of items per page
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const { query } = req;
-
-    try {
-      const page = query.page != null ? parseInt(query.page) : undefined;
-      const limit = query.limit != null ? parseInt(query.limit) : undefined;
-      const offset = query.offset != null ? parseInt(query.offset) : undefined;
-      const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const { query } = req;
 
-      const result = await UserGroup.findWithPagination({
-        page, limit, offset, pagination,
-      });
-      const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
-      return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
-    }
-    catch (err) {
-      const msg = 'Error occurred in fetching user group list';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-list-fetch-failed'));
-    }
-  });
+      try {
+        const page = query.page != null ? parseInt(query.page) : undefined;
+        const limit = query.limit != null ? parseInt(query.limit) : undefined;
+        const offset =
+          query.offset != null ? parseInt(query.offset) : undefined;
+        const pagination =
+          query.pagination != null ? query.pagination !== 'false' : undefined;
+
+        const result = await UserGroup.findWithPagination({
+          page,
+          limit,
+          offset,
+          pagination,
+        });
+        const {
+          docs: userGroups,
+          totalDocs: totalUserGroups,
+          limit: pagingLimit,
+        } = result;
+        return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
+      } catch (err) {
+        const msg = 'Error occurred in fetching user group list';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-group-list-fetch-failed'));
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -190,23 +213,27 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: userGroup objects
    */
-  router.get('/ancestors',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/ancestors',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validator.ancestorGroup,
-    async(req, res) => {
+    async (req, res) => {
       const { groupId } = req.query;
 
       try {
         const userGroup = await UserGroup.findOne({ _id: { $eq: groupId } });
-        const ancestorUserGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
+        const ancestorUserGroups =
+          await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
         return res.apiv3({ ancestorUserGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while searching user groups';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -251,26 +278,33 @@ module.exports = (crowi) => {
    *                          type: object
    *                        description: Grandchild user group objects
    */
-  router.get('/children',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/children',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validator.listChildren,
-    async(req, res) => {
+    async (req, res) => {
       try {
         const { parentIds, includeGrandChildren = false } = req.query;
 
-        const userGroupsResult = await UserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
+        const userGroupsResult = await UserGroup.findChildrenByParentIds(
+          parentIds,
+          includeGrandChildren,
+        );
         return res.apiv3({
           childUserGroups: userGroupsResult.childUserGroups,
           grandChildUserGroups: userGroupsResult.grandChildUserGroups,
         });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in fetching child user group list';
         logger.error(msg, err);
-        return res.apiv3Err(new ErrorV3(msg, 'child-user-group-list-fetch-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'child-user-group-list-fetch-failed'),
+        );
       }
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -309,28 +343,39 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: A result of `UserGroup.createGroupByName`
    */
-  router.post('/',
-    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    addActivity, validator.create, apiV3FormValidator,
-    async(req, res) => {
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.create,
+    apiV3FormValidator,
+    async (req, res) => {
       const { name, description = '', parentId } = req.body;
 
       try {
         const userGroupName = generalXssFilter.process(name);
         const userGroupDescription = generalXssFilter.process(description);
-        const userGroup = await UserGroup.createGroup(userGroupName, userGroupDescription, parentId);
-
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_CREATE };
+        const userGroup = await UserGroup.createGroup(
+          userGroupName,
+          userGroupDescription,
+          parentId,
+        );
+
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_GROUP_CREATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ userGroup }, 201);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in creating a user group';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-group-create-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -363,27 +408,35 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: userGroup objects
    */
-  router.get('/selectable-parent-groups',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/selectable-parent-groups',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validator.selectableGroups,
-    async(req, res) => {
+    async (req, res) => {
       const { groupId } = req.query;
 
       try {
         const userGroup = await UserGroup.findOne({ _id: { $eq: groupId } });
 
-        const descendantGroups = await UserGroup.findGroupsWithDescendantsRecursively([userGroup], []);
-        const descendantGroupIds = descendantGroups.map(userGroups => userGroups._id.toString());
+        const descendantGroups =
+          await UserGroup.findGroupsWithDescendantsRecursively([userGroup], []);
+        const descendantGroupIds = descendantGroups.map((userGroups) =>
+          userGroups._id.toString(),
+        );
 
-        const selectableParentGroups = await UserGroup.find({ _id: { $nin: [groupId, ...descendantGroupIds] } });
+        const selectableParentGroups = await UserGroup.find({
+          _id: { $nin: [groupId, ...descendantGroupIds] },
+        });
         return res.apiv3({ selectableParentGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while searching user groups';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -416,10 +469,13 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: userGroup objects
    */
-  router.get('/selectable-child-groups',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/selectable-child-groups',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validator.selectableGroups,
-    async(req, res) => {
+    async (req, res) => {
       const { groupId } = req.query;
 
       try {
@@ -430,16 +486,22 @@ module.exports = (crowi) => {
           UserGroup.findGroupsWithDescendantsRecursively([userGroup], []),
         ]);
 
-        const excludeUserGroupIds = [userGroup, ...ancestorGroups, ...descendantGroups].map(userGroups => userGroups._id.toString());
-        const selectableChildGroups = await UserGroup.find({ _id: { $nin: excludeUserGroupIds } });
+        const excludeUserGroupIds = [
+          userGroup,
+          ...ancestorGroups,
+          ...descendantGroups,
+        ].map((userGroups) => userGroups._id.toString());
+        const selectableChildGroups = await UserGroup.find({
+          _id: { $nin: excludeUserGroupIds },
+        });
         return res.apiv3({ selectableChildGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while searching user groups';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -470,22 +532,25 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: userGroup object
    */
-  router.get('/:id',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/:id',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validator.selectableGroups,
-    async(req, res) => {
+    async (req, res) => {
       const { id: groupId } = req.params;
 
       try {
         const userGroup = await UserGroup.findById(groupId);
         return res.apiv3({ userGroup });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while getting user group';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-get-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -531,34 +596,51 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: A result of `UserGroup.removeCompletelyById`
    */
-  router.delete('/:id',
-    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validator.delete, apiV3FormValidator, addActivity,
-    async(req, res) => {
+  router.delete(
+    '/:id',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.delete,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
       const { id: deleteGroupId } = req.params;
-      const { actionName, transferToUserGroupId, transferToUserGroupType } = req.query;
-
-      const transferToUserGroup = typeof transferToUserGroupId === 'string'
-        && (transferToUserGroupType === GroupType.userGroup || transferToUserGroupType === GroupType.externalUserGroup)
-        ? {
-          item: transferToUserGroupId,
-          type: transferToUserGroupType,
-        } : undefined;
+      const { actionName, transferToUserGroupId, transferToUserGroupType } =
+        req.query;
+
+      const transferToUserGroup =
+        typeof transferToUserGroupId === 'string' &&
+        (transferToUserGroupType === GroupType.userGroup ||
+          transferToUserGroupType === GroupType.externalUserGroup)
+          ? {
+              item: transferToUserGroupId,
+              type: transferToUserGroupType,
+            }
+          : undefined;
 
       try {
-        const userGroups = await crowi.userGroupService.removeCompletelyByRootGroupId(deleteGroupId, actionName, req.user, transferToUserGroup);
-
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
+        const userGroups =
+          await crowi.userGroupService.removeCompletelyByRootGroupId(
+            deleteGroupId,
+            actionName,
+            req.user,
+            transferToUserGroup,
+          );
+
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ userGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while deleting user groups';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-delete-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -607,30 +689,45 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: A result of `UserGroup.updateName`
    */
-  router.put('/:id',
-    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validator.update, apiV3FormValidator, addActivity,
-    async(req, res) => {
+  router.put(
+    '/:id',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.update,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
       const { id } = req.params;
       const {
-        name, description, parentId, forceUpdateParents = false,
+        name,
+        description,
+        parentId,
+        forceUpdateParents = false,
       } = req.body;
 
       try {
-        const userGroup = await crowi.userGroupService.updateGroup(id, name, description, parentId, forceUpdateParents);
-
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE };
+        const userGroup = await crowi.userGroupService.updateGroup(
+          id,
+          name,
+          description,
+          parentId,
+          forceUpdateParents,
+        );
+
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ userGroup });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating a user group name';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-group-update-failed'));
       }
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -663,28 +760,34 @@ module.exports = (crowi) => {
    *                        $ref: '#/components/schemas/User'
    *                      description: user objects
    */
-  router.get('/:id/users',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    async(req, res) => {
+  router.get(
+    '/:id/users',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
       const { id } = req.params;
 
       try {
         const userGroup = await UserGroup.findById(id);
-        const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+        const userGroupRelations =
+          await UserGroupRelation.findAllRelationForUserGroup(userGroup);
 
         const serializeUsers = userGroupRelations.map((userGroupRelation) => {
           return serializeUserSecurely(userGroupRelation.relatedUser);
         });
-        const users = serializeUsers.filter(user => user != null);
+        const users = serializeUsers.filter((user) => user != null);
 
         return res.apiv3({ users });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = `Error occurred in fetching users for group: ${id}`;
         logger.error(msg, err);
-        return res.apiv3Err(new ErrorV3(msg, 'user-group-user-list-fetch-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'user-group-user-list-fetch-failed'),
+        );
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -737,21 +840,29 @@ module.exports = (crowi) => {
    *                        $ref: '#/components/schemas/User'
    *                      description: user objects
    */
-  router.get('/:id/unrelated-users',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    async(req, res) => {
+  router.get(
+    '/:id/unrelated-users',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
       const { id } = req.params;
-      const {
-        searchWord, searchType, isAlsoNameSearched, isAlsoMailSearched,
-      } = req.query;
+      const { searchWord, searchType, isAlsoNameSearched, isAlsoMailSearched } =
+        req.query;
 
       const queryOptions = {
-        searchWord, searchType, isAlsoNameSearched, isAlsoMailSearched,
+        searchWord,
+        searchType,
+        isAlsoNameSearched,
+        isAlsoMailSearched,
       };
 
       try {
         const userGroup = await UserGroup.findById(id);
-        const users = await UserGroupRelation.findUserByNotRelatedGroup(userGroup, queryOptions);
+        const users = await UserGroupRelation.findUserByNotRelatedGroup(
+          userGroup,
+          queryOptions,
+        );
 
         // return email only this api
         const serializedUsers = users.map((user) => {
@@ -761,14 +872,15 @@ module.exports = (crowi) => {
           return serializedUser;
         });
         return res.apiv3({ users: serializedUsers });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = `Error occurred in fetching unrelated users for group: ${id}`;
         logger.error(msg, err);
-        return res.apiv3Err(new ErrorV3(msg, 'user-group-unrelated-user-list-fetch-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'user-group-unrelated-user-list-fetch-failed'),
+        );
       }
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -809,10 +921,15 @@ module.exports = (crowi) => {
    *                      type: number
    *                      description: the number of relations created
    */
-  router.post('/:id/users/:username',
-    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validator.users.post, apiV3FormValidator, addActivity,
-    async(req, res) => {
+  router.post(
+    '/:id/users/:username',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.users.post,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
       const { id, username } = req.params;
 
       try {
@@ -821,28 +938,42 @@ module.exports = (crowi) => {
           User.findUserByUsername(username),
         ]);
 
-        const userGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
-        const userGroupIds = userGroups.map(g => g._id);
+        const userGroups =
+          await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
+        const userGroupIds = userGroups.map((g) => g._id);
 
         // remove existing relations from list to create
-        const existingRelations = await UserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
-        const existingGroupIds = existingRelations.map(r => r.relatedGroup);
-        const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
-
-        const insertedRelations = await UserGroupRelation.createRelations(groupIdsToCreateRelation, user);
+        const existingRelations = await UserGroupRelation.find({
+          relatedGroup: { $in: userGroupIds },
+          relatedUser: user._id,
+        });
+        const existingGroupIds = existingRelations.map((r) => r.relatedGroup);
+        const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(
+          userGroupIds,
+          existingGroupIds,
+        );
+
+        const insertedRelations = await UserGroupRelation.createRelations(
+          groupIdsToCreateRelation,
+          user,
+        );
         const serializedUser = serializeUserSecurely(user);
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_ADD_USER };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_GROUP_ADD_USER,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
-        return res.apiv3({ user: serializedUser, createdRelationCount: insertedRelations.length });
-      }
-      catch (err) {
+        return res.apiv3({
+          user: serializedUser,
+          createdRelationCount: insertedRelations.length,
+        });
+      } catch (err) {
         const msg = `Error occurred in adding the user "${username}" to group "${id}"`;
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-group-add-user-failed'));
       }
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -884,25 +1015,35 @@ module.exports = (crowi) => {
    *                      type: number
    *                      description: the number of groups from which the user was removed
    */
-  router.delete('/:id/users/:username',
-    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validator.users.delete, apiV3FormValidator,
-    async(req, res) => {
+  router.delete(
+    '/:id/users/:username',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.users.delete,
+    apiV3FormValidator,
+    async (req, res) => {
       const { id: userGroupId, username } = req.params;
 
       try {
-        const removedUserRes = await crowi.userGroupService.removeUserByUsername(userGroupId, username);
+        const removedUserRes =
+          await crowi.userGroupService.removeUserByUsername(
+            userGroupId,
+            username,
+          );
         const serializedUser = serializeUserSecurely(removedUserRes.user);
 
-        return res.apiv3({ user: serializedUser, deletedGroupsCount: removedUserRes.deletedGroupsCount });
-      }
-      catch (err) {
+        return res.apiv3({
+          user: serializedUser,
+          deletedGroupsCount: removedUserRes.deletedGroupsCount,
+        });
+      } catch (err) {
         const msg = 'Error occurred while removing the user from groups.';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-group-remove-user-failed'));
       }
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -935,24 +1076,31 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: userGroupRelation objects
    */
-  router.get('/:id/user-group-relations',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    async(req, res) => {
+  router.get(
+    '/:id/user-group-relations',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
       const { id } = req.params;
 
       try {
         const userGroup = await UserGroup.findById(id);
-        const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
-        const serialized = userGroupRelations.map(relation => serializeUserGroupRelationSecurely(relation));
+        const userGroupRelations =
+          await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+        const serialized = userGroupRelations.map((relation) =>
+          serializeUserGroupRelationSecurely(relation),
+        );
         return res.apiv3({ userGroupRelations: serialized });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = `Error occurred in fetching user group relations for group: ${id}`;
         logger.error(msg, err);
-        return res.apiv3Err(new ErrorV3(msg, 'user-group-user-group-relation-list-fetch-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'user-group-user-group-relation-list-fetch-failed'),
+        );
       }
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -985,26 +1133,33 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: page objects
    */
-  router.get('/:id/pages',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validator.pages.get, apiV3FormValidator,
-    async(req, res) => {
+  router.get(
+    '/:id/pages',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.pages.get,
+    apiV3FormValidator,
+    async (req, res) => {
       const { id } = req.params;
       const { limit, offset } = req.query;
 
       try {
-        const { docs, totalDocs } = await Page.paginate({
-          grant: Page.GRANT_USER_GROUP,
-          grantedGroups: {
-            $elemMatch: {
-              item: id,
+        const { docs, totalDocs } = await Page.paginate(
+          {
+            grant: Page.GRANT_USER_GROUP,
+            grantedGroups: {
+              $elemMatch: {
+                item: id,
+              },
             },
           },
-        }, {
-          offset,
-          limit,
-          populate: 'lastUpdateUser',
-        });
+          {
+            offset,
+            limit,
+            populate: 'lastUpdateUser',
+          },
+        );
 
         const current = offset / limit + 1;
 
@@ -1015,13 +1170,15 @@ module.exports = (crowi) => {
 
         // TODO: create a common moudule for paginated response
         return res.apiv3({ total: totalDocs, current, pages });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = `Error occurred in fetching pages for group: ${id}`;
         logger.error(msg, err);
-        return res.apiv3Err(new ErrorV3(msg, 'user-group-page-list-fetch-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'user-group-page-list-fetch-failed'),
+        );
       }
-    });
+    },
+  );
 
   return router;
 };

Разница между файлами не показана из-за своего большого размера
+ 402 - 224
apps/app/src/server/routes/apiv3/users.js


+ 32 - 36
apps/app/src/server/service/acl.integ.ts

@@ -3,22 +3,24 @@ import type { MockInstance } from 'vitest';
 import { aclService } from './acl';
 import { configManager } from './config-manager';
 
-
 describe('AclService', () => {
   test("has consts 'isLabeledStatement'", () => {
     expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_DENY).toBe('Deny');
-    expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY).toBe('Readonly');
+    expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY).toBe(
+      'Readonly',
+    );
     expect(aclService.labels.SECURITY_REGISTRATION_MODE_OPEN).toBe('Open');
-    expect(aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED).toBe('Restricted');
+    expect(aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED).toBe(
+      'Restricted',
+    );
     expect(aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED).toBe('Closed');
   });
 });
 
 describe('AclService test', () => {
-
   const initialEnv = process.env;
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     await configManager.loadConfigs();
   });
 
@@ -27,8 +29,7 @@ describe('AclService test', () => {
   });
 
   describe('isAclEnabled()', () => {
-
-    test('to be false when FORCE_WIKI_MODE is undefined', async() => {
+    test('to be false when FORCE_WIKI_MODE is undefined', async () => {
       delete process.env.FORCE_WIKI_MODE;
 
       // reload
@@ -41,7 +42,7 @@ describe('AclService test', () => {
       expect(result).toBe(true);
     });
 
-    test('to be false when FORCE_WIKI_MODE is dummy string', async() => {
+    test('to be false when FORCE_WIKI_MODE is dummy string', async () => {
       process.env.FORCE_WIKI_MODE = 'dummy string';
 
       // reload
@@ -54,7 +55,7 @@ describe('AclService test', () => {
       expect(result).toBe(true);
     });
 
-    test('to be true when FORCE_WIKI_MODE=private', async() => {
+    test('to be true when FORCE_WIKI_MODE=private', async () => {
       process.env.FORCE_WIKI_MODE = 'private';
 
       // reload
@@ -67,7 +68,7 @@ describe('AclService test', () => {
       expect(result).toBe(true);
     });
 
-    test('to be false when FORCE_WIKI_MODE=public', async() => {
+    test('to be false when FORCE_WIKI_MODE=public', async () => {
       process.env.FORCE_WIKI_MODE = 'public';
 
       // reload
@@ -79,13 +80,10 @@ describe('AclService test', () => {
       expect(wikiMode).toBe('public');
       expect(result).toBe(false);
     });
-
   });
 
-
   describe('isWikiModeForced()', () => {
-
-    test('to be false when FORCE_WIKI_MODE is undefined', async() => {
+    test('to be false when FORCE_WIKI_MODE is undefined', async () => {
       delete process.env.FORCE_WIKI_MODE;
 
       // reload
@@ -98,7 +96,7 @@ describe('AclService test', () => {
       expect(result).toBe(false);
     });
 
-    test('to be false when FORCE_WIKI_MODE is dummy string', async() => {
+    test('to be false when FORCE_WIKI_MODE is dummy string', async () => {
       process.env.FORCE_WIKI_MODE = 'dummy string';
 
       // reload
@@ -111,7 +109,7 @@ describe('AclService test', () => {
       expect(result).toBe(false);
     });
 
-    test('to be true when FORCE_WIKI_MODE=private', async() => {
+    test('to be true when FORCE_WIKI_MODE=private', async () => {
       process.env.FORCE_WIKI_MODE = 'private';
 
       // reload
@@ -124,7 +122,7 @@ describe('AclService test', () => {
       expect(result).toBe(true);
     });
 
-    test('to be false when FORCE_WIKI_MODE=public', async() => {
+    test('to be false when FORCE_WIKI_MODE=public', async () => {
       process.env.FORCE_WIKI_MODE = 'public';
 
       // reload
@@ -136,19 +134,17 @@ describe('AclService test', () => {
       expect(wikiMode).toBe('public');
       expect(result).toBe(true);
     });
-
   });
 
-
   describe('isGuestAllowedToRead()', () => {
     let getConfigSpy: MockInstance<typeof configManager.getConfig>;
 
-    beforeEach(async() => {
+    beforeEach(async () => {
       // prepare spy for ConfigManager.getConfig
       getConfigSpy = vi.spyOn(configManager, 'getConfig');
     });
 
-    test('to be false when FORCE_WIKI_MODE=private', async() => {
+    test('to be false when FORCE_WIKI_MODE=private', async () => {
       process.env.FORCE_WIKI_MODE = 'private';
 
       // reload
@@ -158,11 +154,13 @@ describe('AclService test', () => {
 
       const wikiMode = configManager.getConfig('security:wikiMode');
       expect(wikiMode).toBe('private');
-      expect(getConfigSpy).not.toHaveBeenCalledWith('security:restrictGuestMode');
+      expect(getConfigSpy).not.toHaveBeenCalledWith(
+        'security:restrictGuestMode',
+      );
       expect(result).toBe(false);
     });
 
-    test('to be true when FORCE_WIKI_MODE=public', async() => {
+    test('to be true when FORCE_WIKI_MODE=public', async () => {
       process.env.FORCE_WIKI_MODE = 'public';
 
       // reload
@@ -172,22 +170,23 @@ describe('AclService test', () => {
 
       const wikiMode = configManager.getConfig('security:wikiMode');
       expect(wikiMode).toBe('public');
-      expect(getConfigSpy).not.toHaveBeenCalledWith('security:restrictGuestMode');
+      expect(getConfigSpy).not.toHaveBeenCalledWith(
+        'security:restrictGuestMode',
+      );
       expect(result).toBe(true);
     });
 
     /* eslint-disable indent */
     describe.each`
-      restrictGuestMode   | expected
-      ${undefined}        | ${false}
-      ${'Deny'}           | ${false}
-      ${'Readonly'}       | ${true}
-      ${'Open'}           | ${false}
-      ${'Restricted'}     | ${false}
-      ${'closed'}         | ${false}
+      restrictGuestMode | expected
+      ${undefined}      | ${false}
+      ${'Deny'}         | ${false}
+      ${'Readonly'}     | ${true}
+      ${'Open'}         | ${false}
+      ${'Restricted'}   | ${false}
+      ${'closed'}       | ${false}
     `('to be $expected', ({ restrictGuestMode, expected }) => {
-      test(`when FORCE_WIKI_MODE is undefined and 'security:restrictGuestMode' is '${restrictGuestMode}`, async() => {
-
+      test(`when FORCE_WIKI_MODE is undefined and 'security:restrictGuestMode' is '${restrictGuestMode}`, async () => {
         // reload
         await configManager.loadConfigs();
 
@@ -210,8 +209,5 @@ describe('AclService test', () => {
         expect(result).toBe(expected);
       });
     });
-
   });
-
-
 });

+ 5 - 7
apps/app/src/server/service/acl.ts

@@ -6,18 +6,17 @@ import { configManager } from './config-manager';
 const logger = loggerFactory('growi:service:AclService');
 
 export interface AclService {
-  get labels(): { [key: string]: string },
-  isAclEnabled(): boolean,
-  isWikiModeForced(): boolean,
-  isGuestAllowedToRead(): boolean,
-  getGuestModeValue(): string,
+  get labels(): { [key: string]: string };
+  isAclEnabled(): boolean;
+  isWikiModeForced(): boolean;
+  isGuestAllowedToRead(): boolean;
+  getGuestModeValue(): string;
 }
 
 /**
  * the service class of AclService
  */
 class AclServiceImpl implements AclService {
-
   get labels() {
     return {
       SECURITY_RESTRICT_GUEST_MODE_DENY: 'Deny',
@@ -73,7 +72,6 @@ class AclServiceImpl implements AclService {
       ? this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY
       : this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY;
   }
-
 }
 
 export const aclService = new AclServiceImpl();

+ 99 - 57
apps/app/src/server/service/activity.ts

@@ -3,16 +3,18 @@ import mongoose from 'mongoose';
 
 import type { IActivity, SupportedActionType } from '~/interfaces/activity';
 import {
-  AllSupportedActions, ActionGroupSize,
-  AllEssentialActions, AllSmallGroupActions, AllMediumGroupActions, AllLargeGroupActions,
+  ActionGroupSize,
+  AllEssentialActions,
+  AllLargeGroupActions,
+  AllMediumGroupActions,
+  AllSmallGroupActions,
+  AllSupportedActions,
 } from '~/interfaces/activity';
 import type { ActivityDocument } from '~/server/models/activity';
 import Activity from '~/server/models/activity';
 
 import loggerFactory from '../../utils/logger';
 import type Crowi from '../crowi';
-
-
 import type { GeneratePreNotify, GetAdditionalTargetUsers } from './pre-notify';
 
 const logger = loggerFactory('growi:service:ActivityService');
@@ -22,12 +24,13 @@ const parseActionString = (actionsString: string): SupportedActionType[] => {
     return [];
   }
 
-  const actions = actionsString.split(',').map(value => value.trim());
-  return actions.filter(action => (AllSupportedActions as string[]).includes(action)) as SupportedActionType[];
+  const actions = actionsString.split(',').map((value) => value.trim());
+  return actions.filter((action) =>
+    (AllSupportedActions as string[]).includes(action),
+  ) as SupportedActionType[];
 };
 
 class ActivityService {
-
   crowi!: Crowi;
 
   activityEvent: any;
@@ -43,40 +46,60 @@ class ActivityService {
   }
 
   initActivityEventListeners(): void {
-    this.activityEvent.on('update', async(
-        activityId: string, parameters, target: IPage, generatePreNotify?: GeneratePreNotify, getAdditionalTargetUsers?: GetAdditionalTargetUsers,
-    ) => {
-      let activity: ActivityDocument;
-      const shoudUpdate = this.shoudUpdateActivity(parameters.action);
-
-      if (shoudUpdate) {
-        try {
-          activity = await Activity.updateByParameters(activityId, parameters);
+    this.activityEvent.on(
+      'update',
+      async (
+        activityId: string,
+        parameters,
+        target: IPage,
+        generatePreNotify?: GeneratePreNotify,
+        getAdditionalTargetUsers?: GetAdditionalTargetUsers,
+      ) => {
+        let activity: ActivityDocument;
+        const shoudUpdate = this.shoudUpdateActivity(parameters.action);
+
+        if (shoudUpdate) {
+          try {
+            activity = await Activity.updateByParameters(
+              activityId,
+              parameters,
+            );
+          } catch (err) {
+            logger.error('Update activity failed', err);
+            return;
+          }
+
+          if (generatePreNotify != null) {
+            const preNotify = generatePreNotify(
+              activity,
+              getAdditionalTargetUsers,
+            );
+
+            this.activityEvent.emit('updated', activity, target, preNotify);
+
+            return;
+          }
+
+          this.activityEvent.emit('updated', activity, target);
         }
-        catch (err) {
-          logger.error('Update activity failed', err);
-          return;
-        }
-
-        if (generatePreNotify != null) {
-          const preNotify = generatePreNotify(activity, getAdditionalTargetUsers);
-
-          this.activityEvent.emit('updated', activity, target, preNotify);
-
-          return;
-        }
-
-        this.activityEvent.emit('updated', activity, target);
-
-      }
-    });
+      },
+    );
   }
 
-  getAvailableActions = function(isIncludeEssentialActions = true): SupportedActionType[] {
-    const auditLogEnabled = this.crowi.configManager.getConfig('app:auditLogEnabled') || false;
-    const auditLogActionGroupSize = this.crowi.configManager.getConfig('app:auditLogActionGroupSize') || ActionGroupSize.Small;
-    const auditLogAdditionalActions = this.crowi.configManager.getConfig('app:auditLogAdditionalActions');
-    const auditLogExcludeActions = this.crowi.configManager.getConfig('app:auditLogExcludeActions');
+  getAvailableActions = function (
+    isIncludeEssentialActions = true,
+  ): SupportedActionType[] {
+    const auditLogEnabled =
+      this.crowi.configManager.getConfig('app:auditLogEnabled') || false;
+    const auditLogActionGroupSize =
+      this.crowi.configManager.getConfig('app:auditLogActionGroupSize') ||
+      ActionGroupSize.Small;
+    const auditLogAdditionalActions = this.crowi.configManager.getConfig(
+      'app:auditLogAdditionalActions',
+    );
+    const auditLogExcludeActions = this.crowi.configManager.getConfig(
+      'app:auditLogExcludeActions',
+    );
 
     if (!auditLogEnabled) {
       return AllEssentialActions;
@@ -87,55 +110,71 @@ class ActivityService {
     // Set base action group
     switch (auditLogActionGroupSize) {
       case ActionGroupSize.Small:
-        AllSmallGroupActions.forEach(action => availableActionsSet.add(action));
+        AllSmallGroupActions.forEach((action) => {
+          availableActionsSet.add(action);
+        });
         break;
       case ActionGroupSize.Medium:
-        AllMediumGroupActions.forEach(action => availableActionsSet.add(action));
+        AllMediumGroupActions.forEach((action) => {
+          availableActionsSet.add(action);
+        });
         break;
       case ActionGroupSize.Large:
-        AllLargeGroupActions.forEach(action => availableActionsSet.add(action));
+        AllLargeGroupActions.forEach((action) => {
+          availableActionsSet.add(action);
+        });
         break;
     }
 
     // Add additionalActions
     const additionalActions = parseActionString(auditLogAdditionalActions);
-    additionalActions.forEach(action => availableActionsSet.add(action));
+    additionalActions.forEach((action) => {
+      availableActionsSet.add(action);
+    });
 
     // Delete excludeActions
     const excludeActions = parseActionString(auditLogExcludeActions);
-    excludeActions.forEach(action => availableActionsSet.delete(action));
+    excludeActions.forEach((action) => {
+      availableActionsSet.delete(action);
+    });
 
     // Add essentialActions
     if (isIncludeEssentialActions) {
-      AllEssentialActions.forEach(action => availableActionsSet.add(action));
+      AllEssentialActions.forEach((action) => {
+        availableActionsSet.add(action);
+      });
     }
 
     return Array.from(availableActionsSet);
   };
 
-  shoudUpdateActivity = function(action: SupportedActionType): boolean {
+  shoudUpdateActivity = function (action: SupportedActionType): boolean {
     return this.getAvailableActions().includes(action);
   };
 
   // for GET request
-  createActivity = async function(parameters): Promise<IActivity | null> {
-    const shoudCreateActivity = this.crowi.activityService.shoudUpdateActivity(parameters.action);
+  createActivity = async function (parameters): Promise<IActivity | null> {
+    const shoudCreateActivity = this.crowi.activityService.shoudUpdateActivity(
+      parameters.action,
+    );
     if (shoudCreateActivity) {
       let activity: IActivity;
       try {
         activity = await Activity.createByParameters(parameters);
         return activity;
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Create activity failed', err);
       }
     }
     return null;
   };
 
-  createTtlIndex = async function() {
+  createTtlIndex = async function () {
     const configManager = this.crowi.configManager;
-    const activityExpirationSeconds = configManager != null ? configManager.getConfig('app:activityExpirationSeconds') : 2592000;
+    const activityExpirationSeconds =
+      configManager != null
+        ? configManager.getConfig('app:activityExpirationSeconds')
+        : 2592000;
 
     try {
       // create the collection with indexes at first
@@ -145,9 +184,11 @@ class ActivityService {
       const indexes = await collection.indexes();
 
       const targetField = 'createdAt_1';
-      const foundCreatedAt = indexes.find(i => i.name === targetField);
+      const foundCreatedAt = indexes.find((i) => i.name === targetField);
 
-      const isNotSpec = foundCreatedAt?.expireAfterSeconds == null || foundCreatedAt?.expireAfterSeconds !== activityExpirationSeconds;
+      const isNotSpec =
+        foundCreatedAt?.expireAfterSeconds == null ||
+        foundCreatedAt?.expireAfterSeconds !== activityExpirationSeconds;
       const shoudDropIndex = foundCreatedAt != null && isNotSpec;
       const shoudCreateIndex = foundCreatedAt == null || shoudDropIndex;
 
@@ -156,15 +197,16 @@ class ActivityService {
       }
 
       if (shoudCreateIndex) {
-        await collection.createIndex({ createdAt: 1 }, { expireAfterSeconds: activityExpirationSeconds });
+        await collection.createIndex(
+          { createdAt: 1 },
+          { expireAfterSeconds: activityExpirationSeconds },
+        );
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Failed to create TTL Index', err);
       throw err;
     }
   };
-
 }
 
 module.exports = ActivityService;

+ 5 - 7
apps/app/src/server/service/app.ts

@@ -4,7 +4,6 @@ import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
 import S2sMessage from '../models/vo/s2s-message';
-
 import { configManager } from './config-manager';
 import type { S2sMessagingService } from './s2s-messaging/base';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
@@ -14,7 +13,6 @@ const logger = loggerFactory('growi:service:AppService');
  * the service class of AppService
  */
 export default class AppService implements S2sMessageHandlable {
-
   crowi: Crowi;
 
   s2sMessagingService: S2sMessagingService;
@@ -64,12 +62,13 @@ export default class AppService implements S2sMessageHandlable {
 
       try {
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish post installation message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish post installation message with S2sMessagingService: ',
+          e.message,
+        );
       }
     }
-
   }
 
   getAppTitle() {
@@ -108,5 +107,4 @@ export default class AppService implements S2sMessageHandlable {
   async endMaintenanceMode(): Promise<void> {
     await configManager.updateConfig('app:isMaintenanceMode', false);
   }
-
 }

+ 51 - 25
apps/app/src/server/service/attachment.ts

@@ -1,4 +1,5 @@
 import type { IAttachment, Ref } from '@growi/core/dist/interfaces';
+import type { ReadStream } from 'fs';
 import type { HydratedDocument } from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
@@ -15,20 +16,30 @@ const mongoose = require('mongoose');
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:service:AttachmentService');
 
-const createReadStream = (filePath) => {
+const createReadStream = (filePath: string): ReadStream => {
   return fs.createReadStream(filePath, {
-    flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true,
+    flags: 'r',
+    encoding: null,
+    fd: null,
+    mode: '0666',
+    autoClose: true,
   });
 };
 
-type AttachHandler = (pageId: string | null, attachment: IAttachmentDocument, file: Express.Multer.File) => Promise<void>;
+type AttachHandler = (
+  pageId: string | null,
+  attachment: IAttachmentDocument,
+  file: Express.Multer.File,
+) => Promise<void>;
 
 type DetachHandler = (attachmentId: string) => Promise<void>;
 
-
 type IAttachmentService = {
   createAttachment(
-    file: Express.Multer.File, user: any, pageId: string | null, attachmentType: AttachmentType,
+    file: Express.Multer.File,
+    user: any,
+    pageId: string | null,
+    attachmentType: AttachmentType,
     disposeTmpFileCallback?: (file: Express.Multer.File) => void,
   ): Promise<IAttachmentDocument>;
   removeAllAttachments(attachments: IAttachmentDocument[]): Promise<void>;
@@ -38,12 +49,10 @@ type IAttachmentService = {
   addDetachHandler(handler: DetachHandler): void;
 };
 
-
 /**
  * the service class for Attachment and file-uploader
  */
 export class AttachmentService implements IAttachmentService {
-
   attachHandlers: AttachHandler[] = [];
 
   detachHandlers: DetachHandler[] = [];
@@ -54,7 +63,13 @@ export class AttachmentService implements IAttachmentService {
     this.crowi = crowi;
   }
 
-  async createAttachment(file, user, pageId: string | null | undefined = null, attachmentType, disposeTmpFileCallback): Promise<IAttachmentDocument> {
+  async createAttachment(
+    file,
+    user,
+    pageId: string | null | undefined = null,
+    attachmentType,
+    disposeTmpFileCallback,
+  ): Promise<IAttachmentDocument> {
     const { fileUploadService } = this.crowi;
 
     // check limit
@@ -64,12 +79,22 @@ export class AttachmentService implements IAttachmentService {
     }
 
     // create an Attachment document and upload file
-    let attachment;
-    let readStreamForCreateAttachmentDocument;
+    let attachment: IAttachmentDocument;
+    let readStreamForCreateAttachmentDocument: ReadStream | null = null;
     try {
       readStreamForCreateAttachmentDocument = createReadStream(file.path);
-      attachment = Attachment.createWithoutSave(pageId, user, file.originalname, file.mimetype, file.size, attachmentType);
-      await fileUploadService.uploadAttachment(readStreamForCreateAttachmentDocument, attachment);
+      attachment = Attachment.createWithoutSave(
+        pageId,
+        user,
+        file.originalname,
+        file.mimetype,
+        file.size,
+        attachmentType,
+      );
+      await fileUploadService.uploadAttachment(
+        readStreamForCreateAttachmentDocument,
+        attachment,
+      );
       await attachment.save();
 
       const attachHandlerPromises = this.attachHandlers.map((handler) => {
@@ -84,23 +109,24 @@ export class AttachmentService implements IAttachmentService {
         .finally(() => {
           disposeTmpFileCallback?.(file);
         });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error while creating attachment', err);
       disposeTmpFileCallback?.(file);
       throw err;
-    }
-    finally {
-      readStreamForCreateAttachmentDocument.destroy();
+    } finally {
+      readStreamForCreateAttachmentDocument?.destroy();
     }
 
     return attachment;
   }
 
-  async removeAllAttachments(attachments: HydratedDocument<IAttachmentDocument>[]): Promise<void> {
+  async removeAllAttachments(
+    attachments: HydratedDocument<IAttachmentDocument>[],
+  ): Promise<void> {
     const { fileUploadService } = this.crowi;
     const attachmentsCollection = mongoose.connection.collection('attachments');
-    const unorderAttachmentsBulkOp = attachmentsCollection.initializeUnorderedBulkOp();
+    const unorderAttachmentsBulkOp =
+      attachmentsCollection.initializeUnorderedBulkOp();
 
     if (attachments.length === 0) {
       return;
@@ -116,7 +142,9 @@ export class AttachmentService implements IAttachmentService {
     return;
   }
 
-  async removeAttachment(attachmentId: Ref<IAttachment> | undefined): Promise<void> {
+  async removeAttachment(
+    attachmentId: Ref<IAttachment> | undefined,
+  ): Promise<void> {
     const { fileUploadService } = this.crowi;
     const attachment = await Attachment.findById(attachmentId);
 
@@ -132,10 +160,9 @@ export class AttachmentService implements IAttachmentService {
     });
 
     // Do not await, run in background
-    Promise.all(detachedHandlerPromises)
-      .catch((err) => {
-        logger.error('Error while executing detached handler', err);
-      });
+    Promise.all(detachedHandlerPromises).catch((err) => {
+      logger.error('Error while executing detached handler', err);
+    });
 
     return;
   }
@@ -162,5 +189,4 @@ export class AttachmentService implements IAttachmentService {
   addDetachHandler(handler: DetachHandler): void {
     this.detachHandlers.push(handler);
   }
-
 }

+ 22 - 19
apps/app/src/server/service/comment.ts

@@ -13,7 +13,6 @@ const USERNAME_PATTERN = new RegExp(/\B@[\w@.-]+/g);
 const logger = loggerFactory('growi:service:CommentService');
 
 class CommentService {
-
   crowi!: Crowi;
 
   activityService!: any;
@@ -31,35 +30,35 @@ class CommentService {
 
   initCommentEventListeners(): void {
     // create
-    commentEvent.on(CommentEvent.CREATE, async(savedComment) => {
-
+    commentEvent.on(CommentEvent.CREATE, async (savedComment) => {
       try {
         const Page = pageModelFactory(this.crowi);
         await Page.updateCommentCount(savedComment.page);
+      } catch (err) {
+        logger.error(
+          'Error occurred while handling the comment create event:\n',
+          err,
+        );
       }
-      catch (err) {
-        logger.error('Error occurred while handling the comment create event:\n', err);
-      }
-
     });
 
     // update
-    commentEvent.on(CommentEvent.UPDATE, async() => {
-    });
+    commentEvent.on(CommentEvent.UPDATE, async () => {});
 
     // remove
-    commentEvent.on(CommentEvent.DELETE, async(removedComment) => {
+    commentEvent.on(CommentEvent.DELETE, async (removedComment) => {
       try {
         const Page = pageModelFactory(this.crowi);
         await Page.updateCommentCount(removedComment.page);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Error occurred while updating the comment count:\n', err);
       }
     });
   }
 
-  getMentionedUsers = async(commentId: Types.ObjectId): Promise<Types.ObjectId[]> => {
+  getMentionedUsers = async (
+    commentId: Types.ObjectId,
+  ): Promise<Types.ObjectId[]> => {
     const User = userModelFactory(this.crowi);
 
     // Get comment by comment ID
@@ -76,18 +75,22 @@ class CommentService {
     const usernamesFromComment = comment.match(USERNAME_PATTERN);
 
     // Get username from comment and remove duplicate username
-    const mentionedUsernames = [...new Set(usernamesFromComment?.map((username) => {
-      return username.slice(1);
-    }))];
+    const mentionedUsernames = [
+      ...new Set(
+        usernamesFromComment?.map((username) => {
+          return username.slice(1);
+        }),
+      ),
+    ];
 
     // Get mentioned users ID
-    const mentionedUserIDs = await User.find({ username: { $in: mentionedUsernames } });
+    const mentionedUserIDs = await User.find({
+      username: { $in: mentionedUsernames },
+    });
     return mentionedUserIDs?.map((user) => {
       return user._id;
     });
   };
-
 }
 
-
 module.exports = CommentService;

+ 2 - 5
apps/app/src/server/service/cron.ts

@@ -9,7 +9,6 @@ const logger = loggerFactory('growi:service:cron');
  * Base class for services that manage a cronjob
  */
 abstract class CronService {
-
   // The current cronjob to manage
   cronJob: ScheduledTask | undefined;
 
@@ -50,16 +49,14 @@ abstract class CronService {
    * @param cronSchedule e.g. '0 1 * * *'
    */
   protected generateCronJob(cronSchedule: string): ScheduledTask {
-    return nodeCron.schedule(cronSchedule, async() => {
+    return nodeCron.schedule(cronSchedule, async () => {
       try {
         await this.executeJob();
-      }
-      catch (e) {
+      } catch (e) {
         logger.error(e);
       }
     });
   }
-
 }
 
 export default CronService;

+ 36 - 22
apps/app/src/server/service/customize.ts

@@ -1,8 +1,11 @@
-import path from 'path';
-
 import type { ColorScheme } from '@growi/core';
 import { getForcedColorScheme } from '@growi/core/dist/utils';
-import { DefaultThemeMetadata, PresetThemesMetadatas, manifestPath } from '@growi/preset-themes';
+import {
+  DefaultThemeMetadata,
+  manifestPath,
+  PresetThemesMetadatas,
+} from '@growi/preset-themes';
+import path from 'path';
 import uglifycss from 'uglifycss';
 
 import { growiPluginService } from '~/features/growi-plugin/server/services';
@@ -10,20 +13,15 @@ import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
 import S2sMessage from '../models/vo/s2s-message';
-
-
 import { configManager } from './config-manager';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
-
 const logger = loggerFactory('growi:service:CustomizeService');
 
-
 /**
  * the service class of CustomizeService
  */
 export class CustomizeService implements S2sMessageHandlable {
-
   s2sMessagingService: any;
 
   appService: any;
@@ -54,7 +52,10 @@ export class CustomizeService implements S2sMessageHandlable {
       return false;
     }
 
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+    return (
+      this.lastLoadedAt == null ||
+      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
+    );
   }
 
   /**
@@ -72,13 +73,17 @@ export class CustomizeService implements S2sMessageHandlable {
     const { s2sMessagingService } = this;
 
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('customizeServiceUpdated', { updatedAt: new Date() });
+      const s2sMessage = new S2sMessage('customizeServiceUpdated', {
+        updatedAt: new Date(),
+      });
 
       try {
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
     }
   }
@@ -124,27 +129,36 @@ export class CustomizeService implements S2sMessageHandlable {
 
     this.theme = theme;
 
-    const resultForThemePlugin = await growiPluginService.findThemePlugin(theme);
+    const resultForThemePlugin =
+      await growiPluginService.findThemePlugin(theme);
 
     if (resultForThemePlugin != null) {
-      this.forcedColorScheme = getForcedColorScheme(resultForThemePlugin.themeMetadata.schemeType);
+      this.forcedColorScheme = getForcedColorScheme(
+        resultForThemePlugin.themeMetadata.schemeType,
+      );
       this.themeHref = resultForThemePlugin.themeHref;
     }
     // retrieve preset theme
     else {
       // import preset-themes manifest
-      const presetThemesManifest = await import(path.join('@growi/preset-themes', manifestPath)).then(imported => imported.default);
+      const presetThemesManifest = await import(
+        path.join('@growi/preset-themes', manifestPath)
+      ).then((imported) => imported.default);
 
-      const themeMetadata = PresetThemesMetadatas.find(p => p.name === theme);
+      const themeMetadata = PresetThemesMetadatas.find((p) => p.name === theme);
       this.forcedColorScheme = getForcedColorScheme(themeMetadata?.schemeType);
 
-      const manifestKey = themeMetadata?.manifestKey ?? DefaultThemeMetadata.manifestKey;
-      if (themeMetadata == null || !(themeMetadata.manifestKey in presetThemesManifest)) {
-        logger.warn(`Use default theme because the key for '${theme} does not exist in preset-themes manifest`);
+      const manifestKey =
+        themeMetadata?.manifestKey ?? DefaultThemeMetadata.manifestKey;
+      if (
+        themeMetadata == null ||
+        !(themeMetadata.manifestKey in presetThemesManifest)
+      ) {
+        logger.warn(
+          `Use default theme because the key for '${theme} does not exist in preset-themes manifest`,
+        );
       }
       this.themeHref = `/static/preset-themes/${presetThemesManifest[manifestKey].file}`; // configured by express.static
     }
-
   }
-
 }

+ 55 - 30
apps/app/src/server/service/export.ts

@@ -1,45 +1,41 @@
+import archiver from 'archiver';
 import fs from 'fs';
 import path from 'path';
 import { Readable, Transform } from 'stream';
 
-import archiver from 'archiver';
-
 import { toArrayIfNot } from '~/utils/array-utils';
 import { getGrowiVersion } from '~/utils/growi-version';
 import loggerFactory from '~/utils/logger';
 
 import type CollectionProgress from '../models/vo/collection-progress';
 import CollectionProgressingStatus from '../models/vo/collection-progressing-status';
-
 import type AppService from './app';
 import { configManager } from './config-manager';
 import type { GrowiBridgeService } from './growi-bridge';
 import { growiInfoService } from './growi-info';
 import type { ZipFileStat } from './interfaces/export';
 
-
 const logger = loggerFactory('growi:services:ExportService');
 const { pipeline, finished } = require('stream/promises');
 
 const mongoose = require('mongoose');
 
 class ExportProgressingStatus extends CollectionProgressingStatus {
-
   async init() {
     // retrieve total document count from each collections
-    const promises = this.progressList.map(async(collectionProgress) => {
-      const collection = mongoose.connection.collection(collectionProgress.collectionName);
+    const promises = this.progressList.map(async (collectionProgress) => {
+      const collection = mongoose.connection.collection(
+        collectionProgress.collectionName,
+      );
       collectionProgress.totalCount = await collection.count();
     });
     await Promise.all(promises);
 
     this.recalculateTotalCount();
   }
-
 }
 
 class ExportService {
-
   crowi: any;
 
   appService: AppService;
@@ -83,7 +79,9 @@ class ExportService {
    * @return {object} info for zip files and whether currentProgressingStatus exists
    */
   async getStatus() {
-    const zipFiles = fs.readdirSync(this.baseDir).filter(file => path.extname(file) === '.zip');
+    const zipFiles = fs
+      .readdirSync(this.baseDir)
+      .filter((file) => path.extname(file) === '.zip');
 
     // process serially so as not to waste memory
     const zipFileStats: Array<ZipFileStat | null> = [];
@@ -96,14 +94,16 @@ class ExportService {
     }
 
     // filter null object (broken zip)
-    const filtered = zipFileStats.filter(element => element != null);
+    const filtered = zipFileStats.filter((element) => element != null);
 
     const isExporting = this.currentProgressingStatus != null;
 
     return {
       zipFileStats: filtered,
       isExporting,
-      progressList: isExporting ? this.currentProgressingStatus?.progressList : null,
+      progressList: isExporting
+        ? this.currentProgressingStatus?.progressList
+        : null,
     };
   }
 
@@ -114,8 +114,13 @@ class ExportService {
    * @return {string} path to meta.json
    */
   async createMetaJson(): Promise<string> {
-    const metaJson = path.join(this.baseDir, this.growiBridgeService.getMetaFileName());
-    const writeStream = fs.createWriteStream(metaJson, { encoding: this.growiBridgeService.getEncoding() });
+    const metaJson = path.join(
+      this.baseDir,
+      this.growiBridgeService.getMetaFileName(),
+    );
+    const writeStream = fs.createWriteStream(metaJson, {
+      encoding: this.growiBridgeService.getEncoding(),
+    });
     const passwordSeed = this.crowi.env.PASSWORD_SEED || null;
 
     const metaData = {
@@ -211,12 +216,15 @@ class ExportService {
     const transformStream = this.generateTransformStream();
 
     // log configuration
-    const exportProgress = this.currentProgressingStatus?.progressMap[collectionName];
+    const exportProgress =
+      this.currentProgressingStatus?.progressMap[collectionName];
     const logStream = this.generateLogStream(exportProgress);
 
     // create WritableStream
     const jsonFileToWrite = path.join(this.baseDir, `${collectionName}.json`);
-    const writeStream = fs.createWriteStream(jsonFileToWrite, { encoding: this.growiBridgeService.getEncoding() });
+    const writeStream = fs.createWriteStream(jsonFileToWrite, {
+      encoding: this.growiBridgeService.getEncoding(),
+    });
 
     await pipeline(readStream, logStream, transformStream, writeStream);
 
@@ -230,12 +238,16 @@ class ExportService {
    * @param {Array.<string>} collections array of collection name
    * @return {Array.<ZipFileStat>} info of zip file created
    */
-  async exportCollectionsToZippedJson(collections: string[]): Promise<ZipFileStat | null> {
+  async exportCollectionsToZippedJson(
+    collections: string[],
+  ): Promise<ZipFileStat | null> {
     const metaJson = await this.createMetaJson();
 
     // process serially so as not to waste memory
     const jsonFiles: string[] = [];
-    const jsonFilesPromises = collections.map(collectionName => this.exportCollectionToJson(collectionName));
+    const jsonFilesPromises = collections.map((collectionName) =>
+      this.exportCollectionToJson(collectionName),
+    );
     for await (const jsonFile of jsonFilesPromises) {
       jsonFiles.push(jsonFile);
     }
@@ -244,14 +256,17 @@ class ExportService {
     this.emitStartZippingEvent();
 
     // zip json
-    const configs = jsonFiles.map((jsonFile) => { return { from: jsonFile, as: path.basename(jsonFile) } });
+    const configs = jsonFiles.map((jsonFile) => {
+      return { from: jsonFile, as: path.basename(jsonFile) };
+    });
     // add meta.json in zip
     configs.push({ from: metaJson, as: path.basename(metaJson) });
     // exec zip
     const zipFile = await this.zipFiles(configs);
 
     // get stats for the zip file
-    const addedZipFileStat = await this.growiBridgeService.parseZipFile(zipFile);
+    const addedZipFileStat =
+      await this.growiBridgeService.parseZipFile(zipFile);
 
     // send terminate event
     this.emitTerminateEvent(addedZipFileStat);
@@ -272,8 +287,7 @@ class ExportService {
     let zipFileStat: ZipFileStat | null;
     try {
       zipFileStat = await this.exportCollectionsToZippedJson(collections);
-    }
-    finally {
+    } finally {
       this.currentProgressingStatus = null;
     }
 
@@ -288,7 +302,10 @@ class ExportService {
    * @param {CollectionProgress} collectionProgress
    * @param {number} currentCount number of items exported
    */
-  logProgress(collectionProgress: CollectionProgress | undefined, currentCount: number): void {
+  logProgress(
+    collectionProgress: CollectionProgress | undefined,
+    currentCount: number,
+  ): void {
     if (collectionProgress == null) return;
 
     const output = `${collectionProgress.collectionName}: ${currentCount}/${collectionProgress.totalCount} written`;
@@ -334,7 +351,9 @@ class ExportService {
    * @param {object} zipFileStat added zip file status data
    */
   emitTerminateEvent(zipFileStat: ZipFileStat | null): void {
-    this.adminEvent.emit('onTerminateForExport', { addedZipFileStat: zipFileStat });
+    this.adminEvent.emit('onTerminateForExport', {
+      addedZipFileStat: zipFileStat,
+    });
   }
 
   /**
@@ -345,11 +364,14 @@ class ExportService {
    * @return {string} absolute path to the zip file
    * @see https://www.archiverjs.com/#quick-start
    */
-  async zipFiles(_configs: {from: string, as: string}[]): Promise<string> {
+  async zipFiles(_configs: { from: string; as: string }[]): Promise<string> {
     const configs = toArrayIfNot(_configs);
     const appTitle = this.appService.getAppTitle();
-    const timeStamp = (new Date()).getTime();
-    const zipFile = path.join(this.baseDir, `${appTitle}-${timeStamp}.growi.zip`);
+    const timeStamp = new Date().getTime();
+    const zipFile = path.join(
+      this.baseDir,
+      `${appTitle}-${timeStamp}.growi.zip`,
+    );
     const archive = archiver('zip', {
       zlib: { level: this.zlibLevel },
     });
@@ -361,7 +383,9 @@ class ExportService {
     });
 
     // good practice to catch this error explicitly
-    archive.on('error', (err) => { throw err });
+    archive.on('error', (err) => {
+      throw err;
+    });
 
     for (const { from, as } of configs) {
       const input = fs.createReadStream(from);
@@ -379,7 +403,9 @@ class ExportService {
     // pipe archive data to the file
     await pipeline(archive, output);
 
-    logger.info(`zipped GROWI data into ${zipFile} (${archive.pointer()} bytes)`);
+    logger.info(
+      `zipped GROWI data into ${zipFile} (${archive.pointer()} bytes)`,
+    );
 
     // delete json files
     for (const { from } of configs) {
@@ -399,7 +425,6 @@ class ExportService {
 
     return readable;
   }
-
 }
 
 // eslint-disable-next-line import/no-mutable-exports

+ 24 - 19
apps/app/src/server/service/external-account.ts

@@ -7,13 +7,11 @@ import loggerFactory from '~/utils/logger';
 import { NullUsernameToBeRegisteredError } from '../models/errors';
 import type { ExternalAccountDocument } from '../models/external-account';
 import ExternalAccount from '../models/external-account';
-
 import type PassportService from './passport';
 
 const logger = loggerFactory('growi:service:external-account-service');
 
 class ExternalAccountService {
-
   passportService: PassportService;
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -22,14 +20,16 @@ class ExternalAccountService {
   }
 
   async getOrCreateUser(
-      userInfo: {id: string, username: string, name?: string, email?: string},
-      providerId: IExternalAuthProviderType,
+    userInfo: { id: string; username: string; name?: string; email?: string },
+    providerId: IExternalAuthProviderType,
   ): Promise<ExternalAccountDocument | undefined> {
     // get option
-    const isSameUsernameTreatedAsIdenticalUser = this.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
-    const isSameEmailTreatedAsIdenticalUser = providerId === 'ldap'
-      ? false
-      : this.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
+    const isSameUsernameTreatedAsIdenticalUser =
+      this.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
+    const isSameEmailTreatedAsIdenticalUser =
+      providerId === 'ldap'
+        ? false
+        : this.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
 
     try {
       // find or register(create) user
@@ -43,30 +43,35 @@ class ExternalAccountService {
         userInfo.email,
       );
       return externalAccount;
-    }
-    catch (err) {
+    } catch (err) {
       if (err instanceof NullUsernameToBeRegisteredError) {
         logger.error(err.message);
         throw new ErrorV3(err.message);
-      }
-      else if (err.name === 'DuplicatedUsernameException') {
-        if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
+      } else if (err.name === 'DuplicatedUsernameException') {
+        if (
+          isSameEmailTreatedAsIdenticalUser ||
+          isSameUsernameTreatedAsIdenticalUser
+        ) {
           // associate to existing user
-          logger.debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
+          logger.debug(
+            `ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`,
+          );
           return ExternalAccount.associate(providerId, userInfo.id, err.user);
         }
         logger.error('provider-DuplicatedUsernameException', providerId);
 
-        throw new ErrorV3('message.provider_duplicated_username_exception', LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION,
-          undefined, { failedProviderForDuplicatedUsernameException: providerId });
-      }
-      else if (err.name === 'UserUpperLimitException') {
+        throw new ErrorV3(
+          'message.provider_duplicated_username_exception',
+          LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION,
+          undefined,
+          { failedProviderForDuplicatedUsernameException: providerId },
+        );
+      } else if (err.name === 'UserUpperLimitException') {
         logger.error(err.message);
         throw new ErrorV3(err.message);
       }
     }
   }
-
 }
 
 // eslint-disable-next-line import/no-mutable-exports

+ 12 - 8
apps/app/src/server/service/file-uploader-switch.ts

@@ -2,7 +2,6 @@ import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
 import S2sMessage from '../models/vo/s2s-message';
-
 import { configManager } from './config-manager';
 import type { S2sMessagingService } from './s2s-messaging/base';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
@@ -10,7 +9,6 @@ import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 const logger = loggerFactory('growi:service:FileUploaderSwitch');
 
 class FileUploaderSwitch implements S2sMessageHandlable {
-
   crowi: Crowi;
 
   s2sMessagingService: S2sMessagingService;
@@ -31,7 +29,10 @@ class FileUploaderSwitch implements S2sMessageHandlable {
       return false;
     }
 
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+    return (
+      this.lastLoadedAt == null ||
+      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
+    );
   }
 
   /**
@@ -47,17 +48,20 @@ class FileUploaderSwitch implements S2sMessageHandlable {
     const { s2sMessagingService } = this;
 
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('fileUploadServiceUpdated', { updatedAt: new Date() });
+      const s2sMessage = new S2sMessage('fileUploadServiceUpdated', {
+        updatedAt: new Date(),
+      });
 
       try {
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
     }
   }
-
 }
 
 module.exports = FileUploaderSwitch;

+ 217 - 112
apps/app/src/server/service/g2g-transfer.ts

@@ -1,15 +1,18 @@
-import type { ReadStream } from 'fs';
-import { createReadStream } from 'fs';
-import { basename } from 'path';
-import type { Readable } from 'stream';
-
 import { ConfigSource } from '@growi/core';
 import type { IUser } from '@growi/core/dist/interfaces';
+// biome-ignore lint/style/noRestrictedImports: TODO: check effects of using custom axios
 import rawAxios, { type AxiosRequestConfig } from 'axios';
 import FormData from 'form-data';
-import mongoose, { Types as MongooseTypes } from 'mongoose';
+import type { ReadStream } from 'fs';
+import { createReadStream } from 'fs';
+import mongoose, {
+  type HydratedDocument,
+  Types as MongooseTypes,
+} from 'mongoose';
+import { basename } from 'path';
 
 import { G2G_PROGRESS_STATUS } from '~/interfaces/g2g-transfer';
+import type { ITransferKey } from '~/interfaces/transfer-key';
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { ImportMode } from '~/models/admin/import-mode';
 import TransferKeyModel from '~/server/models/transfer-key';
@@ -22,8 +25,10 @@ import { TransferKey } from '~/utils/vo/transfer-key';
 
 import type Crowi from '../crowi';
 import { Attachment } from '../models/attachment';
-import { G2GTransferError, G2GTransferErrorCode } from '../models/vo/g2g-transfer-error';
-
+import {
+  G2GTransferError,
+  G2GTransferErrorCode,
+} from '../models/vo/g2g-transfer-error';
 import { configManager } from './config-manager';
 import type { ConfigKey } from './config-manager/config-definition';
 import { exportService } from './export';
@@ -58,20 +63,20 @@ const UPLOAD_CONFIG_KEYS = [
 /**
  * File upload related configs
  */
-type FileUploadConfigs = { [key in typeof UPLOAD_CONFIG_KEYS[number] ]: any; }
+type FileUploadConfigs = { [key in (typeof UPLOAD_CONFIG_KEYS)[number]]: any };
 
 /**
  * Data used for comparing to/from GROWI information
  */
 export type IDataGROWIInfo = {
   /** GROWI version */
-  version: string
+  version: string;
   /** Max user count */
-  userUpperLimit: number | null // Handle null as Infinity
+  userUpperLimit: number | null; // Handle null as Infinity
   /** Whether file upload is disabled */
   fileUploadDisabled: boolean;
   /** Total file size allowed */
-  fileUploadTotalLimit: number | null // Handle null as Infinity
+  fileUploadTotalLimit: number | null; // Handle null as Infinity
   /** Attachment infromation */
   attachmentInfo: {
     /** File storage type */
@@ -89,7 +94,7 @@ export type IDataGROWIInfo = {
     /** Azure container name */
     containerName?: string;
   };
-}
+};
 
 /**
  * File metadata in storage
@@ -105,7 +110,9 @@ interface FileMeta {
 /**
  * Return type for {@link Pusher.getTransferability}
  */
-type Transferability = { canTransfer: true; } | { canTransfer: false; reason: string; };
+type Transferability =
+  | { canTransfer: true }
+  | { canTransfer: false; reason: string };
 
 /**
  * G2g transfer pusher
@@ -116,27 +123,30 @@ interface Pusher {
    * @param {TransferKey} tk Transfer key
    * @param {AxiosRequestConfig} config Axios config
    */
-  generateAxiosConfig(tk: TransferKey, config: AxiosRequestConfig): AxiosRequestConfig
+  generateAxiosConfig(
+    tk: TransferKey,
+    config: AxiosRequestConfig,
+  ): AxiosRequestConfig;
   /**
    * Send to-growi a request to get GROWI info
    * @param {TransferKey} tk Transfer key
    */
-  askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo>
+  askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo>;
   /**
    * Check if transfering is proceedable
    * @param {IDataGROWIInfo} destGROWIInfo GROWI info from dest GROWI
    */
-  getTransferability(destGROWIInfo: IDataGROWIInfo): Promise<Transferability>
+  getTransferability(destGROWIInfo: IDataGROWIInfo): Promise<Transferability>;
   /**
    * List files in the storage
    * @param {TransferKey} tk Transfer key
    */
-  listFilesInStorage(tk: TransferKey): Promise<FileMeta[]>
+  listFilesInStorage(tk: TransferKey): Promise<FileMeta[]>;
   /**
    * Transfer all Attachment data to dest GROWI
    * @param {TransferKey} tk Transfer key
    */
-  transferAttachments(tk: TransferKey): Promise<void>
+  transferAttachments(tk: TransferKey): Promise<void>;
   /**
    * Start transfer data between GROWIs
    * @param {TransferKey} tk TransferKey object
@@ -151,7 +161,7 @@ interface Pusher {
     collections: string[],
     optionsMap: any,
     destGROWIInfo: IDataGROWIInfo,
-  ): Promise<void>
+  ): Promise<void>;
 }
 
 /**
@@ -163,12 +173,12 @@ interface Receiver {
    * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
    * @param {string} key Transfer key
    */
-  validateTransferKey(key: string): Promise<void>
+  validateTransferKey(key: string): Promise<void>;
   /**
    * Generate GROWIInfo
    * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
    */
-  answerGROWIInfo(): Promise<IDataGROWIInfo>
+  answerGROWIInfo(): Promise<IDataGROWIInfo>;
   /**
    * DO NOT USE TransferKeyModel.create() directly, instead, use this method to create a TransferKey document.
    * This method receives appSiteUrlOrigin to create a TransferKey document and returns generated transfer key string.
@@ -176,7 +186,7 @@ interface Receiver {
    * @param {string} appSiteUrlOrigin GROWI app site URL origin
    * @returns {string} Transfer key string (e.g. http://localhost:3000__grw_internal_tranferkey__<uuid>)
    */
-  createTransferKey(appSiteUrlOrigin: string): Promise<string>
+  createTransferKey(appSiteUrlOrigin: string): Promise<string>;
   /**
    * Returns a map of collection name and ImportSettings
    * @param {any[]} innerFileStats
@@ -186,9 +196,9 @@ interface Receiver {
    */
   getImportSettingMap(
     innerFileStats: any[],
-    optionsMap: { [key: string]: GrowiArchiveImportOption; },
+    optionsMap: { [key: string]: GrowiArchiveImportOption },
     operatorUserId: string,
-  ): Map<string, ImportSettings>
+  ): Map<string, ImportSettings>;
   /**
    * Import collections
    * @param {string} collections Array of collection name
@@ -199,29 +209,28 @@ interface Receiver {
     collections: string[],
     importSettingsMap: Map<string, ImportSettings>,
     sourceGROWIUploadConfigs: FileUploadConfigs,
-  ): Promise<void>
+  ): Promise<void>;
   /**
    * Returns file upload configs
    */
-  getFileUploadConfigs(): Promise<FileUploadConfigs>
-    /**
+  getFileUploadConfigs(): Promise<FileUploadConfigs>;
+  /**
    * Update file upload configs
    * @param fileUploadConfigs  File upload configs
    */
-  updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void>
+  updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void>;
   /**
    * Upload attachment file
    * @param {ReadStream} content Pushed attachment data from source GROWI
    * @param {any} attachmentMap Map-ped Attachment instance
    */
-  receiveAttachment(content: ReadStream, attachmentMap: any): Promise<void>
+  receiveAttachment(content: ReadStream, attachmentMap: any): Promise<void>;
 }
 
 /**
  * G2g transfer pusher
  */
 export class G2GTransferPusherService implements Pusher {
-
   crowi: Crowi;
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -229,7 +238,10 @@ export class G2GTransferPusherService implements Pusher {
     this.crowi = crowi;
   }
 
-  public generateAxiosConfig(tk: TransferKey, baseConfig: AxiosRequestConfig = {}): AxiosRequestConfig {
+  public generateAxiosConfig(
+    tk: TransferKey,
+    baseConfig: AxiosRequestConfig = {},
+  ): AxiosRequestConfig {
     const { appSiteUrlOrigin, key } = tk;
 
     return {
@@ -245,16 +257,25 @@ export class G2GTransferPusherService implements Pusher {
 
   public async askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo> {
     try {
-      const { data: { growiInfo } } = await axios.get('/_api/v3/g2g-transfer/growi-info', this.generateAxiosConfig(tk));
+      const {
+        data: { growiInfo },
+      } = await axios.get(
+        '/_api/v3/g2g-transfer/growi-info',
+        this.generateAxiosConfig(tk),
+      );
       return growiInfo;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
-      throw new G2GTransferError('Failed to retrieve GROWI info.', G2GTransferErrorCode.FAILED_TO_RETRIEVE_GROWI_INFO);
+      throw new G2GTransferError(
+        'Failed to retrieve GROWI info.',
+        G2GTransferErrorCode.FAILED_TO_RETRIEVE_GROWI_INFO,
+      );
     }
   }
 
-  public async getTransferability(destGROWIInfo: IDataGROWIInfo): Promise<Transferability> {
+  public async getTransferability(
+    destGROWIInfo: IDataGROWIInfo,
+  ): Promise<Transferability> {
     const { fileUploadService } = this.crowi;
 
     const version = getGrowiVersion();
@@ -325,12 +346,19 @@ export class G2GTransferPusherService implements Pusher {
 
   public async listFilesInStorage(tk: TransferKey): Promise<FileMeta[]> {
     try {
-      const { data: { files } } = await axios.get<{ files: FileMeta[] }>('/_api/v3/g2g-transfer/files', this.generateAxiosConfig(tk));
+      const {
+        data: { files },
+      } = await axios.get<{ files: FileMeta[] }>(
+        '/_api/v3/g2g-transfer/files',
+        this.generateAxiosConfig(tk),
+      );
       return files;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
-      throw new G2GTransferError('Failed to retrieve file metadata', G2GTransferErrorCode.FAILED_TO_RETRIEVE_FILE_METADATA);
+      throw new G2GTransferError(
+        'Failed to retrieve file metadata',
+        G2GTransferErrorCode.FAILED_TO_RETRIEVE_FILE_METADATA,
+      );
     }
   }
 
@@ -381,27 +409,32 @@ export class G2GTransferPusherService implements Pusher {
      * | c.png | 1024 |
      * | d.png | 2048 |
      */
-    const filter = filesFromSrcGROWI.length > 0 ? {
-      $and: filesFromSrcGROWI.map(({ name, size }) => ({
-        $or: [
-          { fileName: { $ne: basename(name) } },
-          { fileSize: { $ne: size } },
-        ],
-      })),
-    } : {};
+    const filter =
+      filesFromSrcGROWI.length > 0
+        ? {
+            $and: filesFromSrcGROWI.map(({ name, size }) => ({
+              $or: [
+                { fileName: { $ne: basename(name) } },
+                { fileSize: { $ne: size } },
+              ],
+            })),
+          }
+        : {};
     const attachmentsCursor = await Attachment.find(filter).cursor();
     const batchStream = createBatchStream(BATCH_SIZE);
 
     for await (const attachmentBatch of attachmentsCursor.pipe(batchStream)) {
       for await (const attachment of attachmentBatch) {
         logger.debug(`processing attachment: ${attachment}`);
-        let fileStream;
+        let fileStream: NodeJS.ReadableStream;
         try {
           // get read stream of each attachment
           fileStream = await fileUploadService.findDeliveryFile(attachment);
-        }
-        catch (err) {
-          logger.warn(`Error occured when getting Attachment(ID=${attachment.id}), skipping: `, err);
+        } catch (err) {
+          logger.warn(
+            `Error occured when getting Attachment(ID=${attachment.id}), skipping: `,
+            err,
+          );
           socket?.emit('admin:g2gError', {
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
@@ -413,9 +446,11 @@ export class G2GTransferPusherService implements Pusher {
         // post each attachment file data to receiver
         try {
           await this.doTransferAttachment(tk, attachment, fileStream);
-        }
-        catch (err) {
-          logger.error(`Error occured when uploading attachment(ID=${attachment.id})`, err);
+        } catch (err) {
+          logger.error(
+            `Error occured when uploading attachment(ID=${attachment.id})`,
+            err,
+          );
           socket?.emit('admin:g2gError', {
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
@@ -428,7 +463,13 @@ export class G2GTransferPusherService implements Pusher {
   }
 
   // eslint-disable-next-line max-len
-  public async startTransfer(tk: TransferKey, user: any, collections: string[], optionsMap: any, destGROWIInfo: IDataGROWIInfo): Promise<void> {
+  public async startTransfer(
+    tk: TransferKey,
+    user: any,
+    collections: string[],
+    optionsMap: any,
+    destGROWIInfo: IDataGROWIInfo,
+  ): Promise<void> {
     const socket = this.crowi.socketIoService?.getAdminSocket();
 
     socket?.emit('admin:g2gProgress', {
@@ -438,9 +479,11 @@ export class G2GTransferPusherService implements Pusher {
 
     const targetConfigKeys = UPLOAD_CONFIG_KEYS;
 
-    const uploadConfigs = Object.fromEntries(targetConfigKeys.map((key) => {
-      return [key, configManager.getConfig(key)];
-    }));
+    const uploadConfigs = Object.fromEntries(
+      targetConfigKeys.map((key) => {
+        return [key, configManager.getConfig(key)];
+      }),
+    );
 
     let zipFileStream: ReadStream;
     try {
@@ -450,14 +493,16 @@ export class G2GTransferPusherService implements Pusher {
       if (zipFilePath == null) throw new Error('Failed to generate zip file');
 
       zipFileStream = createReadStream(zipFilePath);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.ERROR,
         attachments: G2G_PROGRESS_STATUS.PENDING,
       });
-      socket?.emit('admin:g2gError', { message: 'Failed to generate GROWI archive file', key: 'admin:g2g:error_generate_growi_archive' });
+      socket?.emit('admin:g2gError', {
+        message: 'Failed to generate GROWI archive file',
+        key: 'admin:g2g:error_generate_growi_archive',
+      });
       throw err;
     }
 
@@ -467,20 +512,30 @@ export class G2GTransferPusherService implements Pusher {
       const form = new FormData();
 
       const appTitle = this.crowi.appService.getAppTitle();
-      form.append('transferDataZipFile', zipFileStream, `${appTitle}-${Date.now}.growi.zip`);
+      form.append(
+        'transferDataZipFile',
+        zipFileStream,
+        `${appTitle}-${Date.now}.growi.zip`,
+      );
       form.append('collections', JSON.stringify(collections));
       form.append('optionsMap', JSON.stringify(optionsMap));
       form.append('operatorUserId', user._id.toString());
       form.append('uploadConfigs', JSON.stringify(uploadConfigs));
-      await rawAxios.post('/_api/v3/g2g-transfer/', form, this.generateAxiosConfig(tk, { headers: form.getHeaders() }));
-    }
-    catch (err) {
+      await rawAxios.post(
+        '/_api/v3/g2g-transfer/',
+        form,
+        this.generateAxiosConfig(tk, { headers: form.getHeaders() }),
+      );
+    } catch (err) {
       logger.error(err);
       socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.ERROR,
         attachments: G2G_PROGRESS_STATUS.PENDING,
       });
-      socket?.emit('admin:g2gError', { message: 'Failed to send GROWI archive file to the destination GROWI', key: 'admin:g2g:error_send_growi_archive' });
+      socket?.emit('admin:g2gError', {
+        message: 'Failed to send GROWI archive file to the destination GROWI',
+        key: 'admin:g2g:error_send_growi_archive',
+      });
       throw err;
     }
 
@@ -491,14 +546,16 @@ export class G2GTransferPusherService implements Pusher {
 
     try {
       await this.transferAttachments(tk);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.COMPLETED,
         attachments: G2G_PROGRESS_STATUS.ERROR,
       });
-      socket?.emit('admin:g2gError', { message: 'Failed to transfer attachments', key: 'admin:g2g:error_upload_attachment' });
+      socket?.emit('admin:g2gError', {
+        message: 'Failed to transfer attachments',
+        key: 'admin:g2g:error_upload_attachment',
+      });
       throw err;
     }
 
@@ -512,24 +569,30 @@ export class G2GTransferPusherService implements Pusher {
    * Transfer attachment to dest GROWI
    * @param {TransferKey} tk Transfer key
    * @param {any} attachment Attachment model instance
-   * @param {Readable} fileStream Attachment data(loaded from storage)
+   * @param {NodeJS.ReadableStream} fileStream Attachment data(loaded from storage)
    */
-  private async doTransferAttachment(tk: TransferKey, attachment: any, fileStream: Readable): Promise<void> {
+  private async doTransferAttachment(
+    tk: TransferKey,
+    attachment: any,
+    fileStream: NodeJS.ReadableStream,
+  ): Promise<void> {
     // Use FormData to immitate browser's form data object
     const form = new FormData();
 
     form.append('content', fileStream, attachment.fileName);
     form.append('attachmentMetadata', JSON.stringify(attachment));
-    await rawAxios.post('/_api/v3/g2g-transfer/attachment', form, this.generateAxiosConfig(tk, { headers: form.getHeaders() }));
+    await rawAxios.post(
+      '/_api/v3/g2g-transfer/attachment',
+      form,
+      this.generateAxiosConfig(tk, { headers: form.getHeaders() }),
+    );
   }
-
 }
 
 /**
  * G2g transfer receiver
  */
 export class G2GTransferReceiverService implements Receiver {
-
   crowi: Crowi;
 
   constructor(crowi: Crowi) {
@@ -545,8 +608,7 @@ export class G2GTransferReceiverService implements Receiver {
 
     try {
       TransferKey.parse(transferKey.keyString);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw new Error(`Transfer key "${key}" is invalid`);
     }
@@ -556,7 +618,9 @@ export class G2GTransferReceiverService implements Receiver {
     const { fileUploadService } = this.crowi;
     const version = getGrowiVersion();
     const userUpperLimit = configManager.getConfig('security:userUpperLimit');
-    const fileUploadDisabled = configManager.getConfig('app:fileUploadDisabled');
+    const fileUploadDisabled = configManager.getConfig(
+      'app:fileUploadDisabled',
+    );
     const fileUploadTotalLimit = fileUploadService.getFileUploadTotalLimit();
     const isWritable = await fileUploadService.isWritable();
 
@@ -574,15 +638,23 @@ export class G2GTransferReceiverService implements Receiver {
     switch (attachmentInfo.type) {
       case 'aws':
         attachmentInfo.bucket = configManager.getConfig('aws:s3Bucket');
-        attachmentInfo.customEndpoint = configManager.getConfig('aws:s3CustomEndpoint');
+        attachmentInfo.customEndpoint = configManager.getConfig(
+          'aws:s3CustomEndpoint',
+        );
         break;
       case 'gcs':
         attachmentInfo.bucket = configManager.getConfig('gcs:bucket');
-        attachmentInfo.uploadNamespace = configManager.getConfig('gcs:uploadNamespace');
+        attachmentInfo.uploadNamespace = configManager.getConfig(
+          'gcs:uploadNamespace',
+        );
         break;
       case 'azure':
-        attachmentInfo.accountName = configManager.getConfig('azure:storageAccountName');
-        attachmentInfo.containerName = configManager.getConfig('azure:storageContainerName');
+        attachmentInfo.accountName = configManager.getConfig(
+          'azure:storageAccountName',
+        );
+        attachmentInfo.containerName = configManager.getConfig(
+          'azure:storageContainerName',
+        );
         break;
       default:
     }
@@ -598,14 +670,20 @@ export class G2GTransferReceiverService implements Receiver {
 
   public async createTransferKey(appSiteUrlOrigin: string): Promise<string> {
     const uuid = new MongooseTypes.ObjectId().toString();
-    const transferKeyString = TransferKey.generateKeyString(uuid, appSiteUrlOrigin);
+    const transferKeyString = TransferKey.generateKeyString(
+      uuid,
+      appSiteUrlOrigin,
+    );
 
     // Save TransferKey document
-    let tkd;
+    let tkd: any;
     try {
-      tkd = await TransferKeyModel.create({ _id: uuid, keyString: transferKeyString, key: uuid });
-    }
-    catch (err) {
+      tkd = await TransferKeyModel.create({
+        _id: uuid,
+        keyString: transferKeyString,
+        key: uuid,
+      });
+    } catch (err) {
       logger.error(err);
       throw err;
     }
@@ -614,31 +692,50 @@ export class G2GTransferReceiverService implements Receiver {
   }
 
   public getImportSettingMap(
-      innerFileStats: any[],
-      optionsMap: { [key: string]: GrowiArchiveImportOption; },
-      operatorUserId: string,
+    innerFileStats: any[],
+    optionsMap: { [key: string]: GrowiArchiveImportOption },
+    operatorUserId: string,
   ): Map<string, ImportSettings> {
     const importSettingsMap = new Map<string, ImportSettings>();
     innerFileStats.forEach(({ fileName, collectionName }) => {
-      const options = new GrowiArchiveImportOption(collectionName, undefined, optionsMap[collectionName]);
-
-      if (collectionName === 'configs' && options.mode !== ImportMode.flushAndInsert) {
-        throw new Error('`flushAndInsert` is only available as an import setting for configs collection');
+      const options = new GrowiArchiveImportOption(
+        collectionName,
+        undefined,
+        optionsMap[collectionName],
+      );
+
+      if (
+        collectionName === 'configs' &&
+        options.mode !== ImportMode.flushAndInsert
+      ) {
+        throw new Error(
+          '`flushAndInsert` is only available as an import setting for configs collection',
+        );
       }
       if (collectionName === 'pages' && options.mode === ImportMode.insert) {
-        throw new Error('`insert` is not available as an import setting for pages collection');
+        throw new Error(
+          '`insert` is not available as an import setting for pages collection',
+        );
       }
       if (collectionName === 'attachmentFiles.chunks') {
-        throw new Error('`attachmentFiles.chunks` must not be transferred. Please omit it from request body `collections`.');
+        throw new Error(
+          '`attachmentFiles.chunks` must not be transferred. Please omit it from request body `collections`.',
+        );
       }
       if (collectionName === 'attachmentFiles.files') {
-        throw new Error('`attachmentFiles.files` must not be transferred. Please omit it from request body `collections`.');
+        throw new Error(
+          '`attachmentFiles.files` must not be transferred. Please omit it from request body `collections`.',
+        );
       }
 
       const importSettings: ImportSettings = {
         mode: options.mode,
         jsonFileName: fileName,
-        overwriteParams: generateOverwriteParams(collectionName, operatorUserId, options),
+        overwriteParams: generateOverwriteParams(
+          collectionName,
+          operatorUserId,
+          options,
+        ),
       };
       importSettingsMap.set(collectionName, importSettings);
     });
@@ -647,14 +744,15 @@ export class G2GTransferReceiverService implements Receiver {
   }
 
   public async importCollections(
-      collections: string[],
-      importSettingsMap: Map<string, ImportSettings>,
-      sourceGROWIUploadConfigs: FileUploadConfigs,
+    collections: string[],
+    importSettingsMap: Map<string, ImportSettings>,
+    sourceGROWIUploadConfigs: FileUploadConfigs,
   ): Promise<void> {
     const { appService } = this.crowi;
     const importService = getImportService();
     /** whether to keep current file upload configs */
-    const shouldKeepUploadConfigs = configManager.getConfig('app:fileUploadType') !== 'none';
+    const shouldKeepUploadConfigs =
+      configManager.getConfig('app:fileUploadType') !== 'none';
 
     if (shouldKeepUploadConfigs) {
       /** cache file upload configs */
@@ -666,8 +764,7 @@ export class G2GTransferReceiverService implements Receiver {
       // restore file upload config from cache
       await configManager.removeConfigs(UPLOAD_CONFIG_KEYS);
       await configManager.updateConfigs(fileUploadConfigs);
-    }
-    else {
+    } else {
       // import mongo collections(overwrites file uplaod configs)
       await importService.import(collections, importSettingsMap);
 
@@ -680,25 +777,33 @@ export class G2GTransferReceiverService implements Receiver {
   }
 
   public async getFileUploadConfigs(): Promise<FileUploadConfigs> {
-    const fileUploadConfigs = Object.fromEntries(UPLOAD_CONFIG_KEYS.map((key) => {
-      return [key, configManager.getConfig(key, ConfigSource.db)];
-    })) as FileUploadConfigs;
+    const fileUploadConfigs = Object.fromEntries(
+      UPLOAD_CONFIG_KEYS.map((key) => {
+        return [key, configManager.getConfig(key, ConfigSource.db)];
+      }),
+    ) as FileUploadConfigs;
 
     return fileUploadConfigs;
   }
 
-  public async updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void> {
+  public async updateFileUploadConfigs(
+    fileUploadConfigs: FileUploadConfigs,
+  ): Promise<void> {
     const { appService } = this.crowi;
 
-    await configManager.removeConfigs(Object.keys(fileUploadConfigs) as ConfigKey[]);
+    await configManager.removeConfigs(
+      Object.keys(fileUploadConfigs) as ConfigKey[],
+    );
     await configManager.updateConfigs(fileUploadConfigs);
     await this.crowi.setUpFileUpload(true);
     await appService.setupAfterInstall();
   }
 
-  public async receiveAttachment(content: ReadStream, attachmentMap): Promise<void> {
+  public async receiveAttachment(
+    content: ReadStream,
+    attachmentMap,
+  ): Promise<void> {
     const { fileUploadService } = this.crowi;
     return fileUploadService.uploadAttachment(content, attachmentMap);
   }
-
 }

+ 18 - 17
apps/app/src/server/service/i18next.ts

@@ -1,9 +1,8 @@
-import path from 'path';
-
 import type { Lang } from '@growi/core';
-import type { InitOptions, TFunction, i18n } from 'i18next';
+import type { InitOptions, i18n, TFunction } from 'i18next';
 import { createInstance } from 'i18next';
 import resourcesToBackend from 'i18next-resources-to-backend';
+import path from 'path';
 
 import * as i18nextConfig from '^/config/i18next.config';
 
@@ -11,18 +10,20 @@ import { resolveFromRoot } from '~/server/util/project-dir-utils';
 
 import { configManager } from './config-manager';
 
+const relativePathToLocalesRoot = path.relative(
+  __dirname,
+  resolveFromRoot('public/static/locales'),
+);
 
-const relativePathToLocalesRoot = path.relative(__dirname, resolveFromRoot('public/static/locales'));
-
-const initI18next = async(overwriteOpts: InitOptions) => {
+const initI18next = async (overwriteOpts: InitOptions) => {
   const i18nInstance = createInstance();
   await i18nInstance
     .use(
-      resourcesToBackend(
-        (language: string, namespace: string) => {
-          return import(path.join(relativePathToLocalesRoot, language, `${namespace}.json`));
-        },
-      ),
+      resourcesToBackend((language: string, namespace: string) => {
+        return import(
+          path.join(relativePathToLocalesRoot, language, `${namespace}.json`)
+        );
+      }),
     )
     .init({
       ...i18nextConfig.initOptions,
@@ -32,14 +33,14 @@ const initI18next = async(overwriteOpts: InitOptions) => {
 };
 
 type Translation = {
-  t: TFunction,
-  i18n: i18n
-}
+  t: TFunction;
+  i18n: i18n;
+};
 
 type Opts = {
-  lang?: Lang,
-  ns?: string | readonly string[],
-}
+  lang?: Lang;
+  ns?: string | readonly string[];
+};
 
 export async function getTranslation(opts?: Opts): Promise<Translation> {
   const globalLang = configManager.getConfig('app:globalLang');

+ 92 - 58
apps/app/src/server/service/in-app-notification.ts

@@ -1,9 +1,7 @@
-import type {
-  HasObjectId, IUser, IPage,
-} from '@growi/core';
+import type { HasObjectId, IPage, IUser } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import { subDays } from 'date-fns/subDays';
-import type { Types, FilterQuery, UpdateQuery } from 'mongoose';
+import type { FilterQuery, Types, UpdateQuery } from 'mongoose';
 
 import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import { AllEssentialActions } from '~/interfaces/activity';
@@ -11,27 +9,21 @@ import type { PaginateResult } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import type { ActivityDocument } from '~/server/models/activity';
 import type { InAppNotificationDocument } from '~/server/models/in-app-notification';
-import {
-  InAppNotification,
-} from '~/server/models/in-app-notification';
+import { InAppNotification } from '~/server/models/in-app-notification';
 import InAppNotificationSettings from '~/server/models/in-app-notification-settings';
 import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
-
-
 import { generateSnapshot } from './in-app-notification/in-app-notification-utils';
-import { preNotifyService, type PreNotify } from './pre-notify';
-import { RoomPrefix, getRoomNameWithId } from './socket-io/helper';
-
+import { type PreNotify, preNotifyService } from './pre-notify';
+import { getRoomNameWithId, RoomPrefix } from './socket-io/helper';
 
 const { STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
 const logger = loggerFactory('growi:service:inAppNotification');
 
 export default class InAppNotificationService {
-
   crowi!: Crowi;
 
   socketIoService!: any;
@@ -52,42 +44,58 @@ export default class InAppNotificationService {
   }
 
   initActivityEventListeners(): void {
-    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IUser | IPage | IPageBulkExportJob, preNotify: PreNotify) => {
-      try {
-        const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
-        if (shouldNotification) {
-          await this.createInAppNotification(activity, target, preNotify);
+    this.activityEvent.on(
+      'updated',
+      async (
+        activity: ActivityDocument,
+        target: IUser | IPage | IPageBulkExportJob,
+        preNotify: PreNotify,
+      ) => {
+        try {
+          const shouldNotification =
+            activity != null &&
+            target != null &&
+            (AllEssentialActions as ReadonlyArray<string>).includes(
+              activity.action,
+            );
+          if (shouldNotification) {
+            await this.createInAppNotification(activity, target, preNotify);
+          }
+        } catch (err) {
+          logger.error('Create InAppNotification failed', err);
         }
-      }
-      catch (err) {
-        logger.error('Create InAppNotification failed', err);
-      }
-    });
+      },
+    );
   }
 
-  emitSocketIo = async(targetUsers) => {
+  emitSocketIo = async (targetUsers) => {
     if (this.socketIoService.isInitialized) {
-      targetUsers.forEach(async(userId) => {
-
+      targetUsers.forEach(async (userId) => {
         // emit to the room for each user
-        await this.socketIoService.getDefaultSocket()
+        await this.socketIoService
+          .getDefaultSocket()
           .in(getRoomNameWithId(RoomPrefix.USER, userId))
           .emit('notificationUpdated');
       });
     }
   };
 
-  upsertByActivity = async function(
-      users: Types.ObjectId[], activity: ActivityDocument, snapshot: string, createdAt?: Date | null,
-  ): Promise<void> {
-    const {
-      _id: activityId, targetModel, target, action,
-    } = activity;
+  upsertByActivity = async (
+    users: Types.ObjectId[],
+    activity: ActivityDocument,
+    snapshot: string,
+    createdAt?: Date | null,
+  ): Promise<void> => {
+    const { _id: activityId, targetModel, target, action } = activity;
     const now = createdAt || Date.now();
     const lastWeek = subDays(now, 7);
     const operations = users.map((user) => {
       const filter: FilterQuery<InAppNotificationDocument> = {
-        user, target, action, createdAt: { $gt: lastWeek }, snapshot,
+        user,
+        target,
+        action,
+        createdAt: { $gt: lastWeek },
+        snapshot,
       };
       const parameters: UpdateQuery<InAppNotificationDocument> = {
         user,
@@ -113,9 +121,13 @@ export default class InAppNotificationService {
     return;
   };
 
-  getLatestNotificationsByUser = async(
-      userId: Types.ObjectId,
-      queryOptions: {offset: number, limit: number, status?: InAppNotificationStatuses},
+  getLatestNotificationsByUser = async (
+    userId: Types.ObjectId,
+    queryOptions: {
+      offset: number;
+      limit: number;
+      status?: InAppNotificationStatuses;
+    },
   ): Promise<PaginateResult<InAppNotificationDocument>> => {
     const { limit, offset, status } = queryOptions;
 
@@ -136,9 +148,7 @@ export default class InAppNotificationService {
             { path: 'user' },
             {
               path: 'target',
-              populate: [
-                { path: 'attachment', strictPopulate: false },
-              ],
+              populate: [{ path: 'attachment', strictPopulate: false }],
             },
             { path: 'activities', populate: { path: 'user' } },
           ],
@@ -146,14 +156,16 @@ export default class InAppNotificationService {
       );
 
       return paginationResult;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error', err);
       throw new Error(err);
     }
   };
 
-  open = async function(user: IUser & HasObjectId, id: Types.ObjectId): Promise<void> {
+  open = async (
+    user: IUser & HasObjectId,
+    id: Types.ObjectId,
+  ): Promise<void> => {
     const query = { _id: id, user: user._id };
     const parameters = { status: STATUS_OPENED };
     const options = { new: true };
@@ -162,7 +174,9 @@ export default class InAppNotificationService {
     return;
   };
 
-  updateAllNotificationsAsOpened = async function(user: IUser & HasObjectId): Promise<void> {
+  updateAllNotificationsAsOpened = async (
+    user: IUser & HasObjectId,
+  ): Promise<void> => {
     const filter = { user: user._id, status: STATUS_UNOPENED };
     const options = { status: STATUS_OPENED };
 
@@ -170,36 +184,54 @@ export default class InAppNotificationService {
     return;
   };
 
-  getUnreadCountByUser = async function(user: Types.ObjectId): Promise<number| undefined> {
+  getUnreadCountByUser = async (
+    user: Types.ObjectId,
+  ): Promise<number | undefined> => {
     const query = { user, status: STATUS_UNOPENED };
 
     try {
       const count = await InAppNotification.countDocuments(query);
 
       return count;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error on getUnreadCountByUser', err);
       throw err;
     }
   };
 
-  createSubscription = async function(userId: Types.ObjectId, pageId: Types.ObjectId, targetRuleName: string): Promise<void> {
+  createSubscription = async (
+    userId: Types.ObjectId,
+    pageId: Types.ObjectId,
+    targetRuleName: string,
+  ): Promise<void> => {
     const query = { userId };
-    const inAppNotificationSettings = await InAppNotificationSettings.findOne(query);
+    const inAppNotificationSettings =
+      await InAppNotificationSettings.findOne(query);
     if (inAppNotificationSettings != null) {
-      const subscribeRule = inAppNotificationSettings.subscribeRules.find(subscribeRule => subscribeRule.name === targetRuleName);
+      const subscribeRule = inAppNotificationSettings.subscribeRules.find(
+        (subscribeRule) => subscribeRule.name === targetRuleName,
+      );
       if (subscribeRule != null && subscribeRule.isEnabled) {
-        await Subscription.subscribeByPageId(userId, pageId, SubscriptionStatusType.SUBSCRIBE);
+        await Subscription.subscribeByPageId(
+          userId,
+          pageId,
+          SubscriptionStatusType.SUBSCRIBE,
+        );
       }
     }
 
     return;
   };
 
-  createInAppNotification = async function(activity: ActivityDocument, target: IUser | IPage | IPageBulkExportJob, preNotify: PreNotify): Promise<void> {
-
-    const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
+  createInAppNotification = async function (
+    activity: ActivityDocument,
+    target: IUser | IPage | IPageBulkExportJob,
+    preNotify: PreNotify,
+  ): Promise<void> {
+    const shouldNotification =
+      activity != null &&
+      target != null &&
+      (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
 
     const targetModel = activity.targetModel;
 
@@ -210,15 +242,17 @@ export default class InAppNotificationService {
 
       await preNotify(props);
 
-      await this.upsertByActivity(props.notificationTargetUsers, activity, snapshot);
+      await this.upsertByActivity(
+        props.notificationTargetUsers,
+        activity,
+        snapshot,
+      );
       await this.emitSocketIo(props.notificationTargetUsers);
-    }
-    else {
+    } else {
       throw Error('no activity to notify');
     }
     return;
   };
-
 }
 
 module.exports = InAppNotificationService;

+ 74 - 47
apps/app/src/server/service/installer.ts

@@ -1,31 +1,25 @@
-import path from 'path';
-
-import type {
-  Lang, IPage, IUser,
-} from '@growi/core';
+import type { IPage, IUser, Lang } from '@growi/core';
 import { addSeconds } from 'date-fns/addSeconds';
 import ExtensibleCustomError from 'extensible-custom-error';
 import fs from 'graceful-fs';
 import mongoose from 'mongoose';
+import path from 'path';
 
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
-
 import { configManager } from './config-manager';
 
 const logger = loggerFactory('growi:service:installer');
 
-export class FailedToCreateAdminUserError extends ExtensibleCustomError {
-}
+export class FailedToCreateAdminUserError extends ExtensibleCustomError {}
 
 export type AutoInstallOptions = {
-  allowGuestMode?: boolean,
-  serverDate?: Date,
-}
+  allowGuestMode?: boolean;
+  serverDate?: Date;
+};
 
 export class InstallerService {
-
   crowi: Crowi;
 
   constructor(crowi: Crowi) {
@@ -41,25 +35,26 @@ export class InstallerService {
 
     try {
       await searchService.rebuildIndex();
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Rebuild index failed', err);
     }
   }
 
-  private async createPage(filePath, pagePath): Promise<IPage|undefined> {
+  private async createPage(filePath, pagePath): Promise<IPage | undefined> {
     const { pageService } = this.crowi;
 
     try {
       const markdown = fs.readFileSync(filePath);
       return pageService.forceCreateBySystem(pagePath, markdown.toString(), {});
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
     }
   }
 
-  private async createInitialPages(lang: Lang, initialPagesCreatedAt?: Date): Promise<any> {
+  private async createInitialPages(
+    lang: Lang,
+    initialPagesCreatedAt?: Date,
+  ): Promise<any> {
     const { localeDir } = this.crowi;
     // create /Sandbox/*
     /*
@@ -68,10 +63,22 @@ export class InstallerService {
      *   2. avoid difference for order in VRT
      */
     await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox');
-    await this.createPage(path.join(localeDir, lang, 'sandbox-markdown.md'), '/Sandbox/Markdown');
-    await this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap5.md'), '/Sandbox/Bootstrap5');
-    await this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams');
-    await this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math');
+    await this.createPage(
+      path.join(localeDir, lang, 'sandbox-markdown.md'),
+      '/Sandbox/Markdown',
+    );
+    await this.createPage(
+      path.join(localeDir, lang, 'sandbox-bootstrap5.md'),
+      '/Sandbox/Bootstrap5',
+    );
+    await this.createPage(
+      path.join(localeDir, lang, 'sandbox-diagrams.md'),
+      '/Sandbox/Diagrams',
+    );
+    await this.createPage(
+      path.join(localeDir, lang, 'sandbox-math.md'),
+      '/Sandbox/Math',
+    );
 
     // update createdAt and updatedAt fields of all pages
     if (initialPagesCreatedAt != null) {
@@ -81,8 +88,13 @@ export class InstallerService {
         const Page = mongoose.model('Page') as any;
 
         // Increment timestamp to avoid difference for order in VRT
-        const pagePaths = ['/Sandbox', '/Sandbox/Bootstrap4', '/Sandbox/Diagrams', '/Sandbox/Math'];
-        const promises = pagePaths.map(async(path: string, idx: number) => {
+        const pagePaths = [
+          '/Sandbox',
+          '/Sandbox/Bootstrap4',
+          '/Sandbox/Diagrams',
+          '/Sandbox/Math',
+        ];
+        const promises = pagePaths.map(async (path: string, idx: number) => {
           const date = addSeconds(initialPagesCreatedAt, idx);
           return Page.update(
             { path },
@@ -93,16 +105,14 @@ export class InstallerService {
           );
         });
         await Promise.all(promises);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Failed to update createdAt', err);
       }
     }
 
     try {
       await this.initSearchIndex();
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Failed to build Elasticsearch Indices', err);
     }
   }
@@ -110,20 +120,37 @@ export class InstallerService {
   /**
    * Execute only once for installing application
    */
-  private async initDB(globalLang: Lang, options?: AutoInstallOptions): Promise<void> {
-    await configManager.updateConfigs({
-      'app:installed': true,
-      'app:fileUpload': true,
-      'app:isV5Compatible': true,
-      'app:globalLang': globalLang,
-    }, { skipPubsub: true });
+  private async initDB(
+    globalLang: Lang,
+    options?: AutoInstallOptions,
+  ): Promise<void> {
+    await configManager.updateConfigs(
+      {
+        'app:installed': true,
+        'app:fileUpload': true,
+        'app:isV5Compatible': true,
+        'app:globalLang': globalLang,
+      },
+      { skipPubsub: true },
+    );
 
     if (options?.allowGuestMode) {
-      await configManager.updateConfig('security:restrictGuestMode', 'Readonly', { skipPubsub: true });
+      await configManager.updateConfig(
+        'security:restrictGuestMode',
+        'Readonly',
+        { skipPubsub: true },
+      );
     }
   }
 
-  async install(firstAdminUserToSave: Pick<IUser, 'name' | 'username' | 'email' | 'password'>, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
+  async install(
+    firstAdminUserToSave: Pick<
+      IUser,
+      'name' | 'username' | 'email' | 'password'
+    >,
+    globalLang: Lang,
+    options?: AutoInstallOptions,
+  ): Promise<IUser> {
     await this.initDB(globalLang, options);
 
     const User = mongoose.model<IUser, { createUser }>('User');
@@ -134,30 +161,30 @@ export class InstallerService {
         path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
         '/',
       );
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw err;
     }
 
     try {
       // create first admin user
-      const {
-        name, username, email, password,
-      } = firstAdminUserToSave;
-      const adminUser = await User.createUser(name, username, email, password, globalLang);
+      const { name, username, email, password } = firstAdminUserToSave;
+      const adminUser = await User.createUser(
+        name,
+        username,
+        email,
+        password,
+        globalLang,
+      );
       await (adminUser as any).asyncGrantAdmin();
 
       // create initial pages
       await this.createInitialPages(globalLang, options?.serverDate);
 
       return adminUser;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw new FailedToCreateAdminUserError(err);
     }
-
   }
-
 }

+ 81 - 51
apps/app/src/server/service/ldap.ts

@@ -4,25 +4,23 @@ import loggerFactory from '~/utils/logger';
 
 import { configManager } from './config-manager';
 
-
 const logger = loggerFactory('growi:service:ldap-service');
 
 // @types/ldapjs is outdated, and SearchResultEntry does not exist.
 // Declare it manually in the meantime.
 export interface SearchResultEntry {
-  objectName: string // DN
+  objectName: string; // DN
   attributes: {
-    type: string,
-    values: string | string[]
-  }[]
+    type: string;
+    values: string | string[];
+  }[];
 }
 
 /**
  * Service to connect to LDAP server.
  * User auth using LDAP is done with PassportService, not here.
-*/
+ */
 class LdapService {
-
   client: ldap.Client | null;
 
   searchBase: string;
@@ -33,7 +31,9 @@ class LdapService {
    * @param {string} userBindPassword Necessary when bind type is user bind
    */
   initClient(userBindUsername?: string, userBindPassword?: string): void {
-    const serverUrl = configManager.getConfig('security:passport-ldap:serverUrl');
+    const serverUrl = configManager.getConfig(
+      'security:passport-ldap:serverUrl',
+    );
 
     // parse serverUrl
     // see: https://regex101.com/r/0tuYBB/1
@@ -62,7 +62,9 @@ class LdapService {
     const client = this.client;
     if (client == null) throw new Error('LDAP client is not initialized');
 
-    const isLdapEnabled = configManager.getConfig('security:passport-ldap:isEnabled');
+    const isLdapEnabled = configManager.getConfig(
+      'security:passport-ldap:isEnabled',
+    );
     if (!isLdapEnabled) {
       const notEnabledMessage = 'LDAP is not enabled';
       logger.error(notEnabledMessage);
@@ -70,15 +72,21 @@ class LdapService {
     }
 
     // get configurations
-    const isUserBind = configManager.getConfig('security:passport-ldap:isUserBind');
-    const bindDN = configManager.getConfig('security:passport-ldap:bindDN') ?? '';
-    const bindCredentials = configManager.getConfig('security:passport-ldap:bindDNPassword') ?? '';
+    const isUserBind = configManager.getConfig(
+      'security:passport-ldap:isUserBind',
+    );
+    const bindDN =
+      configManager.getConfig('security:passport-ldap:bindDN') ?? '';
+    const bindCredentials =
+      configManager.getConfig('security:passport-ldap:bindDNPassword') ?? '';
 
     // user bind
-    const fixedBindDN = (isUserBind)
+    const fixedBindDN = isUserBind
       ? bindDN.replace(/{{username}}/, userBindUsername)
       : bindDN;
-    const fixedBindCredentials = (isUserBind) ? userBindPassword : bindCredentials;
+    const fixedBindCredentials = isUserBind
+      ? userBindPassword
+      : bindCredentials;
 
     return new Promise<void>((resolve, reject) => {
       client.bind(fixedBindDN, fixedBindCredentials, (err) => {
@@ -97,7 +105,11 @@ class LdapService {
    * @param {string} base Base DN to execute search on
    * @returns {SearchEntry[]} Search result. Default scope is set to 'sub'.
    */
-  search(filter?: string, base?: string, scope: 'sub' | 'base' | 'one' = 'sub'): Promise<SearchResultEntry[]> {
+  search(
+    filter?: string,
+    base?: string,
+    scope: 'sub' | 'base' | 'one' = 'sub',
+  ): Promise<SearchResultEntry[]> {
     const client = this.client;
     if (client == null) throw new Error('LDAP client is not initialized');
 
@@ -109,36 +121,43 @@ class LdapService {
         reject(err);
       });
 
-      client.search(base || this.searchBase, {
-        scope, filter, paged: true, sizeLimit: 200,
-      }, (err, res) => {
-        if (err != null) {
-          reject(err);
-        }
-
-        // @types/ldapjs is outdated, and pojo property (type SearchResultEntry) does not exist.
-        // Typecast to manually declared SearchResultEntry in the meantime.
-        res.on('searchEntry', (entry: any) => {
-          const pojo = entry?.pojo as SearchResultEntry;
-          searchResults.push(pojo);
-        });
-        res.on('error', (err) => {
-          if (err instanceof NoSuchObjectError) {
-            resolve([]);
-          }
-          else {
+      client.search(
+        base || this.searchBase,
+        {
+          scope,
+          filter,
+          paged: true,
+          sizeLimit: 200,
+        },
+        (err, res) => {
+          if (err != null) {
             reject(err);
           }
-        });
-        res.on('end', (result) => {
-          if (result?.status === 0) {
-            resolve(searchResults);
-          }
-          else {
-            reject(new Error(`LDAP search failed: status code ${result?.status}`));
-          }
-        });
-      });
+
+          // @types/ldapjs is outdated, and pojo property (type SearchResultEntry) does not exist.
+          // Typecast to manually declared SearchResultEntry in the meantime.
+          res.on('searchEntry', (entry: any) => {
+            const pojo = entry?.pojo as SearchResultEntry;
+            searchResults.push(pojo);
+          });
+          res.on('error', (err) => {
+            if (err instanceof NoSuchObjectError) {
+              resolve([]);
+            } else {
+              reject(err);
+            }
+          });
+          res.on('end', (result) => {
+            if (result?.status === 0) {
+              resolve(searchResults);
+            } else {
+              reject(
+                new Error(`LDAP search failed: status code ${result?.status}`),
+              );
+            }
+          });
+        },
+      );
     });
   }
 
@@ -146,13 +165,23 @@ class LdapService {
     return this.search(undefined, this.getGroupSearchBase());
   }
 
-  getArrayValFromSearchResultEntry(entry: SearchResultEntry, attributeType: string | undefined): string[] {
-    const values: string | string[] = entry.attributes.find(attribute => attribute.type === attributeType)?.values || [];
+  getArrayValFromSearchResultEntry(
+    entry: SearchResultEntry,
+    attributeType: string | undefined,
+  ): string[] {
+    const values: string | string[] =
+      entry.attributes.find((attribute) => attribute.type === attributeType)
+        ?.values || [];
     return typeof values === 'string' ? [values] : values;
   }
 
-  getStringValFromSearchResultEntry(entry: SearchResultEntry, attributeType: string | undefined): string | undefined {
-    const values: string | string[] | undefined = entry.attributes.find(attribute => attribute.type === attributeType)?.values;
+  getStringValFromSearchResultEntry(
+    entry: SearchResultEntry,
+    attributeType: string | undefined,
+  ): string | undefined {
+    const values: string | string[] | undefined = entry.attributes.find(
+      (attribute) => attribute.type === attributeType,
+    )?.values;
     if (typeof values === 'string' || values == null) {
       return values;
     }
@@ -163,11 +192,12 @@ class LdapService {
   }
 
   getGroupSearchBase(): string {
-    return configManager.getConfig('external-user-group:ldap:groupSearchBase')
-      ?? configManager.getConfig('security:passport-ldap:groupSearchBase')
-      ?? '';
+    return (
+      configManager.getConfig('external-user-group:ldap:groupSearchBase') ??
+      configManager.getConfig('security:passport-ldap:groupSearchBase') ??
+      ''
+    );
   }
-
 }
 
 // export the singleton instance

+ 37 - 31
apps/app/src/server/service/mail.ts

@@ -1,28 +1,24 @@
-import { promisify } from 'util';
-
 import ejs from 'ejs';
 import nodemailer from 'nodemailer';
+import { promisify } from 'util';
 
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
 import S2sMessage from '../models/vo/s2s-message';
-
 import type { IConfigManagerForApp } from './config-manager';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 const logger = loggerFactory('growi:service:mail');
 
-
 type MailConfig = {
-  to?: string,
-  from?: string,
-  text?: string,
-  subject?: string,
-}
+  to?: string;
+  from?: string;
+  text?: string;
+  subject?: string;
+};
 
 class MailService implements S2sMessageHandlable {
-
   appService!: any;
 
   configManager: IConfigManagerForApp;
@@ -57,7 +53,10 @@ class MailService implements S2sMessageHandlable {
       return false;
     }
 
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+    return (
+      this.lastLoadedAt == null ||
+      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
+    );
   }
 
   /**
@@ -75,18 +74,21 @@ class MailService implements S2sMessageHandlable {
     const { s2sMessagingService } = this;
 
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('mailServiceUpdated', { updatedAt: new Date() });
+      const s2sMessage = new S2sMessage('mailServiceUpdated', {
+        updatedAt: new Date(),
+      });
 
       try {
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
     }
   }
 
-
   initialize() {
     const { appService, configManager } = this;
 
@@ -97,15 +99,15 @@ class MailService implements S2sMessageHandlable {
       return;
     }
 
-    const transmissionMethod = configManager.getConfig('mail:transmissionMethod');
+    const transmissionMethod = configManager.getConfig(
+      'mail:transmissionMethod',
+    );
 
     if (transmissionMethod === 'smtp') {
       this.mailer = this.createSMTPClient();
-    }
-    else if (transmissionMethod === 'ses') {
+    } else if (transmissionMethod === 'ses') {
       this.mailer = this.createSESClient();
-    }
-    else {
+    } else {
       this.mailer = null;
     }
 
@@ -130,7 +132,8 @@ class MailService implements S2sMessageHandlable {
       if (host == null || port == null) {
         return null;
       }
-      option = { // eslint-disable-line no-param-reassign
+      option = {
+        // eslint-disable-line no-param-reassign
         host,
         port,
       };
@@ -159,11 +162,14 @@ class MailService implements S2sMessageHandlable {
 
     if (!option) {
       const accessKeyId = configManager.getConfig('mail:sesAccessKeyId');
-      const secretAccessKey = configManager.getConfig('mail:sesSecretAccessKey');
+      const secretAccessKey = configManager.getConfig(
+        'mail:sesSecretAccessKey',
+      );
       if (accessKeyId == null || secretAccessKey == null) {
         return null;
       }
-      option = { // eslint-disable-line no-param-reassign
+      option = {
+        // eslint-disable-line no-param-reassign
         accessKeyId,
         secretAccessKey,
       };
@@ -193,21 +199,21 @@ class MailService implements S2sMessageHandlable {
 
   async send(config) {
     if (this.mailer == null) {
-      throw new Error('Mailer is not completed to set up. Please set up SMTP or AWS setting.');
+      throw new Error(
+        'Mailer is not completed to set up. Please set up SMTP or AWS setting.',
+      );
     }
 
-    const renderFilePromisified = promisify<string, ejs.Data, string>(ejs.renderFile);
+    const renderFilePromisified = promisify<string, ejs.Data, string>(
+      ejs.renderFile,
+    );
 
     const templateVars = config.vars || {};
-    const output = await renderFilePromisified(
-      config.template,
-      templateVars,
-    );
+    const output = await renderFilePromisified(config.template, templateVars);
 
     config.text = output;
     return this.mailer.sendMail(this.setupMailConfig(config));
   }
-
 }
 
 module.exports = MailService;

Разница между файлами не показана из-за своего большого размера
+ 513 - 206
apps/app/src/server/service/page-grant.ts


+ 97 - 40
apps/app/src/server/service/page-operation.ts

@@ -2,8 +2,11 @@ import type { IPage } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import mongoose from 'mongoose';
 
-import type { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
-import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
+import type {
+  IPageOperationProcessData,
+  IPageOperationProcessInfo,
+} from '~/interfaces/page-operation';
+import { PageActionStage, PageActionType } from '~/interfaces/page-operation';
 import type { PageOperationDocument } from '~/server/models/page-operation';
 import PageOperation from '~/server/models/page-operation';
 import loggerFactory from '~/utils/logger';
@@ -15,26 +18,35 @@ import { collectAncestorPaths } from '../util/collect-ancestor-paths';
 
 const logger = loggerFactory('growi:services:page-operation');
 
-const {
-  isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage,
-} = pagePathUtils;
+const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } =
+  pagePathUtils;
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 const {
-  Create, Update,
-  Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
+  Create,
+  Update,
+  Duplicate,
+  Delete,
+  DeleteCompletely,
+  Revert,
+  NormalizeParent,
 } = PageActionType;
 
 export interface IPageOperationService {
-  generateProcessInfo(pageOperations: PageOperationDocument[]): IPageOperationProcessInfo;
-  canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean>;
+  generateProcessInfo(
+    pageOperations: PageOperationDocument[],
+  ): IPageOperationProcessInfo;
+  canOperate(
+    isRecursively: boolean,
+    fromPathToOp: string | null,
+    toPathToOp: string | null,
+  ): Promise<boolean>;
   autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout;
   clearAutoUpdateInterval(timerObj: NodeJS.Timeout): void;
   getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[];
 }
 
 class PageOperationService implements IPageOperationService {
-
   crowi: Crowi;
 
   constructor(crowi: Crowi) {
@@ -43,9 +55,20 @@ class PageOperationService implements IPageOperationService {
 
   async init(): Promise<void> {
     // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
-    const types = [Create, Update, Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
+    const types = [
+      Create,
+      Update,
+      Duplicate,
+      Delete,
+      DeleteCompletely,
+      Revert,
+      NormalizeParent,
+    ];
     await PageOperation.deleteByActionTypes(types);
-    await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
+    await PageOperation.deleteMany({
+      actionType: PageActionType.Rename,
+      actionStage: PageActionStage.Main,
+    });
   }
 
   /**
@@ -53,12 +76,13 @@ class PageOperationService implements IPageOperationService {
    */
   async afterExpressServerReady(): Promise<void> {
     try {
-      const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
-        .sort({ createdAt: 'asc' });
+      const pageOps = await PageOperation.find({
+        actionType: PageActionType.Rename,
+        actionStage: PageActionStage.Sub,
+      }).sort({ createdAt: 'asc' });
       // execute rename operation
       await this.executeAllRenameOperationBySystem(pageOps);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
     }
   }
@@ -66,13 +90,14 @@ class PageOperationService implements IPageOperationService {
   /**
    * Execute renameSubOperation on every page operation for rename ordered by createdAt ASC
    */
-  private async executeAllRenameOperationBySystem(pageOps: PageOperationDocument[]): Promise<void> {
+  private async executeAllRenameOperationBySystem(
+    pageOps: PageOperationDocument[],
+  ): Promise<void> {
     if (pageOps.length === 0) return;
 
     const Page = mongoose.model<IPage, PageModel>('Page');
 
     for await (const pageOp of pageOps) {
-
       const renamedPage = await Page.findById(pageOp.page._id);
       if (renamedPage == null) {
         logger.warn('operating page is not found');
@@ -80,7 +105,10 @@ class PageOperationService implements IPageOperationService {
       }
 
       // rename
-      await this.crowi.pageService.resumeRenameSubOperation(renamedPage, pageOp);
+      await this.crowi.pageService.resumeRenameSubOperation(
+        renamedPage,
+        pageOp,
+      );
     }
   }
 
@@ -91,48 +119,64 @@ class PageOperationService implements IPageOperationService {
    * @param toPathToOp The path to operate to
    * @returns boolean
    */
-  async canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean> {
+  async canOperate(
+    isRecursively: boolean,
+    fromPathToOp: string | null,
+    toPathToOp: string | null,
+  ): Promise<boolean> {
     const pageOperations = await PageOperation.find();
 
     if (pageOperations.length === 0) {
       return true;
     }
 
-    const fromPaths = pageOperations.map(op => op.fromPath).filter((p): p is string => p != null);
-    const toPaths = pageOperations.map(op => op.toPath).filter((p): p is string => p != null);
+    const fromPaths = pageOperations
+      .map((op) => op.fromPath)
+      .filter((p): p is string => p != null);
+    const toPaths = pageOperations
+      .map((op) => op.toPath)
+      .filter((p): p is string => p != null);
 
     if (isRecursively) {
       if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
-        const fromFlag = fromPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
+        const fromFlag = fromPaths.some((p) =>
+          isEitherOfPathAreaOverlap(p, fromPathToOp),
+        );
         if (fromFlag) return false;
 
-        const toFlag = toPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
+        const toFlag = toPaths.some((p) =>
+          isEitherOfPathAreaOverlap(p, fromPathToOp),
+        );
         if (toFlag) return false;
       }
 
       if (toPathToOp != null && !isTrashPage(toPathToOp)) {
-        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        const fromFlag = fromPaths.some((p) =>
+          isPathAreaOverlap(p, toPathToOp),
+        );
         if (fromFlag) return false;
 
-        const toFlag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        const toFlag = toPaths.some((p) => isPathAreaOverlap(p, toPathToOp));
         if (toFlag) return false;
       }
-
-    }
-    else {
+    } else {
       if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
-        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
+        const fromFlag = fromPaths.some((p) =>
+          isPathAreaOverlap(p, fromPathToOp),
+        );
         if (fromFlag) return false;
 
-        const toFlag = toPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
+        const toFlag = toPaths.some((p) => isPathAreaOverlap(p, fromPathToOp));
         if (toFlag) return false;
       }
 
       if (toPathToOp != null && !isTrashPage(toPathToOp)) {
-        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        const fromFlag = fromPaths.some((p) =>
+          isPathAreaOverlap(p, toPathToOp),
+        );
         if (fromFlag) return false;
 
-        const toFlag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        const toFlag = toPaths.some((p) => isPathAreaOverlap(p, toPathToOp));
         if (toFlag) return false;
       }
     }
@@ -144,7 +188,9 @@ class PageOperationService implements IPageOperationService {
    * Generate object that connects page id with processData of PageOperation.
    * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
    */
-  generateProcessInfo(pageOps: PageOperationDocument[]): IPageOperationProcessInfo {
+  generateProcessInfo(
+    pageOps: PageOperationDocument[],
+  ): IPageOperationProcessInfo {
     const processInfo: IPageOperationProcessInfo = {};
 
     pageOps.forEach((pageOp) => {
@@ -154,8 +200,14 @@ class PageOperationService implements IPageOperationService {
       const isProcessable = pageOp.isProcessable();
 
       // processData for processInfo
-      const mainProcessableInfo = pageOp.actionStage === PageActionStage.Main ? { isProcessable } : undefined;
-      const subProcessableInfo = pageOp.actionStage === PageActionStage.Sub ? { isProcessable } : undefined;
+      const mainProcessableInfo =
+        pageOp.actionStage === PageActionStage.Main
+          ? { isProcessable }
+          : undefined;
+      const subProcessableInfo =
+        pageOp.actionStage === PageActionStage.Sub
+          ? { isProcessable }
+          : undefined;
       const processData: IPageOperationProcessData = {
         [actionType]: {
           [PageActionStage.Main]: mainProcessableInfo,
@@ -182,7 +234,7 @@ class PageOperationService implements IPageOperationService {
    */
   autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout {
     // https://github.com/Microsoft/TypeScript/issues/30128#issuecomment-651877225
-    const timerObj = global.setInterval(async() => {
+    const timerObj = global.setInterval(async () => {
       await PageOperation.extendExpiryDate(operationId);
     }, AUTO_UPDATE_INTERVAL_SEC * 1000);
     return timerObj;
@@ -202,11 +254,16 @@ class PageOperationService implements IPageOperationService {
     return Array.from(new Set(toAncestorsPaths.concat(fromAncestorsPaths)));
   }
 
-  async getRenameSubOperationByPageId(pageId: ObjectIdLike): Promise<PageOperationDocument | null> {
-    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': pageId };
+  async getRenameSubOperationByPageId(
+    pageId: ObjectIdLike,
+  ): Promise<PageOperationDocument | null> {
+    const filter = {
+      actionType: PageActionType.Rename,
+      actionStage: PageActionStage.Sub,
+      'page._id': pageId,
+    };
     return PageOperation.findOne(filter);
   }
-
 }
 
 // eslint-disable-next-line import/no-mutable-exports

+ 60 - 56
apps/app/src/server/service/passport.spec.ts

@@ -8,10 +8,9 @@ import { configManager } from './config-manager';
 import PassportService from './passport';
 
 describe('PassportService test', () => {
+  let crowiMock: Crowi;
 
-  let crowiMock;
-
-  beforeAll(async() => {
+  beforeAll(async () => {
     crowiMock = mock<Crowi>({
       event: vi.fn().mockImplementation((eventName) => {
         if (eventName === 'user') {
@@ -24,71 +23,76 @@ describe('PassportService test', () => {
   });
 
   describe('verifySAMLResponseByABLCRule()', () => {
-
     const passportService = new PassportService(crowiMock);
 
     let getConfigSpy: MockInstance<typeof configManager.getConfig>;
-    let extractAttributesFromSAMLResponseSpy: MockInstance<typeof passportService.extractAttributesFromSAMLResponse>;
+    let extractAttributesFromSAMLResponseSpy: MockInstance<
+      typeof passportService.extractAttributesFromSAMLResponse
+    >;
 
-    beforeEach(async() => {
+    beforeEach(async () => {
       // prepare spy for ConfigManager.getConfig
       getConfigSpy = vi.spyOn(configManager, 'getConfig');
       // prepare spy for extractAttributesFromSAMLResponse method
-      extractAttributesFromSAMLResponseSpy = vi.spyOn(passportService, 'extractAttributesFromSAMLResponse');
+      extractAttributesFromSAMLResponseSpy = vi.spyOn(
+        passportService,
+        'extractAttributesFromSAMLResponse',
+      );
     });
 
     /* eslint-disable indent */
     let i = 0;
     describe.each`
-      conditionId | departments   | positions     | ruleStr                                                         | expected
-      ${i++}      | ${undefined}  | ${undefined}  | ${' '}                                                          | ${true}
-      ${i++}      | ${undefined}  | ${undefined}  | ${'Department: A'}                                              | ${false}
-      ${i++}      | ${[]}         | ${['Leader']} | ${'Position'}                                                   | ${true}
-      ${i++}      | ${[]}         | ${['Leader']} | ${'Position: Leader'}                                           | ${true}
-      ${i++}      | ${['A']}      | ${[]}         | ${'Department: A || Department: B && Position: Leader'}         | ${true}
-      ${i++}      | ${['B']}      | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}         | ${true}
-      ${i++}      | ${['A', 'C']} | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}         | ${true}
-      ${i++}      | ${['B', 'C']} | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}         | ${true}
-      ${i++}      | ${[]}         | ${[]}         | ${'Department: A || Department: B && Position: Leader'}         | ${false}
-      ${i++}      | ${['C']}      | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}         | ${false}
-      ${i++}      | ${['A']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'}       | ${true}
-      ${i++}      | ${['B']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'}       | ${true}
-      ${i++}      | ${['C']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'}       | ${false}
-      ${i++}      | ${['A', 'B']} | ${[]}         | ${'(Department: A || Department: B) && Position: Leader'}       | ${false}
-      ${i++}      | ${['A']}      | ${[]}         | ${'Department: A NOT Position: Leader'}                         | ${true}
-      ${i++}      | ${['A']}      | ${['Leader']} | ${'Department: A NOT Position: Leader'}                         | ${false}
-      ${i++}      | ${[]}         | ${['Leader']} | ${'Department: A OR (Position NOT Position: User)'}             | ${true}
-      ${i++}      | ${[]}         | ${['User']}   | ${'Department: A OR (Position NOT Position: User)'}             | ${false}
-    `('to be $expected under rule="$ruleStr"', ({
-      conditionId, departments, positions, ruleStr, expected,
-    }) => {
-      test(`when conditionId=${conditionId}`, async() => {
-        const responseMock = {};
-
-        // setup mock implementation
-        getConfigSpy.mockImplementation((key) => {
-          if (key === 'security:passport-saml:ABLCRule') {
-            return ruleStr;
-          }
-          throw new Error('Unexpected behavior.');
-        });
-        extractAttributesFromSAMLResponseSpy.mockImplementation((response) => {
-          if (response !== responseMock) {
-            throw new Error('Unexpected args.');
-          }
-          return {
-            Department: departments,
-            Position: positions,
-          };
+      conditionId | departments   | positions     | ruleStr                                                   | expected
+      ${i++}      | ${undefined}  | ${undefined}  | ${' '}                                                    | ${true}
+      ${i++}      | ${undefined}  | ${undefined}  | ${'Department: A'}                                        | ${false}
+      ${i++}      | ${[]}         | ${['Leader']} | ${'Position'}                                             | ${true}
+      ${i++}      | ${[]}         | ${['Leader']} | ${'Position: Leader'}                                     | ${true}
+      ${i++}      | ${['A']}      | ${[]}         | ${'Department: A || Department: B && Position: Leader'}   | ${true}
+      ${i++}      | ${['B']}      | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}   | ${true}
+      ${i++}      | ${['A', 'C']} | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}   | ${true}
+      ${i++}      | ${['B', 'C']} | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}   | ${true}
+      ${i++}      | ${[]}         | ${[]}         | ${'Department: A || Department: B && Position: Leader'}   | ${false}
+      ${i++}      | ${['C']}      | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}   | ${false}
+      ${i++}      | ${['A']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'} | ${true}
+      ${i++}      | ${['B']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'} | ${true}
+      ${i++}      | ${['C']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'} | ${false}
+      ${i++}      | ${['A', 'B']} | ${[]}         | ${'(Department: A || Department: B) && Position: Leader'} | ${false}
+      ${i++}      | ${['A']}      | ${[]}         | ${'Department: A NOT Position: Leader'}                   | ${true}
+      ${i++}      | ${['A']}      | ${['Leader']} | ${'Department: A NOT Position: Leader'}                   | ${false}
+      ${i++}      | ${[]}         | ${['Leader']} | ${'Department: A OR (Position NOT Position: User)'}       | ${true}
+      ${i++}      | ${[]}         | ${['User']}   | ${'Department: A OR (Position NOT Position: User)'}       | ${false}
+    `(
+      'to be $expected under rule="$ruleStr"',
+      ({ conditionId, departments, positions, ruleStr, expected }) => {
+        test(`when conditionId=${conditionId}`, async () => {
+          const responseMock = {};
+
+          // setup mock implementation
+          getConfigSpy.mockImplementation((key) => {
+            if (key === 'security:passport-saml:ABLCRule') {
+              return ruleStr;
+            }
+            throw new Error('Unexpected behavior.');
+          });
+          extractAttributesFromSAMLResponseSpy.mockImplementation(
+            (response) => {
+              if (response !== responseMock) {
+                throw new Error('Unexpected args.');
+              }
+              return {
+                Department: departments,
+                Position: positions,
+              };
+            },
+          );
+
+          const result =
+            passportService.verifySAMLResponseByABLCRule(responseMock);
+
+          expect(result).toBe(expected);
         });
-
-        const result = passportService.verifySAMLResponseByABLCRule(responseMock);
-
-        expect(result).toBe(expected);
-      });
-    });
-
+      },
+    );
   });
-
-
 });

+ 344 - 174
apps/app/src/server/service/passport.ts

@@ -1,8 +1,11 @@
-import type { IncomingMessage } from 'http';
-
 import axiosRetry from 'axios-retry';
+import type { IncomingMessage } from 'http';
 import luceneQueryParser from 'lucene-query-parser';
-import { Strategy as OidcStrategy, Issuer as OIDCIssuer, custom } from 'openid-client';
+import {
+  custom,
+  Issuer as OIDCIssuer,
+  Strategy as OidcStrategy,
+} from 'openid-client';
 import pRetry from 'p-retry';
 import passport from 'passport';
 import { Strategy as GitHubStrategy } from 'passport-github';
@@ -14,10 +17,10 @@ import { Strategy as SamlStrategy } from 'passport-saml';
 import urljoin from 'url-join';
 
 import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';
-
 import { configManager } from './config-manager';
 import type { ConfigKey } from './config-manager/config-definition';
 import { growiInfoService } from './growi-info';
@@ -25,7 +28,6 @@ import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 const logger = loggerFactory('growi:service:PassportService');
 
-
 interface IncomingMessageWithLdapAccountInfo extends IncomingMessage {
   ldapAccountInfo: any;
 }
@@ -34,11 +36,14 @@ interface IncomingMessageWithLdapAccountInfo extends IncomingMessage {
  * the service class of Passport
  */
 class PassportService implements S2sMessageHandlable {
-
   // see '/lib/form/login.js'
-  static get USERNAME_FIELD() { return 'loginForm[username]' }
+  static get USERNAME_FIELD() {
+    return 'loginForm[username]';
+  }
 
-  static get PASSWORD_FIELD() { return 'loginForm[password]' }
+  static get PASSWORD_FIELD() {
+    return 'loginForm[password]';
+  }
 
   crowi!: any;
 
@@ -122,17 +127,23 @@ class PassportService implements S2sMessageHandlable {
     this.crowi = crowi;
   }
 
-
   /**
    * @inheritdoc
    */
   shouldHandleS2sMessage(s2sMessage) {
     const { eventName, updatedAt, strategyId } = s2sMessage;
-    if (eventName !== 'passportServiceUpdated' || updatedAt == null || strategyId == null) {
+    if (
+      eventName !== 'passportServiceUpdated' ||
+      updatedAt == null ||
+      strategyId == null
+    ) {
       return false;
     }
 
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+    return (
+      this.lastLoadedAt == null ||
+      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
+    );
   }
 
   /**
@@ -158,9 +169,11 @@ class PassportService implements S2sMessageHandlable {
 
       try {
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
     }
   }
@@ -174,12 +187,24 @@ class PassportService implements S2sMessageHandlable {
   getSetupStrategies() {
     const setupStrategies: string[] = [];
 
-    if (this.isLocalStrategySetup) { setupStrategies.push('local') }
-    if (this.isLdapStrategySetup) { setupStrategies.push('ldap') }
-    if (this.isSamlStrategySetup) { setupStrategies.push('saml') }
-    if (this.isOidcStrategySetup) { setupStrategies.push('oidc') }
-    if (this.isGoogleStrategySetup) { setupStrategies.push('google') }
-    if (this.isGitHubStrategySetup) { setupStrategies.push('github') }
+    if (this.isLocalStrategySetup) {
+      setupStrategies.push('local');
+    }
+    if (this.isLdapStrategySetup) {
+      setupStrategies.push('ldap');
+    }
+    if (this.isSamlStrategySetup) {
+      setupStrategies.push('saml');
+    }
+    if (this.isOidcStrategySetup) {
+      setupStrategies.push('oidc');
+    }
+    if (this.isGoogleStrategySetup) {
+      setupStrategies.push('google');
+    }
+    if (this.isGitHubStrategySetup) {
+      setupStrategies.push('github');
+    }
 
     return setupStrategies;
   }
@@ -202,8 +227,7 @@ class PassportService implements S2sMessageHandlable {
 
     try {
       await this[func.setup]();
-    }
-    catch (err) {
+    } catch (err) {
       logger.debug(err);
       this[func.reset]();
     }
@@ -228,12 +252,13 @@ class PassportService implements S2sMessageHandlable {
    * @memberof PassportService
    */
   setupLocalStrategy() {
-
     this.resetLocalStrategy();
 
     const { configManager } = this.crowi;
 
-    const isEnabled = configManager.getConfig('security:passport-local:isEnabled');
+    const isEnabled = configManager.getConfig(
+      'security:passport-local:isEnabled',
+    );
 
     // when disabled
     if (!isEnabled) {
@@ -244,23 +269,27 @@ class PassportService implements S2sMessageHandlable {
 
     const User = this.crowi.model('User');
 
-    passport.use(new LocalStrategy(
-      {
-        usernameField: PassportService.USERNAME_FIELD,
-        passwordField: PassportService.PASSWORD_FIELD,
-      },
-      (username, password, done) => {
-        // find user
-        User.findUserByUsernameOrEmail(username, password, (err, user) => {
-          if (err) { return done(err) }
-          // check existence and password
-          if (!user || !user.isPasswordValid(password)) {
-            return done(null, false, { message: 'Incorrect credentials.' });
-          }
-          return done(null, user);
-        });
-      },
-    ));
+    passport.use(
+      new LocalStrategy(
+        {
+          usernameField: PassportService.USERNAME_FIELD,
+          passwordField: PassportService.PASSWORD_FIELD,
+        },
+        (username, password, done) => {
+          // find user
+          User.findUserByUsernameOrEmail(username, password, (err, user) => {
+            if (err) {
+              return done(err);
+            }
+            // check existence and password
+            if (!user || !user.isPasswordValid(password)) {
+              return done(null, false, { message: 'Incorrect credentials.' });
+            }
+            return done(null, user);
+          });
+        },
+      ),
+    );
 
     this.isLocalStrategySetup = true;
     logger.debug('LocalStrategy: setup is done');
@@ -283,13 +312,14 @@ class PassportService implements S2sMessageHandlable {
    * @memberof PassportService
    */
   setupLdapStrategy() {
-
     this.resetLdapStrategy();
 
     const config = this.crowi.config;
     const { configManager } = this.crowi;
 
-    const isLdapEnabled = configManager.getConfig('security:passport-ldap:isEnabled');
+    const isLdapEnabled = configManager.getConfig(
+      'security:passport-ldap:isEnabled',
+    );
 
     // when disabled
     if (!isLdapEnabled) {
@@ -298,15 +328,20 @@ class PassportService implements S2sMessageHandlable {
 
     logger.debug('LdapStrategy: setting up..');
 
-    passport.use(new LdapStrategy(this.getLdapConfigurationFunc(config, { passReqToCallback: true }),
-      (req, ldapAccountInfo, done) => {
-        logger.debug('LDAP authentication has succeeded', ldapAccountInfo);
+    passport.use(
+      new LdapStrategy(
+        this.getLdapConfigurationFunc(config, { passReqToCallback: true }),
+        (req, ldapAccountInfo, done) => {
+          logger.debug('LDAP authentication has succeeded', ldapAccountInfo);
 
-        // store ldapAccountInfo to req
-        (req as IncomingMessageWithLdapAccountInfo).ldapAccountInfo = ldapAccountInfo;
+          // store ldapAccountInfo to req
+          (req as IncomingMessageWithLdapAccountInfo).ldapAccountInfo =
+            ldapAccountInfo;
 
-        done(null, ldapAccountInfo);
-      }));
+          done(null, ldapAccountInfo);
+        },
+      ),
+    );
 
     this.isLdapStrategySetup = true;
     logger.debug('LdapStrategy: setup is done');
@@ -319,7 +354,9 @@ class PassportService implements S2sMessageHandlable {
    * @memberof PassportService
    */
   getLdapAttrNameMappedToUsername() {
-    return configManager.getConfig('security:passport-ldap:attrMapUsername') || 'uid';
+    return (
+      configManager.getConfig('security:passport-ldap:attrMapUsername') || 'uid'
+    );
   }
 
   /**
@@ -339,7 +376,9 @@ class PassportService implements S2sMessageHandlable {
    * @memberof PassportService
    */
   getLdapAttrNameMappedToMail() {
-    return configManager.getConfig('security:passport-ldap:attrMapMail') || 'mail';
+    return (
+      configManager.getConfig('security:passport-ldap:attrMapMail') || 'mail'
+    );
   }
 
   /**
@@ -367,14 +406,28 @@ class PassportService implements S2sMessageHandlable {
     const { configManager } = this.crowi;
 
     // get configurations
-    const isUserBind        = configManager.getConfig('security:passport-ldap:isUserBind');
-    const serverUrl         = configManager.getConfig('security:passport-ldap:serverUrl');
-    const bindDN            = configManager.getConfig('security:passport-ldap:bindDN');
-    const bindCredentials   = configManager.getConfig('security:passport-ldap:bindDNPassword');
-    const searchFilter      = configManager.getConfig('security:passport-ldap:searchFilter') || '(uid={{username}})';
-    const groupSearchBase   = configManager.getConfig('security:passport-ldap:groupSearchBase');
-    const groupSearchFilter = configManager.getConfig('security:passport-ldap:groupSearchFilter');
-    const groupDnProperty   = configManager.getConfig('security:passport-ldap:groupDnProperty') || 'uid';
+    const isUserBind = configManager.getConfig(
+      'security:passport-ldap:isUserBind',
+    );
+    const serverUrl = configManager.getConfig(
+      'security:passport-ldap:serverUrl',
+    );
+    const bindDN = configManager.getConfig('security:passport-ldap:bindDN');
+    const bindCredentials = configManager.getConfig(
+      'security:passport-ldap:bindDNPassword',
+    );
+    const searchFilter =
+      configManager.getConfig('security:passport-ldap:searchFilter') ||
+      '(uid={{username}})';
+    const groupSearchBase = configManager.getConfig(
+      'security:passport-ldap:groupSearchBase',
+    );
+    const groupSearchFilter = configManager.getConfig(
+      'security:passport-ldap:groupSearchFilter',
+    );
+    const groupDnProperty =
+      configManager.getConfig('security:passport-ldap:groupDnProperty') ||
+      'uid';
     /* eslint-enable no-multi-spaces */
 
     // parse serverUrl
@@ -382,7 +435,9 @@ class PassportService implements S2sMessageHandlable {
     const match = serverUrl.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
     if (match == null || match.length < 1) {
       logger.debug('LdapStrategy: serverUrl is invalid');
-      return (req, callback) => { callback({ message: 'serverUrl is invalid' }) };
+      return (req, callback) => {
+        callback({ message: 'serverUrl is invalid' });
+      };
     }
     const url = match[1];
     const searchBase = match[2] || '';
@@ -407,10 +462,12 @@ class PassportService implements S2sMessageHandlable {
       }
 
       // user bind
-      const fixedBindDN = (isUserBind)
+      const fixedBindDN = isUserBind
         ? bindDN.replace(/{{username}}/, loginForm.username)
         : bindDN;
-      const fixedBindCredentials = (isUserBind) ? loginForm.password : bindCredentials;
+      const fixedBindCredentials = isUserBind
+        ? loginForm.password
+        : bindCredentials;
       let serverOpt = {
         url,
         bindDN: fixedBindDN,
@@ -422,15 +479,22 @@ class PassportService implements S2sMessageHandlable {
       };
 
       if (groupSearchBase && groupSearchFilter) {
-        serverOpt = Object.assign(serverOpt, { groupSearchBase, groupSearchFilter, groupDnProperty });
+        serverOpt = Object.assign(serverOpt, {
+          groupSearchBase,
+          groupSearchFilter,
+          groupDnProperty,
+        });
       }
 
       process.nextTick(() => {
-        const mergedOpts = Object.assign({
-          usernameField: PassportService.USERNAME_FIELD,
-          passwordField: PassportService.PASSWORD_FIELD,
-          server: serverOpt,
-        }, opts);
+        const mergedOpts = Object.assign(
+          {
+            usernameField: PassportService.USERNAME_FIELD,
+            passwordField: PassportService.PASSWORD_FIELD,
+            server: serverOpt,
+          },
+          opts,
+        );
         logger.debug('ldap configuration: ', mergedOpts);
 
         // store configuration to req
@@ -447,10 +511,11 @@ class PassportService implements S2sMessageHandlable {
    * @memberof PassportService
    */
   setupGoogleStrategy() {
-
     this.resetGoogleStrategy();
 
-    const isGoogleEnabled = configManager.getConfig('security:passport-google:isEnabled');
+    const isGoogleEnabled = configManager.getConfig(
+      'security:passport-google:isEnabled',
+    );
 
     // when disabled
     if (!isGoogleEnabled) {
@@ -461,11 +526,21 @@ class PassportService implements S2sMessageHandlable {
     passport.use(
       new GoogleStrategy(
         {
-          clientID: configManager.getConfig('security:passport-google:clientId'),
-          clientSecret: configManager.getConfig('security:passport-google:clientSecret'),
-          callbackURL: configManager.getConfig('app:siteUrl') != null
-            ? urljoin(growiInfoService.getSiteUrl(), '/passport/google/callback') // auto-generated with v3.2.4 and above
-            : configManager.getConfigLegacy<string>('security:passport-google:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
+          clientID: configManager.getConfig(
+            'security:passport-google:clientId',
+          ),
+          clientSecret: configManager.getConfig(
+            'security:passport-google:clientSecret',
+          ),
+          callbackURL:
+            configManager.getConfig('app:siteUrl') != null
+              ? urljoin(
+                  growiInfoService.getSiteUrl(),
+                  '/passport/google/callback',
+                ) // auto-generated with v3.2.4 and above
+              : configManager.getConfigLegacy<string>(
+                  'security:passport-google:callbackUrl',
+                ), // DEPRECATED: backward compatible with v3.2.3 and below
           skipUserProfile: false,
         },
         (accessToken, refreshToken, profile, done) => {
@@ -494,10 +569,11 @@ class PassportService implements S2sMessageHandlable {
   }
 
   setupGitHubStrategy() {
-
     this.resetGitHubStrategy();
 
-    const isGitHubEnabled = configManager.getConfig('security:passport-github:isEnabled');
+    const isGitHubEnabled = configManager.getConfig(
+      'security:passport-github:isEnabled',
+    );
 
     // when disabled
     if (!isGitHubEnabled) {
@@ -508,11 +584,21 @@ class PassportService implements S2sMessageHandlable {
     passport.use(
       new GitHubStrategy(
         {
-          clientID: configManager.getConfig('security:passport-github:clientId'),
-          clientSecret: configManager.getConfig('security:passport-github:clientSecret'),
-          callbackURL: configManager.getConfig('app:siteUrl') != null
-            ? urljoin(growiInfoService.getSiteUrl(), '/passport/github/callback') // auto-generated with v3.2.4 and above
-            : configManager.getConfigLegacy('security:passport-github:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
+          clientID: configManager.getConfig(
+            'security:passport-github:clientId',
+          ),
+          clientSecret: configManager.getConfig(
+            'security:passport-github:clientSecret',
+          ),
+          callbackURL:
+            configManager.getConfig('app:siteUrl') != null
+              ? urljoin(
+                  growiInfoService.getSiteUrl(),
+                  '/passport/github/callback',
+                ) // auto-generated with v3.2.4 and above
+              : configManager.getConfigLegacy(
+                  'security:passport-github:callbackUrl',
+                ), // DEPRECATED: backward compatible with v3.2.3 and below
           skipUserProfile: false,
         },
         (accessToken, refreshToken, profile, done) => {
@@ -541,10 +627,11 @@ class PassportService implements S2sMessageHandlable {
   }
 
   async setupOidcStrategy() {
-
     this.resetOidcStrategy();
 
-    const isOidcEnabled = configManager.getConfig('security:passport-oidc:isEnabled');
+    const isOidcEnabled = configManager.getConfig(
+      'security:passport-oidc:isEnabled',
+    );
 
     // when disabled
     if (!isOidcEnabled) {
@@ -555,52 +642,79 @@ class PassportService implements S2sMessageHandlable {
 
     // setup client
     // extend oidc request timeouts
-    const OIDC_ISSUER_TIMEOUT_OPTION = await configManager.getConfig('security:passport-oidc:oidcIssuerTimeoutOption');
+    const OIDC_ISSUER_TIMEOUT_OPTION = await configManager.getConfig(
+      'security:passport-oidc:oidcIssuerTimeoutOption',
+    );
     // OIDCIssuer.defaultHttpOptions = { timeout: OIDC_ISSUER_TIMEOUT_OPTION };
 
     custom.setHttpOptionsDefaults({
       timeout: OIDC_ISSUER_TIMEOUT_OPTION,
     });
 
-    const issuerHost = configManager.getConfig('security:passport-oidc:issuerHost');
+    const issuerHost = configManager.getConfig(
+      'security:passport-oidc:issuerHost',
+    );
     const clientId = configManager.getConfig('security:passport-oidc:clientId');
-    const clientSecret = configManager.getConfig('security:passport-oidc:clientSecret');
-    const redirectUri = configManager.getConfig('app:siteUrl') != null
-      ? urljoin(growiInfoService.getSiteUrl(), '/passport/oidc/callback')
-      : configManager.getConfigLegacy<string>('security:passport-oidc:callbackUrl'); // DEPRECATED: backward compatible with v3.2.3 and below
+    const clientSecret = configManager.getConfig(
+      'security:passport-oidc:clientSecret',
+    );
+    const redirectUri =
+      configManager.getConfig('app:siteUrl') != null
+        ? urljoin(growiInfoService.getSiteUrl(), '/passport/oidc/callback')
+        : configManager.getConfigLegacy<string>(
+            'security:passport-oidc:callbackUrl',
+          ); // DEPRECATED: backward compatible with v3.2.3 and below
 
     // Prevent request timeout error on app init
     const oidcIssuer = await this.getOIDCIssuerInstance(issuerHost);
     if (clientId != null && oidcIssuer != null) {
       const oidcIssuerMetadata = oidcIssuer.metadata;
 
-      logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
+      logger.debug(
+        'Discovered issuer %s %O',
+        oidcIssuer.issuer,
+        oidcIssuer.metadata,
+      );
 
-      const authorizationEndpoint = configManager.getConfig('security:passport-oidc:authorizationEndpoint');
+      const authorizationEndpoint = configManager.getConfig(
+        'security:passport-oidc:authorizationEndpoint',
+      );
       if (authorizationEndpoint) {
         oidcIssuerMetadata.authorization_endpoint = authorizationEndpoint;
       }
-      const tokenEndpoint = configManager.getConfig('security:passport-oidc:tokenEndpoint');
+      const tokenEndpoint = configManager.getConfig(
+        'security:passport-oidc:tokenEndpoint',
+      );
       if (tokenEndpoint) {
         oidcIssuerMetadata.token_endpoint = tokenEndpoint;
       }
-      const revocationEndpoint = configManager.getConfig('security:passport-oidc:revocationEndpoint');
+      const revocationEndpoint = configManager.getConfig(
+        'security:passport-oidc:revocationEndpoint',
+      );
       if (revocationEndpoint) {
         oidcIssuerMetadata.revocation_endpoint = revocationEndpoint;
       }
-      const introspectionEndpoint = configManager.getConfig('security:passport-oidc:introspectionEndpoint');
+      const introspectionEndpoint = configManager.getConfig(
+        'security:passport-oidc:introspectionEndpoint',
+      );
       if (introspectionEndpoint) {
         oidcIssuerMetadata.introspection_endpoint = introspectionEndpoint;
       }
-      const userInfoEndpoint = configManager.getConfig('security:passport-oidc:userInfoEndpoint');
+      const userInfoEndpoint = configManager.getConfig(
+        'security:passport-oidc:userInfoEndpoint',
+      );
       if (userInfoEndpoint) {
         oidcIssuerMetadata.userinfo_endpoint = userInfoEndpoint;
       }
-      const endSessionEndpoint = configManager.getConfig('security:passport-oidc:endSessionEndpoint');
+      const endSessionEndpoint = configManager.getConfig(
+        'security:passport-oidc:endSessionEndpoint',
+      );
       if (endSessionEndpoint) {
         oidcIssuerMetadata.end_session_endpoint = endSessionEndpoint;
       }
-      const registrationEndpoint = configManager.getConfig('security:passport-oidc:registrationEndpoint');
+      const registrationEndpoint = configManager.getConfig(
+        'security:passport-oidc:registrationEndpoint',
+      );
       if (registrationEndpoint) {
         oidcIssuerMetadata.registration_endpoint = registrationEndpoint;
       }
@@ -611,7 +725,11 @@ class PassportService implements S2sMessageHandlable {
 
       const newOidcIssuer = new OIDCIssuer(oidcIssuerMetadata);
 
-      logger.debug('Configured issuer %s %O', newOidcIssuer.issuer, newOidcIssuer.metadata);
+      logger.debug(
+        'Configured issuer %s %O',
+        newOidcIssuer.issuer,
+        newOidcIssuer.metadata,
+      );
 
       const client = new newOidcIssuer.Client({
         client_id: clientId,
@@ -621,26 +739,30 @@ class PassportService implements S2sMessageHandlable {
       });
       // prevent error AssertionError [ERR_ASSERTION]: id_token issued in the future
       // Doc: https://github.com/panva/node-openid-client/tree/v2.x#allow-for-system-clock-skew
-      const OIDC_CLIENT_CLOCK_TOLERANCE = await configManager.getConfig('security:passport-oidc:oidcClientClockTolerance');
+      const OIDC_CLIENT_CLOCK_TOLERANCE = await configManager.getConfig(
+        'security:passport-oidc:oidcClientClockTolerance',
+      );
       client[custom.clock_tolerance] = OIDC_CLIENT_CLOCK_TOLERANCE;
-      passport.use('oidc', new OidcStrategy(
-        {
-          client,
-          params: { scope: 'openid email profile' },
-        },
-        (tokenset, userinfo, done) => {
-          if (userinfo) {
-            return done(null, userinfo);
-          }
-
-          return done(null, false);
-        },
-      ));
+      passport.use(
+        'oidc',
+        new OidcStrategy(
+          {
+            client,
+            params: { scope: 'openid email profile' },
+          },
+          (tokenset, userinfo, done) => {
+            if (userinfo) {
+              return done(null, userinfo);
+            }
+
+            return done(null, false);
+          },
+        ),
+      );
 
       this.isOidcStrategySetup = true;
       logger.debug('OidcStrategy: setup is done');
     }
-
   }
 
   /**
@@ -663,7 +785,7 @@ class PassportService implements S2sMessageHandlable {
    * @param issuerHost string
    * @returns string URL/.well-known/openid-configuration
    */
-  getOIDCMetadataURL(issuerHost: string) : string {
+  getOIDCMetadataURL(issuerHost: string): string {
     const protocol = 'https://';
     const pattern = /^https?:\/\//i;
     const metadataPath = '/.well-known/openid-configuration';
@@ -672,36 +794,36 @@ class PassportService implements S2sMessageHandlable {
       return issuerHost;
     }
     // Set protocol if not available on url
-    const absUrl = !pattern.test(issuerHost) ? `${protocol}${issuerHost}` : issuerHost;
+    const absUrl = !pattern.test(issuerHost)
+      ? `${protocol}${issuerHost}`
+      : issuerHost;
     const url = new URL(absUrl).href;
     // Remove trailing slash if exists
     return `${url.replace(/\/+$/, '')}${metadataPath}`;
   }
 
   /**
- *
- * Check and initialize connection to OIDC issuer host
- * Prevent request timeout error on app init
- *
- * @param issuerHost string
- * @returns boolean
- */
+   *
+   * Check and initialize connection to OIDC issuer host
+   * Prevent request timeout error on app init
+   *
+   * @param issuerHost string
+   * @returns boolean
+   */
   async isOidcHostReachable(issuerHost: string): Promise<boolean | undefined> {
     try {
       const metadataUrl = this.getOIDCMetadataURL(issuerHost);
-      const client = require('axios').default;
-      axiosRetry(client, {
+      axiosRetry(axios, {
         retries: 3,
       });
-      const response = await client.get(metadataUrl);
+      const response = await axios.get(metadataUrl);
       // Check for valid OIDC Issuer configuration
       if (!response.data.issuer) {
         logger.debug('OidcStrategy: Invalid OIDC Issuer configurations');
         return false;
       }
       return true;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('OidcStrategy: issuer host unreachable:', err.code);
     }
   }
@@ -713,11 +835,20 @@ class PassportService implements S2sMessageHandlable {
    * @param issuerHost string
    * @returns instance of OIDCIssuer
    */
-  async getOIDCIssuerInstance(issuerHost: string | undefined): Promise<void | OIDCIssuer> {
-    const OIDC_TIMEOUT_MULTIPLIER = configManager.getConfig('security:passport-oidc:timeoutMultiplier');
-    const OIDC_DISCOVERY_RETRIES = configManager.getConfig('security:passport-oidc:discoveryRetries');
-    const OIDC_ISSUER_TIMEOUT_OPTION = configManager.getConfig('security:passport-oidc:oidcIssuerTimeoutOption');
-    const oidcIssuerHostReady = issuerHost != null && this.isOidcHostReachable(issuerHost);
+  async getOIDCIssuerInstance(
+    issuerHost: string | undefined,
+  ): Promise<void | OIDCIssuer> {
+    const OIDC_TIMEOUT_MULTIPLIER = configManager.getConfig(
+      'security:passport-oidc:timeoutMultiplier',
+    );
+    const OIDC_DISCOVERY_RETRIES = configManager.getConfig(
+      'security:passport-oidc:discoveryRetries',
+    );
+    const OIDC_ISSUER_TIMEOUT_OPTION = configManager.getConfig(
+      'security:passport-oidc:oidcIssuerTimeoutOption',
+    );
+    const oidcIssuerHostReady =
+      issuerHost != null && this.isOidcHostReachable(issuerHost);
 
     if (!oidcIssuerHostReady) {
       logger.error('OidcStrategy: setup failed');
@@ -725,33 +856,39 @@ class PassportService implements S2sMessageHandlable {
     }
 
     const metadataURL = this.getOIDCMetadataURL(issuerHost);
-    const oidcIssuer = await pRetry(async() => {
-      return OIDCIssuer.discover(metadataURL);
-    }, {
-      onFailedAttempt: (error) => {
-        // get current OIDCIssuer timeout options
-        OIDCIssuer[custom.http_options] = (url, options) => {
-          const timeout = options.timeout
-            ? options.timeout * OIDC_TIMEOUT_MULTIPLIER
-            : OIDC_ISSUER_TIMEOUT_OPTION * OIDC_TIMEOUT_MULTIPLIER;
-          custom.setHttpOptionsDefaults({ timeout });
-          return { timeout };
-        };
-
-        logger.debug(`OidcStrategy: setup attempt ${error.attemptNumber} failed with error: ${error}. Retrying ...`);
+    const oidcIssuer = await pRetry(
+      async () => {
+        return OIDCIssuer.discover(metadataURL);
       },
-      retries: OIDC_DISCOVERY_RETRIES,
-    }).catch((error) => {
+      {
+        onFailedAttempt: (error) => {
+          // get current OIDCIssuer timeout options
+          OIDCIssuer[custom.http_options] = (url, options) => {
+            const timeout = options.timeout
+              ? options.timeout * OIDC_TIMEOUT_MULTIPLIER
+              : OIDC_ISSUER_TIMEOUT_OPTION * OIDC_TIMEOUT_MULTIPLIER;
+            custom.setHttpOptionsDefaults({ timeout });
+            return { timeout };
+          };
+
+          logger.debug(
+            `OidcStrategy: setup attempt ${error.attemptNumber} failed with error: ${error}. Retrying ...`,
+          );
+        },
+        retries: OIDC_DISCOVERY_RETRIES,
+      },
+    ).catch((error) => {
       logger.error(`OidcStrategy: setup failed with error: ${error} `);
     });
     return oidcIssuer;
   }
 
   setupSamlStrategy(): void {
-
     this.resetSamlStrategy();
 
-    const isSamlEnabled = configManager.getConfig('security:passport-saml:isEnabled');
+    const isSamlEnabled = configManager.getConfig(
+      'security:passport-saml:isEnabled',
+    );
 
     // when disabled
     if (!isSamlEnabled) {
@@ -769,10 +906,16 @@ class PassportService implements S2sMessageHandlable {
     passport.use(
       new SamlStrategy(
         {
-          entryPoint: configManager.getConfig('security:passport-saml:entryPoint'),
-          callbackUrl: configManager.getConfig('app:siteUrl') != null
-            ? urljoin(growiInfoService.getSiteUrl(), '/passport/saml/callback') // auto-generated with v3.2.4 and above
-            : configManager.getConfig('security:passport-saml:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
+          entryPoint: configManager.getConfig(
+            'security:passport-saml:entryPoint',
+          ),
+          callbackUrl:
+            configManager.getConfig('app:siteUrl') != null
+              ? urljoin(
+                  growiInfoService.getSiteUrl(),
+                  '/passport/saml/callback',
+                ) // auto-generated with v3.2.4 and above
+              : configManager.getConfig('security:passport-saml:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
           issuer: configManager.getConfig('security:passport-saml:issuer'),
           cert,
           disableRequestedAuthnContext: true,
@@ -841,7 +984,9 @@ class PassportService implements S2sMessageHandlable {
     logger.debug({ 'Parsed Rule': JSON.stringify(luceneRule, null, 2) });
 
     const attributes = this.extractAttributesFromSAMLResponse(response);
-    logger.debug({ 'Extracted Attributes': JSON.stringify(attributes, null, 2) });
+    logger.debug({
+      'Extracted Attributes': JSON.stringify(attributes, null, 2),
+    });
 
     return this.evaluateRuleForSamlAttributes(attributes, luceneRule);
   }
@@ -858,7 +1003,12 @@ class PassportService implements S2sMessageHandlable {
 
     // when combined rules
     if (right != null) {
-      return this.evaluateCombinedRulesForSamlAttributes(attributes, left, right, operator);
+      return this.evaluateCombinedRulesForSamlAttributes(
+        attributes,
+        left,
+        right,
+        operator,
+      );
     }
     if (left != null) {
       return this.evaluateRuleForSamlAttributes(attributes, left);
@@ -891,15 +1041,29 @@ class PassportService implements S2sMessageHandlable {
    * @param {string} luceneOperator operator string expression
    * @see https://github.com/thoward/lucene-query-parser.js/wiki
    */
-  evaluateCombinedRulesForSamlAttributes(attributes, luceneRuleLeft, luceneRuleRight, luceneOperator) {
+  evaluateCombinedRulesForSamlAttributes(
+    attributes,
+    luceneRuleLeft,
+    luceneRuleRight,
+    luceneOperator,
+  ) {
     if (luceneOperator === 'OR') {
-      return this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) || this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight);
+      return (
+        this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) ||
+        this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight)
+      );
     }
     if (luceneOperator === 'AND') {
-      return this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) && this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight);
+      return (
+        this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) &&
+        this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight)
+      );
     }
     if (luceneOperator === 'NOT') {
-      return this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) && !this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight);
+      return (
+        this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) &&
+        !this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight)
+      );
     }
 
     throw new Error(`Unsupported operator: ${luceneOperator}`);
@@ -917,7 +1081,8 @@ class PassportService implements S2sMessageHandlable {
    * }
    */
   extractAttributesFromSAMLResponse(response) {
-    const attributeStatement = response.getAssertion().Assertion.AttributeStatement;
+    const attributeStatement =
+      response.getAssertion().Assertion.AttributeStatement;
     if (attributeStatement == null || attributeStatement[0] == null) {
       return {};
     }
@@ -930,11 +1095,10 @@ class PassportService implements S2sMessageHandlable {
     const result = {};
     for (const attribute of attributes) {
       const name = attribute.$.Name;
-      const attributeValues = attribute.AttributeValue.map(v => v._);
+      const attributeValues = attribute.AttributeValue.map((v) => v._);
       if (result[name] == null) {
         result[name] = attributeValues;
-      }
-      else {
+      } else {
         result[name] = result[name].concat(attributeValues);
       }
     }
@@ -961,7 +1125,7 @@ class PassportService implements S2sMessageHandlable {
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       done(null, (user as any).id);
     });
-    passport.deserializeUser(async(id, done) => {
+    passport.deserializeUser(async (id, done) => {
       try {
         const user = await User.findById(id);
         if (user == null) {
@@ -972,8 +1136,7 @@ class PassportService implements S2sMessageHandlable {
           await user.save();
         }
         done(null, user);
-      }
-      catch (err) {
+      } catch (err) {
         done(err);
       }
     });
@@ -981,12 +1144,20 @@ class PassportService implements S2sMessageHandlable {
     this.isSerializerSetup = true;
   }
 
-  isSameUsernameTreatedAsIdenticalUser(providerType: IExternalAuthProviderType): boolean {
-    return configManager.getConfig(`security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`);
+  isSameUsernameTreatedAsIdenticalUser(
+    providerType: IExternalAuthProviderType,
+  ): boolean {
+    return configManager.getConfig(
+      `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`,
+    );
   }
 
-  isSameEmailTreatedAsIdenticalUser(providerType: Exclude<IExternalAuthProviderType, 'ldap'>): boolean {
-    return configManager.getConfig(`security:passport-${providerType}:isSameEmailTreatedAsIdenticalUser`);
+  isSameEmailTreatedAsIdenticalUser(
+    providerType: Exclude<IExternalAuthProviderType, 'ldap'>,
+  ): boolean {
+    return configManager.getConfig(
+      `security:passport-${providerType}:isSameEmailTreatedAsIdenticalUser`,
+    );
   }
 
   literalUnescape(string: string) {
@@ -1000,7 +1171,6 @@ class PassportService implements S2sMessageHandlable {
       .replace(/\\n/g, '\n')
       .replace(/\\r/g, '\r');
   }
-
 }
 
 export default PassportService;

+ 27 - 22
apps/app/src/server/service/pre-notify.ts

@@ -1,44 +1,52 @@
-import {
-  getIdForRef,
-  type IPage, type IUser, type Ref,
-} from '@growi/core';
+import { getIdForRef, type IPage, type IUser, type Ref } from '@growi/core';
 import mongoose from 'mongoose';
 
 import type { ActivityDocument } from '../models/activity';
 import Subscription from '../models/subscription';
 
 export type PreNotifyProps = {
-  notificationTargetUsers?: Ref<IUser>[],
-}
+  notificationTargetUsers?: Ref<IUser>[];
+};
 
 export type PreNotify = (props: PreNotifyProps) => Promise<void>;
-export type GetAdditionalTargetUsers = (activity: ActivityDocument) => Promise<Ref<IUser>[]>;
-export type GeneratePreNotify = (activity: ActivityDocument, getAdditionalTargetUsers?: GetAdditionalTargetUsers) => PreNotify;
+export type GetAdditionalTargetUsers = (
+  activity: ActivityDocument,
+) => Promise<Ref<IUser>[]>;
+export type GeneratePreNotify = (
+  activity: ActivityDocument,
+  getAdditionalTargetUsers?: GetAdditionalTargetUsers,
+) => PreNotify;
 
 interface IPreNotifyService {
-  generateInitialPreNotifyProps: (PreNotifyProps) => { notificationTargetUsers?: Ref<IUser>[] },
-  generatePreNotify: GeneratePreNotify
+  generateInitialPreNotifyProps: (PreNotifyProps) => {
+    notificationTargetUsers?: Ref<IUser>[];
+  };
+  generatePreNotify: GeneratePreNotify;
 }
 
 class PreNotifyService implements IPreNotifyService {
-
   generateInitialPreNotifyProps = (): PreNotifyProps => {
-
     const initialPreNotifyProps: Ref<IUser>[] = [];
 
     return { notificationTargetUsers: initialPreNotifyProps };
   };
 
-  generatePreNotify = (activity: ActivityDocument, getAdditionalTargetUsers?: GetAdditionalTargetUsers): PreNotify => {
-
-    const preNotify = async(props: PreNotifyProps) => {
+  generatePreNotify = (
+    activity: ActivityDocument,
+    getAdditionalTargetUsers?: GetAdditionalTargetUsers,
+  ): PreNotify => {
+    const preNotify = async (props: PreNotifyProps) => {
       const { notificationTargetUsers } = props;
 
-      const User = mongoose.model<IUser, { find, STATUS_ACTIVE }>('User');
+      const User = mongoose.model<IUser, { find; STATUS_ACTIVE }>('User');
       const actionUser = activity.user;
       const target = activity.target;
-      const subscribedUsers = await Subscription.getSubscription(target as unknown as Ref<IPage>);
-      const notificationUsers = subscribedUsers.filter(item => (item.toString() !== getIdForRef(actionUser).toString()));
+      const subscribedUsers = await Subscription.getSubscription(
+        target as unknown as Ref<IPage>,
+      );
+      const notificationUsers = subscribedUsers.filter(
+        (item) => item.toString() !== getIdForRef(actionUser).toString(),
+      );
       const activeNotificationUsers = await User.find({
         _id: { $in: notificationUsers },
         status: User.STATUS_ACTIVE,
@@ -46,8 +54,7 @@ class PreNotifyService implements IPreNotifyService {
 
       if (getAdditionalTargetUsers == null) {
         notificationTargetUsers?.push(...activeNotificationUsers);
-      }
-      else {
+      } else {
         const AdditionalTargetUsers = await getAdditionalTargetUsers(activity);
 
         notificationTargetUsers?.push(
@@ -55,12 +62,10 @@ class PreNotifyService implements IPreNotifyService {
           ...AdditionalTargetUsers,
         );
       }
-
     };
 
     return preNotify;
   };
-
 }
 
 export const preNotifyService = new PreNotifyService();

+ 9 - 10
apps/app/src/server/service/rest-qiita-API.js

@@ -1,4 +1,5 @@
 function getAxios(team, token) {
+  // biome-ignore lint/style/noRestrictedImports: TODO: check effects of using custom axios
   return require('axios').create({
     baseURL: `https://${team}.qiita.com/api/v2`,
     headers: {
@@ -16,7 +17,6 @@ function getAxios(team, token) {
  */
 
 class RestQiitaAPIService {
-
   /** @type {import('~/server/crowi').default} Crowi instance */
   crowi;
 
@@ -46,13 +46,12 @@ class RestQiitaAPIService {
    * @param {string} path
    */
   async restAPI(path) {
-    return this.axios.get(path)
-      .then((res) => {
-        const data = res.data;
-        const total = res.headers['total-count'];
+    return this.axios.get(path).then((res) => {
+      const data = res.data;
+      const total = res.headers['total-count'];
 
-        return { data, total };
-      });
+      return { data, total };
+    });
   }
 
   /**
@@ -68,7 +67,6 @@ class RestQiitaAPIService {
     }
   }
 
-
   /**
    * get Qiita pages
    * @memberof RestQiitaAPI
@@ -76,7 +74,9 @@ class RestQiitaAPIService {
    * @param {string} perPage
    */
   async getQiitaPages(pageNum, perPage) {
-    const res = await this.restAPI(`/items?page=${pageNum}&per_page=${perPage}`);
+    const res = await this.restAPI(
+      `/items?page=${pageNum}&per_page=${perPage}`,
+    );
     const pages = res.data;
     const total = res.total;
 
@@ -84,7 +84,6 @@ class RestQiitaAPIService {
       return { pages, total };
     }
   }
-
 }
 
 module.exports = RestQiitaAPIService;

+ 266 - 119
apps/app/src/server/service/search.ts

@@ -4,26 +4,36 @@ import mongoose from 'mongoose';
 import { FilterXSS } from 'xss';
 
 import { CommentEvent, commentEvent } from '~/features/comment/server';
-import { isIncludeAiMenthion, removeAiMenthion } from '~/features/search/utils/ai';
+import {
+  isIncludeAiMenthion,
+  removeAiMenthion,
+} from '~/features/search/utils/ai';
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import type { IFormattedSearchResult, IPageWithSearchMeta, ISearchResult } from '~/interfaces/search';
+import type {
+  IFormattedSearchResult,
+  IPageWithSearchMeta,
+  ISearchResult,
+} from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
 import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import type {
-  SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, SearchableData, QueryTerms,
+  ParsedQuery,
+  QueryTerms,
+  SearchableData,
+  SearchDelegator,
+  SearchQueryParser,
+  SearchResolver,
 } from '../interfaces/search';
 import NamedQuery from '../models/named-query';
 import type { PageModel } from '../models/page';
 import { SearchError } from '../models/vo/search-error';
 import { hasIntersection } from '../util/compare-objectId';
-
 import { configManager } from './config-manager';
 import ElasticsearchDelegator from './search-delegator/elasticsearch';
 import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
 
-
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:service:search');
 
@@ -41,8 +51,7 @@ const filterXss = new FilterXSS(filterXssOptions);
 
 const normalizeQueryString = (_queryString: string): string => {
   let queryString = _queryString.trim();
-  queryString = removeAiMenthion(queryString)
-    .replace(/\s+/g, ' ');
+  queryString = removeAiMenthion(queryString).replace(/\s+/g, ' ');
 
   return queryString;
 };
@@ -51,12 +60,14 @@ const normalizeNQName = (nqName: string): string => {
   return nqName.trim();
 };
 
-const findPageListByIds = async(pageIds: ObjectIdLike[], crowi: any) => {
-
+const findPageListByIds = async (pageIds: ObjectIdLike[], crowi: any) => {
   const Page = crowi.model('Page') as unknown as PageModel;
   const User = crowi.model('User');
 
-  const builder = new Page.PageQueryBuilder(Page.find(({ _id: { $in: pageIds } })), false);
+  const builder = new Page.PageQueryBuilder(
+    Page.find({ _id: { $in: pageIds } }),
+    false,
+  );
 
   builder.addConditionToPagenate(undefined, undefined); // offset and limit are unnesessary
 
@@ -73,11 +84,9 @@ const findPageListByIds = async(pageIds: ObjectIdLike[], crowi: any) => {
     pages,
     totalCount,
   };
-
 };
 
 class SearchService implements SearchQueryParser, SearchResolver {
-
   crowi: Crowi;
 
   isErrorOccuredOnHealthcheck: boolean | null;
@@ -86,7 +95,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
   fullTextSearchDelegator: any & ElasticsearchDelegator;
 
-  nqDelegators: {[key in SearchDelegatorName]: SearchDelegator};
+  nqDelegators: { [key in SearchDelegatorName]: SearchDelegator };
 
   constructor(crowi: Crowi) {
     this.crowi = crowi;
@@ -96,10 +105,11 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
     try {
       this.fullTextSearchDelegator = this.generateFullTextSearchDelegator();
-      this.nqDelegators = this.generateNQDelegators(this.fullTextSearchDelegator);
+      this.nqDelegators = this.generateNQDelegators(
+        this.fullTextSearchDelegator,
+      );
       logger.info('Succeeded to initialize search delegators');
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
     }
 
@@ -114,7 +124,11 @@ class SearchService implements SearchQueryParser, SearchResolver {
   }
 
   get isReachable() {
-    return this.isConfigured && !this.isErrorOccuredOnHealthcheck && !this.isErrorOccuredOnSearching;
+    return (
+      this.isConfigured &&
+      !this.isErrorOccuredOnHealthcheck &&
+      !this.isErrorOccuredOnSearching
+    );
   }
 
   get isElasticsearchEnabled() {
@@ -130,48 +144,130 @@ class SearchService implements SearchQueryParser, SearchResolver {
       return new ElasticsearchDelegator(this.crowi.socketIoService);
     }
 
-    logger.info('No elasticsearch URI is specified so that full text search is disabled.');
+    logger.info(
+      'No elasticsearch URI is specified so that full text search is disabled.',
+    );
   }
 
-  generateNQDelegators(defaultDelegator: ElasticsearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
+  generateNQDelegators(defaultDelegator: ElasticsearchDelegator): {
+    [key in SearchDelegatorName]: SearchDelegator;
+  } {
     return {
       [SearchDelegatorName.DEFAULT]: defaultDelegator,
-      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: new PrivateLegacyPagesDelegator() as unknown as SearchDelegator,
+      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]:
+        new PrivateLegacyPagesDelegator() as unknown as SearchDelegator,
     };
   }
 
   registerUpdateEvent() {
     const pageEvent = this.crowi.event('page');
-    pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
-    pageEvent.on('update', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on(
+      'create',
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    pageEvent.on(
+      'update',
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
     pageEvent.on('delete', (targetPage, deletedPage, user) => {
-      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator)(targetPage, user);
-      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator)(deletedPage, user);
+      this.fullTextSearchDelegator.syncPageDeleted.bind(
+        this.fullTextSearchDelegator,
+      )(targetPage, user);
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      )(deletedPage, user);
     });
     pageEvent.on('revert', (targetPage, revertedPage, user) => {
-      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator)(targetPage, user);
-      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator)(revertedPage, user);
+      this.fullTextSearchDelegator.syncPageDeleted.bind(
+        this.fullTextSearchDelegator,
+      )(targetPage, user);
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      )(revertedPage, user);
     });
-    pageEvent.on('deleteCompletely', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
-    pageEvent.on('syncDescendantsDelete', this.fullTextSearchDelegator.syncDescendantsPagesDeleted.bind(this.fullTextSearchDelegator));
-    pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));
-    pageEvent.on('syncDescendantsUpdate', this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(this.fullTextSearchDelegator));
-    pageEvent.on('addSeenUsers', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on(
+      'deleteCompletely',
+      this.fullTextSearchDelegator.syncPageDeleted.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    pageEvent.on(
+      'syncDescendantsDelete',
+      this.fullTextSearchDelegator.syncDescendantsPagesDeleted.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    pageEvent.on(
+      'updateMany',
+      this.fullTextSearchDelegator.syncPagesUpdated.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    pageEvent.on(
+      'syncDescendantsUpdate',
+      this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    pageEvent.on(
+      'addSeenUsers',
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
     pageEvent.on('rename', () => {
-      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator);
-      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator);
+      this.fullTextSearchDelegator.syncPageDeleted.bind(
+        this.fullTextSearchDelegator,
+      );
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      );
     });
 
     const bookmarkEvent = this.crowi.event('bookmark');
-    bookmarkEvent.on('create', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
-    bookmarkEvent.on('delete', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
+    bookmarkEvent.on(
+      'create',
+      this.fullTextSearchDelegator.syncBookmarkChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    bookmarkEvent.on(
+      'delete',
+      this.fullTextSearchDelegator.syncBookmarkChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
 
     const tagEvent = this.crowi.event('tag');
-    tagEvent.on('update', this.fullTextSearchDelegator.syncTagChanged.bind(this.fullTextSearchDelegator));
-
-    commentEvent.on(CommentEvent.CREATE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
-    commentEvent.on(CommentEvent.UPDATE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
-    commentEvent.on(CommentEvent.DELETE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    tagEvent.on(
+      'update',
+      this.fullTextSearchDelegator.syncTagChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+
+    commentEvent.on(
+      CommentEvent.CREATE,
+      this.fullTextSearchDelegator.syncCommentChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    commentEvent.on(
+      CommentEvent.UPDATE,
+      this.fullTextSearchDelegator.syncCommentChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    commentEvent.on(
+      CommentEvent.DELETE,
+      this.fullTextSearchDelegator.syncCommentChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
   }
 
   resetErrorStatus() {
@@ -188,8 +284,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
       logger.info('Reconnecting succeeded.');
       this.resetErrorStatus();
-    }
-    catch (err) {
+    } catch (err) {
       throw err;
     }
   }
@@ -197,8 +292,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
   async getInfo() {
     try {
       return await this.fullTextSearchDelegator.getInfo();
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       throw err;
     }
@@ -210,8 +304,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
       this.isErrorOccuredOnHealthcheck = false;
       return result;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
 
       // switch error flag, `isErrorOccuredOnHealthcheck` to be `false`
@@ -232,7 +325,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return this.fullTextSearchDelegator.rebuildIndex();
   }
 
-  async parseSearchQuery(queryString: string, nqName: string | null): Promise<ParsedQuery> {
+  async parseSearchQuery(
+    queryString: string,
+    nqName: string | null,
+  ): Promise<ParsedQuery> {
     // eslint-disable-next-line no-param-reassign
     queryString = normalizeQueryString(queryString);
 
@@ -246,7 +342,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
     // will delegate to full-text search
     if (nq == null) {
-      logger.debug(`Delegated to full-text search since a named query document did not found. (nqName="${nqName}")`);
+      logger.debug(
+        `Delegated to full-text search since a named query document did not found. (nqName="${nqName}")`,
+      );
       return { queryString, terms };
     }
 
@@ -254,17 +352,25 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
     let parsedQuery: ParsedQuery;
     if (aliasOf != null) {
-      parsedQuery = { queryString: normalizeQueryString(aliasOf), terms: this.parseQueryString(aliasOf) };
-    }
-    else {
+      parsedQuery = {
+        queryString: normalizeQueryString(aliasOf),
+        terms: this.parseQueryString(aliasOf),
+      };
+    } else {
       parsedQuery = { queryString, terms, delegatorName };
     }
 
     return parsedQuery;
   }
 
-  async resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData]> {
-    const { queryString, terms, delegatorName = SearchDelegatorName.DEFAULT } = parsedQuery;
+  async resolve(
+    parsedQuery: ParsedQuery,
+  ): Promise<[SearchDelegator, SearchableData]> {
+    const {
+      queryString,
+      terms,
+      delegatorName = SearchDelegatorName.DEFAULT,
+    } = parsedQuery;
     const nqDeledator = this.nqDelegators[delegatorName];
 
     const data = {
@@ -280,7 +386,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
    * @param {SearchDelegator} delegator
    * @throws {SearchError} SearchError
    */
-  private validateSearchableData(delegator: SearchDelegator, data: SearchableData): void {
+  private validateSearchableData(
+    delegator: SearchDelegator,
+    data: SearchableData,
+  ): void {
     const { terms } = data;
 
     if (delegator.isTermsNormalized(terms)) {
@@ -289,16 +398,24 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
     const unavailableTermsKeys = delegator.validateTerms(terms);
 
-    throw new SearchError('The query string includes unavailable terms.', unavailableTermsKeys);
+    throw new SearchError(
+      'The query string includes unavailable terms.',
+      unavailableTermsKeys,
+    );
   }
 
-  async searchKeyword(keyword: string, nqName: string | null, user, userGroups, searchOpts): Promise<[ISearchResult<unknown>, string | null]> {
+  async searchKeyword(
+    keyword: string,
+    nqName: string | null,
+    user,
+    userGroups,
+    searchOpts,
+  ): Promise<[ISearchResult<unknown>, string | null]> {
     let parsedQuery: ParsedQuery;
     // parse
     try {
       parsedQuery = await this.parseSearchQuery(keyword, nqName);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error occurred while parseSearchQuery', err);
       throw err;
     }
@@ -312,8 +429,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
     // resolve
     try {
       [delegator, data] = await this.resolve(parsedQuery);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error occurred while resolving search delegator', err);
       throw err;
     }
@@ -321,7 +437,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
     // throws
     this.validateSearchableData(delegator, data);
 
-    return [await delegator.search(data, user, userGroups, searchOpts), delegator.name ?? null];
+    return [
+      await delegator.search(data, user, userGroups, searchOpts),
+      delegator.name ?? null,
+    ];
   }
 
   parseQueryString(queryString: string): QueryTerms {
@@ -346,8 +465,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
         phrase.trim();
         if (phrase.match(/^-/)) {
           notPhraseWords.push(phrase.replace(/^-/, ''));
-        }
-        else {
+        } else {
           phraseWords.push(phrase);
         }
       });
@@ -367,22 +485,17 @@ class SearchService implements SearchQueryParser, SearchResolver {
       if (matchNegative != null) {
         if (matchNegative[1] === 'prefix:') {
           notPrefixPaths.push(matchNegative[2]);
-        }
-        else if (matchNegative[1] === 'tag:') {
+        } else if (matchNegative[1] === 'tag:') {
           notTags.push(matchNegative[2]);
-        }
-        else {
+        } else {
           notMatchWords.push(matchNegative[2]);
         }
-      }
-      else if (matchPositive != null) {
+      } else if (matchPositive != null) {
         if (matchPositive[1] === 'prefix:') {
           prefixPaths.push(matchPositive[2]);
-        }
-        else if (matchPositive[1] === 'tag:') {
+        } else if (matchPositive[1] === 'tag:') {
           tags.push(matchPositive[2]);
-        }
-        else {
+        } else {
           matchWords.push(matchPositive[2]);
         }
       }
@@ -404,14 +517,22 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
   // TODO: optimize the way to check isFormattable e.g. check data schema of searchResult
   // So far, it determines by delegatorName passed by searchService.searchKeyword
-  checkIsFormattable(searchResult, delegatorName: SearchDelegatorName): boolean {
+  checkIsFormattable(
+    searchResult,
+    delegatorName: SearchDelegatorName,
+  ): boolean {
     return delegatorName === SearchDelegatorName.DEFAULT;
   }
 
   /**
    * formatting result
    */
-  async formatSearchResult(searchResult: ISearchResult<any>, delegatorName: SearchDelegatorName, user, userGroups): Promise<IFormattedSearchResult> {
+  async formatSearchResult(
+    searchResult: ISearchResult<any>,
+    delegatorName: SearchDelegatorName,
+    user,
+    userGroups,
+  ): Promise<IFormattedSearchResult> {
     if (!this.checkIsFormattable(searchResult, delegatorName)) {
       const data: IPageWithSearchMeta[] = searchResult.data.map((page) => {
         return {
@@ -432,7 +553,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
     const result = {} as IFormattedSearchResult;
 
     // get page data
-    const pageIds: string[] = searchResult.data.map((page) => { return page._id });
+    const pageIds: string[] = searchResult.data.map((page) => {
+      return page._id;
+    });
 
     const findPageResult = await findPageListByIds(pageIds, this.crowi);
 
@@ -440,53 +563,75 @@ class SearchService implements SearchQueryParser, SearchResolver {
     result.meta = searchResult.meta;
 
     // set search result page data
-    const pages: (IPageWithSearchMeta | null)[] = searchResult.data.map((data) => {
-      const pageData = findPageResult.pages.find((pageData) => {
-        return pageData.id === data._id;
-      });
+    const pages: (IPageWithSearchMeta | null)[] = searchResult.data.map(
+      (data) => {
+        const pageData = findPageResult.pages.find((pageData) => {
+          return pageData.id === data._id;
+        });
+
+        if (pageData == null) {
+          return null;
+        }
 
-      if (pageData == null) {
-        return null;
-      }
+        // add tags and seenUserCount to pageData
+        pageData._doc.tags = data._source.tag_names;
+        pageData._doc.seenUserCount =
+          (pageData.seenUsers && pageData.seenUsers.length) || 0;
+
+        // serialize lastUpdateUser
+        if (
+          pageData.lastUpdateUser != null &&
+          pageData.lastUpdateUser instanceof User
+        ) {
+          pageData.lastUpdateUser = serializeUserSecurely(
+            pageData.lastUpdateUser,
+          );
+        }
 
-      // add tags and seenUserCount to pageData
-      pageData._doc.tags = data._source.tag_names;
-      pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
+        // increment elasticSearchResult
+        let elasticSearchResult:
+          | { snippet: string | null; highlightedPath: string | null }
+          | undefined;
+        const highlightData = data._highlight;
+        if (highlightData != null) {
+          const snippet = this.canShowSnippet(pageData, user, userGroups)
+            ? // eslint-disable-next-line max-len
+              highlightData.body ||
+              highlightData['body.en'] ||
+              highlightData['body.ja'] ||
+              highlightData.comments ||
+              highlightData['comments.en'] ||
+              highlightData['comments.ja']
+            : null;
+          const pathMatch =
+            highlightData['path.en'] || highlightData['path.ja'];
+
+          elasticSearchResult = {
+            snippet:
+              snippet != null && typeof snippet[0] === 'string'
+                ? filterXss.process(snippet)
+                : null,
+            highlightedPath:
+              pathMatch != null && typeof pathMatch[0] === 'string'
+                ? filterXss.process(pathMatch)
+                : null,
+          };
+        }
 
-      // serialize lastUpdateUser
-      if (pageData.lastUpdateUser != null && pageData.lastUpdateUser instanceof User) {
-        pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
-      }
+        // serialize creator
+        if (pageData.creator != null && pageData.creator instanceof User) {
+          pageData.creator = serializeUserSecurely(pageData.creator);
+        }
 
-      // increment elasticSearchResult
-      let elasticSearchResult;
-      const highlightData = data._highlight;
-      if (highlightData != null) {
-        const snippet = this.canShowSnippet(pageData, user, userGroups)
-          // eslint-disable-next-line max-len
-          ? highlightData.body || highlightData['body.en'] || highlightData['body.ja'] || highlightData.comments || highlightData['comments.en'] || highlightData['comments.ja']
-          : null;
-        const pathMatch = highlightData['path.en'] || highlightData['path.ja'];
-
-        elasticSearchResult = {
-          snippet: snippet != null && typeof snippet[0] === 'string' ? filterXss.process(snippet) : null,
-          highlightedPath: pathMatch != null && typeof pathMatch[0] === 'string' ? filterXss.process(pathMatch) : null,
+        // generate pageMeta data
+        const pageMeta = {
+          bookmarkCount: data._source.bookmark_count || 0,
+          elasticSearchResult,
         };
-      }
 
-      // serialize creator
-      if (pageData.creator != null && pageData.creator instanceof User) {
-        pageData.creator = serializeUserSecurely(pageData.creator);
-      }
-
-      // generate pageMeta data
-      const pageMeta = {
-        bookmarkCount: data._source.bookmark_count || 0,
-        elasticSearchResult,
-      };
-
-      return { data: pageData, meta: pageMeta };
-    });
+        return { data: pageData, meta: pageMeta };
+      },
+    );
 
     result.data = pages.filter(nonNullable);
     return result;
@@ -512,12 +657,14 @@ class SearchService implements SearchQueryParser, SearchResolver {
     if (testGrant === Page.GRANT_USER_GROUP) {
       if (userGroups == null) return false;
 
-      return hasIntersection(userGroups.map(id => id.toString()), testGrantedGroups);
+      return hasIntersection(
+        userGroups.map((id) => id.toString()),
+        testGrantedGroups,
+      );
     }
 
     return true;
   }
-
 }
 
 export default SearchService;

+ 115 - 53
apps/app/src/server/service/slack-integration.ts

@@ -1,16 +1,16 @@
 import {
-  SlackbotType, type GrowiCommand, type GrowiBotEvent,
+  type GrowiBotEvent,
+  type GrowiCommand,
+  SlackbotType,
 } from '@growi/slack';
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import type { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
 import type { RespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
 import { generateWebClient } from '@growi/slack/dist/utils/webclient-factory';
-import type { WebClient } from '@slack/web-api';
-import { type ChatPostMessageArguments } from '@slack/web-api';
+import type { ChatPostMessageArguments, WebClient } from '@slack/web-api';
 import type { IncomingWebhookSendArguments } from '@slack/webhook';
 import mongoose from 'mongoose';
 
-
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
@@ -18,7 +18,6 @@ import type { EventActionsPermission } from '../interfaces/slack-integration/eve
 import S2sMessage from '../models/vo/s2s-message';
 import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
 import { slackLegacyUtilFactory } from '../util/slack-legacy';
-
 import { configManager } from './config-manager';
 import type { S2sMessagingService } from './s2s-messaging/base';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
@@ -31,7 +30,6 @@ const OFFICIAL_SLACKBOT_PROXY_URI = 'https://slackbot-proxy.growi.org';
 type S2sMessageForSlackIntegration = S2sMessage & { updatedAt: Date };
 
 export class SlackIntegrationService implements S2sMessageHandlable {
-
   crowi: Crowi;
 
   s2sMessagingService!: S2sMessagingService;
@@ -61,10 +59,12 @@ export class SlackIntegrationService implements S2sMessageHandlable {
       return false;
     }
 
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+    return (
+      this.lastLoadedAt == null ||
+      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
+    );
   }
 
-
   /**
    * @inheritdoc
    */
@@ -80,13 +80,17 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     const { s2sMessagingService } = this;
 
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('slackIntegrationServiceUpdated', { updatedAt: new Date() });
+      const s2sMessage = new S2sMessage('slackIntegrationServiceUpdated', {
+        updatedAt: new Date(),
+      });
 
       try {
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
     }
   }
@@ -96,14 +100,18 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   }
 
   get isSlackbotConfigured(): boolean {
-    const hasSlackbotType = !!configManager.getConfig('slackbot:currentBotType');
+    const hasSlackbotType = !!configManager.getConfig(
+      'slackbot:currentBotType',
+    );
     return hasSlackbotType;
   }
 
   get isSlackLegacyConfigured(): boolean {
     // for legacy util
     const hasSlackToken = !!configManager.getConfig('slack:token');
-    const hasSlackIwhUrl = !!configManager.getConfig('slack:incomingWebhookUrl');
+    const hasSlackIwhUrl = !!configManager.getConfig(
+      'slack:incomingWebhookUrl',
+    );
 
     return hasSlackToken || hasSlackIwhUrl;
   }
@@ -111,7 +119,9 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   private isCheckTypeValid(): boolean {
     const currentBotType = configManager.getConfig('slackbot:currentBotType');
     if (currentBotType == null) {
-      throw new Error('The config \'SLACKBOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
+      throw new Error(
+        "The config 'SLACKBOT_TYPE'(ns: 'crowi', key: 'slackbot:currentBotType') must be set.",
+      );
     }
 
     return true;
@@ -145,7 +155,9 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     const token = configManager.getConfig('slackbot:withoutProxy:botToken');
 
     if (token == null) {
-      throw new Error('The config \'SLACK_BOT_TOKEN\'(ns: \'crowi\', key: \'slackbot:withoutProxy:botToken\') must be set.');
+      throw new Error(
+        "The config 'SLACK_BOT_TOKEN'(ns: 'crowi', key: 'slackbot:withoutProxy:botToken') must be set.",
+      );
     }
 
     return generateWebClient(token);
@@ -160,13 +172,19 @@ export class SlackIntegrationService implements S2sMessageHandlable {
 
     const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 
-    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+    const slackAppIntegration = await SlackAppIntegration.findOne({
+      tokenPtoG,
+    });
 
     if (slackAppIntegration == null) {
-      throw new Error('No SlackAppIntegration exists that corresponds to the tokenPtoG specified.');
+      throw new Error(
+        'No SlackAppIntegration exists that corresponds to the tokenPtoG specified.',
+      );
     }
 
-    return this.generateClientBySlackAppIntegration(slackAppIntegration as unknown as { tokenGtoP: string; });
+    return this.generateClientBySlackAppIntegration(
+      slackAppIntegration as unknown as { tokenGtoP: string },
+    );
   }
 
   /**
@@ -184,20 +202,26 @@ export class SlackIntegrationService implements S2sMessageHandlable {
 
     // retrieve primary SlackAppIntegration
     const SlackAppIntegration = mongoose.model('SlackAppIntegration');
-    const slackAppIntegration = await SlackAppIntegration.findOne({ isPrimary: true });
+    const slackAppIntegration = await SlackAppIntegration.findOne({
+      isPrimary: true,
+    });
 
     if (slackAppIntegration == null) {
       throw new Error('None of the primary SlackAppIntegration exists.');
     }
 
-    return this.generateClientBySlackAppIntegration(slackAppIntegration as unknown as { tokenGtoP: string; });
+    return this.generateClientBySlackAppIntegration(
+      slackAppIntegration as unknown as { tokenGtoP: string },
+    );
   }
 
   /**
    * generate WebClient instance by SlackAppIntegration
    * @param slackAppIntegration
    */
-  async generateClientBySlackAppIntegration(slackAppIntegration: { tokenGtoP: string }): Promise<WebClient> {
+  async generateClientBySlackAppIntegration(slackAppIntegration: {
+    tokenGtoP: string;
+  }): Promise<WebClient> {
     this.isCheckTypeValid();
 
     // connect to proxy
@@ -209,33 +233,37 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     return generateWebClient(undefined, serverUri.toString(), headers);
   }
 
-  async postMessage(messageArgs: ChatPostMessageArguments, slackAppIntegration?: { tokenGtoP: string; }): Promise<void> {
+  async postMessage(
+    messageArgs: ChatPostMessageArguments,
+    slackAppIntegration?: { tokenGtoP: string },
+  ): Promise<void> {
     // use legacy slack configuration
     if (this.isSlackLegacyConfigured && !this.isSlackbotConfigured) {
       return this.postMessageWithLegacyUtil(messageArgs);
     }
 
-    const client = slackAppIntegration == null
-      ? await this.generateClientForPrimaryWorkspace()
-      : await this.generateClientBySlackAppIntegration(slackAppIntegration);
+    const client =
+      slackAppIntegration == null
+        ? await this.generateClientForPrimaryWorkspace()
+        : await this.generateClientBySlackAppIntegration(slackAppIntegration);
 
     try {
       await client.chat.postMessage(messageArgs);
-    }
-    catch (error) {
+    } catch (error) {
       logger.debug('Post error', error);
       logger.debug('Sent data to slack is:', messageArgs);
       throw error;
     }
   }
 
-  private async postMessageWithLegacyUtil(messageArgs: ChatPostMessageArguments | IncomingWebhookSendArguments): Promise<void> {
+  private async postMessageWithLegacyUtil(
+    messageArgs: ChatPostMessageArguments | IncomingWebhookSendArguments,
+  ): Promise<void> {
     const slackLegacyUtil = slackLegacyUtilFactory(configManager);
 
     try {
       await slackLegacyUtil.postMessage(messageArgs);
-    }
-    catch (error) {
+    } catch (error) {
       logger.debug('Post error', error);
       logger.debug('Sent data to slack is:', messageArgs);
       throw error;
@@ -245,22 +273,28 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   /**
    * Handle /commands endpoint
    */
-  async handleCommandRequest(growiCommand: GrowiCommand, client, body, respondUtil: RespondUtil): Promise<void> {
+  async handleCommandRequest(
+    growiCommand: GrowiCommand,
+    client,
+    body,
+    respondUtil: RespondUtil,
+  ): Promise<void> {
     const { growiCommandType } = growiCommand;
     const modulePath = `./slack-command-handler/${growiCommandType}`;
 
-    let handler;
+    let handler: any;
     try {
       handler = require(modulePath)(this.crowi);
-    }
-    catch (err) {
+    } catch (err) {
       const text = `*No command.*\n \`command: ${growiCommand.text}\``;
       logger.error(err);
       throw new SlackCommandHandlerError(text, {
         respondBody: {
           text,
           blocks: [
-            markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
+            markdownSectionBlock(
+              '*No command.*\n Hint\n `/growi [command] [keyword]`',
+            ),
           ],
         },
       });
@@ -271,48 +305,75 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   }
 
   async handleBlockActionsRequest(
-      client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor, respondUtil: RespondUtil,
+    client,
+    interactionPayload: any,
+    interactionPayloadAccessor: InteractionPayloadAccessor,
+    respondUtil: RespondUtil,
   ): Promise<void> {
-    const { actionId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+    const { actionId } =
+      interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = actionId.split(':')[0];
     const handlerMethodName = actionId.split(':')[1];
 
     const modulePath = `./slack-command-handler/${commandName}`;
 
-    let handler;
+    let handler: any;
     try {
       handler = require(modulePath)(this.crowi);
-    }
-    catch (err) {
-      throw new SlackCommandHandlerError(`No interaction.\n \`actionId: ${actionId}\``);
+    } catch (err) {
+      throw new SlackCommandHandlerError(
+        `No interaction.\n \`actionId: ${actionId}\``,
+      );
     }
 
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
-    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
+    return handler.handleInteractions(
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+      handlerMethodName,
+      respondUtil,
+    );
   }
 
   async handleViewSubmissionRequest(
-      client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor, respondUtil: RespondUtil,
+    client,
+    interactionPayload: any,
+    interactionPayloadAccessor: InteractionPayloadAccessor,
+    respondUtil: RespondUtil,
   ): Promise<void> {
-    const { callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+    const { callbackId } =
+      interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = callbackId.split(':')[0];
     const handlerMethodName = callbackId.split(':')[1];
 
     const modulePath = `./slack-command-handler/${commandName}`;
 
-    let handler;
+    let handler: any;
     try {
       handler = require(modulePath)(this.crowi);
-    }
-    catch (err) {
-      throw new SlackCommandHandlerError(`No interaction.\n \`callbackId: ${callbackId}\``);
+    } catch (err) {
+      throw new SlackCommandHandlerError(
+        `No interaction.\n \`callbackId: ${callbackId}\``,
+      );
     }
 
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
-    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
+    return handler.handleInteractions(
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+      handlerMethodName,
+      respondUtil,
+    );
   }
 
-  async handleEventsRequest(client: WebClient, growiBotEvent: GrowiBotEvent<any>, permission: EventActionsPermission, data?: any): Promise<void> {
+  async handleEventsRequest(
+    client: WebClient,
+    growiBotEvent: GrowiBotEvent<any>,
+    permission: EventActionsPermission,
+    data?: any,
+  ): Promise<void> {
     const { eventType } = growiBotEvent;
     const { channel = '' } = growiBotEvent.event; // only channelId
 
@@ -320,7 +381,8 @@ export class SlackIntegrationService implements S2sMessageHandlable {
       return this.linkSharedHandler.handleEvent(client, growiBotEvent, data);
     }
 
-    logger.error(`Any event actions are not permitted, or, a handler for '${eventType}' event is not implemented`);
+    logger.error(
+      `Any event actions are not permitted, or, a handler for '${eventType}' event is not implemented`,
+    );
   }
-
 }

+ 101 - 35
apps/app/src/server/service/user-group.ts

@@ -1,33 +1,54 @@
-import type { IUser, IGrantedGroup } from '@growi/core';
+import type { IGrantedGroup, IUser } from '@growi/core';
 import type { DeleteResult } from 'mongodb';
 import mongoose, { type Model } from 'mongoose';
 
 import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
-import type { UserGroupDocument, UserGroupModel } from '~/server/models/user-group';
+import type {
+  UserGroupDocument,
+  UserGroupModel,
+} from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
-import { excludeTestIdsFromTargetIds, includesObjectIds } from '~/server/util/compare-objectId';
+import {
+  excludeTestIdsFromTargetIds,
+  includesObjectIds,
+} from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
-import type { UserGroupRelationDocument, UserGroupRelationModel } from '../models/user-group-relation';
+import type {
+  UserGroupRelationDocument,
+  UserGroupRelationModel,
+} from '../models/user-group-relation';
 import UserGroupRelation from '../models/user-group-relation';
 
-
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 
 export interface IUserGroupService {
   init(): Promise<void>;
-  updateGroup(id: ObjectIdLike, name?: string, description?: string, parentId?: ObjectIdLike | null, forceUpdateParents?: boolean): Promise<UserGroupDocument>;
-  removeCompletelyByRootGroupId(deleteRootGroupId: ObjectIdLike, action: string, user: IUser, transferToUserGroup?: IGrantedGroup): Promise<DeleteResult>;
-  removeUserByUsername(userGroupId: ObjectIdLike, username: string): Promise<{user: IUser, deletedGroupsCount: number}>;
+  updateGroup(
+    id: ObjectIdLike,
+    name?: string,
+    description?: string,
+    parentId?: ObjectIdLike | null,
+    forceUpdateParents?: boolean,
+  ): Promise<UserGroupDocument>;
+  removeCompletelyByRootGroupId(
+    deleteRootGroupId: ObjectIdLike,
+    action: string,
+    user: IUser,
+    transferToUserGroup?: IGrantedGroup,
+  ): Promise<DeleteResult>;
+  removeUserByUsername(
+    userGroupId: ObjectIdLike,
+    username: string,
+  ): Promise<{ user: IUser; deletedGroupsCount: number }>;
 }
 
 /**
  * the service class of UserGroupService
  */
 class UserGroupService implements IUserGroupService {
-
   crowi: Crowi;
 
   constructor(crowi: Crowi) {
@@ -40,7 +61,13 @@ class UserGroupService implements IUserGroupService {
   }
 
   // ref: https://dev.growi.org/61b2cdabaa330ce7d8152844
-  async updateGroup(id, name?: string, description?: string, parentId?: string | null, forceUpdateParents = false): Promise<UserGroupDocument> {
+  async updateGroup(
+    id,
+    name?: string,
+    description?: string,
+    parentId?: string | null,
+    forceUpdateParents = false,
+  ): Promise<UserGroupDocument> {
     const userGroup = await UserGroup.findById(id);
     if (userGroup == null) {
       throw new Error('The group does not exist');
@@ -67,7 +94,8 @@ class UserGroupService implements IUserGroupService {
     /*
      * Update parent
      */
-    if (parentId === undefined) { // undefined will be ignored
+    if (parentId === undefined) {
+      // undefined will be ignored
       return userGroup.save();
     }
 
@@ -77,10 +105,10 @@ class UserGroupService implements IUserGroupService {
       return userGroup.save();
     }
 
-
     const parent = await UserGroup.findById(parentId);
 
-    if (parent == null) { // it should not be null
+    if (parent == null) {
+      // it should not be null
       throw Error('Parent group does not exist.');
     }
 
@@ -89,16 +117,26 @@ class UserGroupService implements IUserGroupService {
      */
 
     // throw if parent was in self and its descendants
-    const descendantsWithTarget = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
-    if (includesObjectIds(descendantsWithTarget.map(d => d._id), [parent._id])) {
+    const descendantsWithTarget =
+      await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
+    if (
+      includesObjectIds(
+        descendantsWithTarget.map((d) => d._id),
+        [parent._id],
+      )
+    ) {
       throw Error('It is not allowed to choose parent from descendant groups.');
     }
 
     // find users for comparison
-    const [targetGroupUsers, parentGroupUsers] = await Promise.all(
-      [UserGroupRelation.findUserIdsByGroupId(userGroup._id), UserGroupRelation.findUserIdsByGroupId(parent._id)],
+    const [targetGroupUsers, parentGroupUsers] = await Promise.all([
+      UserGroupRelation.findUserIdsByGroupId(userGroup._id),
+      UserGroupRelation.findUserIdsByGroupId(parent._id),
+    ]);
+    const usersBelongsToTargetButNotParent = excludeTestIdsFromTargetIds(
+      targetGroupUsers,
+      parentGroupUsers,
     );
-    const usersBelongsToTargetButNotParent = excludeTestIdsFromTargetIds(targetGroupUsers, parentGroupUsers);
 
     // save if no users exist in both target and parent groups
     if (targetGroupUsers.length === 0 && parentGroupUsers.length === 0) {
@@ -108,16 +146,22 @@ class UserGroupService implements IUserGroupService {
 
     // add the target group's users to all ancestors
     if (forceUpdateParents) {
-      const ancestorGroups = await UserGroup.findGroupsWithAncestorsRecursively(parent);
-      const ancestorGroupIds = ancestorGroups.map(group => group._id);
-
-      await UserGroupRelation.createByGroupIdsAndUserIds(ancestorGroupIds, usersBelongsToTargetButNotParent);
+      const ancestorGroups =
+        await UserGroup.findGroupsWithAncestorsRecursively(parent);
+      const ancestorGroupIds = ancestorGroups.map((group) => group._id);
+
+      await UserGroupRelation.createByGroupIdsAndUserIds(
+        ancestorGroupIds,
+        usersBelongsToTargetButNotParent,
+      );
     }
     // throw if any of users in the target group is NOT included in the parent group
     else {
       const isUpdatable = usersBelongsToTargetButNotParent.length === 0;
       if (!isUpdatable) {
-        throw Error('The parent group does not contain the users in this group.');
+        throw Error(
+          'The parent group does not contain the users in this group.',
+        );
       }
     }
 
@@ -126,28 +170,45 @@ class UserGroupService implements IUserGroupService {
   }
 
   async removeCompletelyByRootGroupId(
-      deleteRootGroupId, action: PageActionOnGroupDelete, user, transferToUserGroup?: IGrantedGroup,
-      userGroupModel: Model<UserGroupDocument> & UserGroupModel = UserGroup,
-      userGroupRelationModel: Model<UserGroupRelationDocument> & UserGroupRelationModel = UserGroupRelation,
+    deleteRootGroupId,
+    action: PageActionOnGroupDelete,
+    user,
+    transferToUserGroup?: IGrantedGroup,
+    userGroupModel: Model<UserGroupDocument> & UserGroupModel = UserGroup,
+    userGroupRelationModel: Model<UserGroupRelationDocument> &
+      UserGroupRelationModel = UserGroupRelation,
   ): Promise<DeleteResult> {
     const rootGroup = await userGroupModel.findById(deleteRootGroupId);
     if (rootGroup == null) {
-      throw new Error(`UserGroup data does not exist. id: ${deleteRootGroupId}`);
+      throw new Error(
+        `UserGroup data does not exist. id: ${deleteRootGroupId}`,
+      );
     }
 
-    const groupsToDelete = await userGroupModel.findGroupsWithDescendantsRecursively([rootGroup]);
+    const groupsToDelete =
+      await userGroupModel.findGroupsWithDescendantsRecursively([rootGroup]);
 
     // 1. update page & remove all groups
-    await this.crowi.pageService.handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroup, user);
+    await this.crowi.pageService.handlePrivatePagesForGroupsToDelete(
+      groupsToDelete,
+      action,
+      transferToUserGroup,
+      user,
+    );
     // 2. remove all groups
-    const deletedGroups = await userGroupModel.deleteMany({ _id: { $in: groupsToDelete.map(g => g._id) } });
+    const deletedGroups = await userGroupModel.deleteMany({
+      _id: { $in: groupsToDelete.map((g) => g._id) },
+    });
     // 3. remove all relations
     await userGroupRelationModel.removeAllByUserGroups(groupsToDelete);
 
     return deletedGroups;
   }
 
-  async removeUserByUsername(userGroupId: ObjectIdLike, username: string): Promise<{user: IUser, deletedGroupsCount: number}> {
+  async removeUserByUsername(
+    userGroupId: ObjectIdLike,
+    username: string,
+  ): Promise<{ user: IUser; deletedGroupsCount: number }> {
     const User = mongoose.model<IUser, { findUserByUsername }>('User');
 
     const [userGroup, user] = await Promise.all([
@@ -155,14 +216,19 @@ class UserGroupService implements IUserGroupService {
       User.findUserByUsername(username),
     ]);
 
-    const groupsOfRelationsToDelete = userGroup != null ? await UserGroup.findGroupsWithDescendantsRecursively([userGroup]) : [];
-    const relatedGroupIdsToDelete = groupsOfRelationsToDelete.map(g => g._id);
+    const groupsOfRelationsToDelete =
+      userGroup != null
+        ? await UserGroup.findGroupsWithDescendantsRecursively([userGroup])
+        : [];
+    const relatedGroupIdsToDelete = groupsOfRelationsToDelete.map((g) => g._id);
 
-    const deleteManyRes = await UserGroupRelation.deleteMany({ relatedUser: user._id, relatedGroup: { $in: relatedGroupIdsToDelete } });
+    const deleteManyRes = await UserGroupRelation.deleteMany({
+      relatedUser: user._id,
+      relatedGroup: { $in: relatedGroupIdsToDelete },
+    });
 
     return { user, deletedGroupsCount: deleteManyRes.deletedCount };
   }
-
 }
 
 export default UserGroupService;

+ 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 ?? []],

+ 22 - 3
biome.json

@@ -30,10 +30,29 @@
       "!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"
+      "!apps/app/src/server/service/access-token",
+      "!apps/app/src/server/service/config-manager",
+      "!apps/app/src/server/service/file-uploader",
+      "!apps/app/src/server/service/global-notification",
+      "!apps/app/src/server/service/growi-bridge",
+      "!apps/app/src/server/service/growi-info",
+      "!apps/app/src/server/service/import",
+      "!apps/app/src/server/service/in-app-notification",
+      "!apps/app/src/server/service/interfaces",
+      "!apps/app/src/server/service/normalize-data",
+      "!apps/app/src/server/service/page",
+      "!apps/app/src/server/service/page-listing",
+      "!apps/app/src/server/service/revision",
+      "!apps/app/src/server/service/s2s-messaging",
+      "!apps/app/src/server/service/search-delegator",
+      "!apps/app/src/server/service/search-reconnect-context",
+      "!apps/app/src/server/service/slack-command-handler",
+      "!apps/app/src/server/service/slack-event-handler",
+      "!apps/app/src/server/service/socket-io",
+      "!apps/app/src/server/service/system-events",
+      "!apps/app/src/server/service/user-notification",
+      "!apps/app/src/server/service/yjs"
     ]
   },
   "formatter": {

+ 9 - 9
pnpm-lock.yaml

@@ -767,8 +767,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
@@ -14937,8 +14937,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:
@@ -17765,7 +17765,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
 
@@ -24934,7 +24934,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:
@@ -28265,7 +28265,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:
@@ -32214,7 +32214,7 @@ snapshots:
 
   validate-npm-package-name@5.0.1: {}
 
-  validator@13.15.20: {}
+  validator@13.15.23: {}
 
   vary@1.1.2: {}
 
@@ -32837,7 +32837,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
 

Некоторые файлы не были показаны из-за большого количества измененных файлов