Browse Source

Merge branch 'dev/7.4.x' into feat/160341-172734-add-editor-guide

Yuki Takei 5 months ago
parent
commit
405a15cd79
100 changed files with 1894 additions and 1221 deletions
  1. 37 1
      CHANGELOG.md
  2. 10 0
      apps/app/.eslintrc.js
  3. 2 2
      apps/app/package.json
  4. 12 1
      apps/app/public/static/locales/en_US/translation.json
  5. 12 1
      apps/app/public/static/locales/fr_FR/translation.json
  6. 12 1
      apps/app/public/static/locales/ja_JP/translation.json
  7. 12 1
      apps/app/public/static/locales/ko_KR/translation.json
  8. 12 1
      apps/app/public/static/locales/zh_CN/translation.json
  9. 3 2
      apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx
  10. 9 2
      apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx
  11. 3 1
      apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx
  12. 5 3
      apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx
  13. 5 3
      apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx
  14. 14 11
      apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx
  15. 6 2
      apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx
  16. 20 17
      apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx
  17. 13 11
      apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx
  18. 15 4
      apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx
  19. 3 3
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.jsx
  20. 27 22
      apps/app/src/client/components/InvitedForm.tsx
  21. 3 0
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  22. 1 1
      apps/app/src/client/components/PageEditor/DrawioModal/DrawioModal.tsx
  23. 8 1
      apps/app/src/client/components/PageEditor/DrawioModal/dynamic.tsx
  24. 3 1
      apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx
  25. 0 8
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss
  26. 4 1
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx
  27. 0 3
      apps/app/src/client/components/PageSideContents/PageSideContents.module.scss
  28. 1 3
      apps/app/src/client/components/PageSideContents/PageSideContents.tsx
  29. 81 0
      apps/app/src/client/components/RecentActivity/ActivityListItem.tsx
  30. 83 0
      apps/app/src/client/components/RecentActivity/RecentActivity.tsx
  31. 9 0
      apps/app/src/client/components/UsersHomepageFooter.tsx
  32. 16 3
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  33. 10 18
      apps/app/src/client/services/AdminGitHubSecurityContainer.js
  34. 9 19
      apps/app/src/client/services/AdminGoogleSecurityContainer.js
  35. 27 89
      apps/app/src/client/services/AdminLdapSecurityContainer.js
  36. 12 14
      apps/app/src/client/services/AdminLocalSecurityContainer.js
  37. 39 153
      apps/app/src/client/services/AdminOidcSecurityContainer.js
  38. 15 66
      apps/app/src/client/services/AdminSamlSecurityContainer.js
  39. 28 0
      apps/app/src/client/services/use-print-mode.ts
  40. 2 2
      apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx
  41. 4 6
      apps/app/src/components/Common/PagePathNavTitle/PagePathNavTitle.tsx
  42. 2 3
      apps/app/src/components/PageView/PageContentFooter.tsx
  43. 7 2
      apps/app/src/components/PageView/PageViewLayout.tsx
  44. 8 2
      apps/app/src/features/search/client/components/SearchPage/SearchResultList.tsx
  45. 32 2
      apps/app/src/interfaces/activity.ts
  46. 12 4
      apps/app/src/pages/[[...path]]/server-side-props.ts
  47. 11 10
      apps/app/src/pages/_document.page.tsx
  48. 1 10
      apps/app/src/pages/admin/customize.page.tsx
  49. 22 1
      apps/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  50. 6 1
      apps/app/src/pages/common-props/commons.ts
  51. 0 0
      apps/app/src/pages/me/[[...path]].page.tsx
  52. 3 14
      apps/app/src/pages/share/[[...path]]/index.page.tsx
  53. 47 32
      apps/app/src/pages/share/[[...path]]/page-data-props.ts
  54. 14 5
      apps/app/src/pages/share/[[...path]]/types.ts
  55. 8 2
      apps/app/src/server/crowi/index.js
  56. 25 16
      apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts
  57. 10 7
      apps/app/src/server/middlewares/access-token-parser/access-token.ts
  58. 8 12
      apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts
  59. 9 4
      apps/app/src/server/middlewares/access-token-parser/api-token.ts
  60. 3 1
      apps/app/src/server/middlewares/access-token-parser/extract-bearer-token.ts
  61. 9 3
      apps/app/src/server/middlewares/access-token-parser/index.ts
  62. 7 6
      apps/app/src/server/middlewares/access-token-parser/interfaces.ts
  63. 30 26
      apps/app/src/server/middlewares/add-activity.ts
  64. 2 4
      apps/app/src/server/middlewares/admin-required.js
  65. 2 1
      apps/app/src/server/middlewares/apiv1-form-validator.ts
  66. 8 3
      apps/app/src/server/middlewares/apiv3-form-validator.ts
  67. 3 2
      apps/app/src/server/middlewares/application-installed.ts
  68. 32 14
      apps/app/src/server/middlewares/application-not-installed.ts
  69. 4 1
      apps/app/src/server/middlewares/auto-reconnect-to-s2s-msg-server.js
  70. 11 5
      apps/app/src/server/middlewares/auto-reconnect-to-search.js
  71. 1 3
      apps/app/src/server/middlewares/certify-brand-logo.ts
  72. 13 9
      apps/app/src/server/middlewares/certify-origin.ts
  73. 38 19
      apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.spec.ts
  74. 15 9
      apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts
  75. 2 2
      apps/app/src/server/middlewares/certify-shared-page-attachment/interfaces.ts
  76. 19 8
      apps/app/src/server/middlewares/certify-shared-page-attachment/retrieve-valid-share-link.ts
  77. 10 5
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-attachment.ts
  78. 0 4
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.spec.ts
  79. 7 6
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.ts
  80. 5 8
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.spec.ts
  81. 17 10
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.ts
  82. 5 4
      apps/app/src/server/middlewares/certify-shared-page.js
  83. 8 6
      apps/app/src/server/middlewares/exclude-read-only-user.spec.ts
  84. 23 11
      apps/app/src/server/middlewares/exclude-read-only-user.ts
  85. 6 9
      apps/app/src/server/middlewares/http-error-handler.js
  86. 24 10
      apps/app/src/server/middlewares/inject-reset-order-by-token-middleware.ts
  87. 26 8
      apps/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts
  88. 10 5
      apps/app/src/server/middlewares/invited-form-validator.ts
  89. 6 3
      apps/app/src/server/middlewares/login-form-validator.ts
  90. 4 6
      apps/app/src/server/middlewares/login-required.js
  91. 18 4
      apps/app/src/server/middlewares/register-form-validator.ts
  92. 18 13
      apps/app/src/server/middlewares/safe-redirect.spec.ts
  93. 29 21
      apps/app/src/server/middlewares/safe-redirect.ts
  94. 18 8
      apps/app/src/server/middlewares/unavailable-when-maintenance-mode.ts
  95. 75 44
      apps/app/src/server/routes/admin.js
  96. 50 37
      apps/app/src/server/routes/apiv3/activity.ts
  97. 26 15
      apps/app/src/server/routes/apiv3/admin-home.ts
  98. 157 94
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  99. 315 157
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  100. 26 13
      apps/app/src/server/routes/apiv3/healthcheck.ts

+ 37 - 1
CHANGELOG.md

@@ -1,9 +1,45 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.3.4...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.3.7...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.3.7](https://github.com/growilabs/compare/v7.3.6...v7.3.7) - 2025-11-25
+
+### 💎 Features
+
+* feat(pdf-converter): Enable puppeteer-cluster config of pdf-converter from env var (#10516) @arafubeatbox
+
+### 🐛 Bug Fixes
+
+* fix: Admin form degradation (#10540) @yuki-takei
+
+## [v7.3.6](https://github.com/growilabs/compare/v7.3.5...v7.3.6) - 2025-11-18
+
+### 🐛 Bug Fixes
+
+* fix: Printing styles (#10505) @yuki-takei
+
+### 🧰 Maintenance
+
+* ci(deps): bump js-yaml from 4.1.0 to 4.1.1 (#10511) @[dependabot[bot]](https://github.com/apps/dependabot)
+* support: Configure biome for app routes excluding apiv3 (#10496) @arafubeatbox
+
+## [v7.3.5](https://github.com/growilabs/compare/v7.3.4...v7.3.5) - 2025-11-10
+
+### 💎 Features
+
+* feat: Activity Log on the user page for viewing recent activity (#10487) @arvid-e
+
+### 🐛 Bug Fixes
+
+* fix: PDF-converter major/minor tags not updated on release (#10476) @arafubeatbox
+
+### 🧰 Maintenance
+
+* support: Configure biome for app/src/server/models dir (#10419) @arafubeatbox
+* support: Playwright tests biome migration (#10248) @arafubeatbox
+
 ## [v7.3.4](https://github.com/growilabs/compare/v7.3.3...v7.3.4) - 2025-11-04
 
 ### 🚀 Improvement

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

@@ -60,6 +60,16 @@ module.exports = {
     'src/server/util/**',
     'src/server/app.ts',
     'src/server/repl.ts',
+    'src/server/middlewares/**',
+    'src/server/routes/*.js',
+    'src/server/routes/*.ts',
+    'src/server/routes/attachment/**',
+    'src/server/routes/apiv3/interfaces/**',
+    'src/server/routes/apiv3/pages/**',
+    'src/server/routes/apiv3/user/**',
+    'src/server/routes/apiv3/personal-setting/**',
+    'src/server/routes/apiv3/security-settings/**',
+    'src/server/routes/apiv3/*.ts',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 2 - 2
apps/app/package.json

@@ -28,7 +28,7 @@
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
-    "lint:biome": "biome check",
+    "lint:biome": "biome check --diagnostic-level=error",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
@@ -150,7 +150,7 @@
     "jotai": "^2.12.3",
     "js-cookie": "^3.0.5",
     "js-tiktoken": "^1.0.15",
-    "js-yaml": "^4.1.0",
+    "js-yaml": "^4.1.1",
     "jsonrepair": "^3.12.0",
     "katex": "^0.16.21",
     "ldapjs": "^3.0.2",

+ 12 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -999,7 +999,18 @@
   },
   "user_home_page": {
     "bookmarks": "Bookmarks",
-    "recently_created": "Recently Created"
+    "recently_created": "Recently Created",
+    "recent_activity": "Recent Activity",
+    "unknown_action": "made an unspecified change",
+    "page_create": "created a page",
+    "page_update": "updated a page",
+    "page_delete": "deleted a page",
+    "page_delete_completely": "deleted a page",
+    "page_rename": "renamed a page",
+    "page_revert": "reverted a page",
+    "page_like": "liked a page",
+    "page_duplicate": "duplicated a page",
+    "comment_create": "posted a comment"
   },
   "bookmark_folder": {
     "bookmark_folder": "bookmark folder",

+ 12 - 1
apps/app/public/static/locales/fr_FR/translation.json

@@ -993,7 +993,18 @@
   },
   "user_home_page": {
     "bookmarks": "Favoris",
-    "recently_created": "Page récentes"
+    "recently_created": "Page récentes",
+    "recent_activity": "Activité récente",
+    "unknown_action": "a effectué une modification non spécifiée",
+    "page_create": "a créé une page",
+    "page_update": "a mis à jour une page",
+    "page_delete": "a supprimé une page",
+    "page_delete_completely": "a supprimé complètement une page",
+    "page_rename": "a renommé une page",
+    "page_revert": "a restauré une page",
+    "page_duplicate": "a dupliqué une page",
+    "page_like": "a aimé une page",
+    "comment_create": "a publié un commentaire"
   },
   "bookmark_folder": {
     "bookmark_folder": "dossier de favoris",

+ 12 - 1
apps/app/public/static/locales/ja_JP/translation.json

@@ -1032,7 +1032,18 @@
   },
   "user_home_page": {
     "bookmarks": "ブックマーク",
-    "recently_created": "最近作成したページ"
+    "recently_created": "最近作成したページ",
+    "recent_activity": "最近のアクティビティ",
+    "unknown_action": "未指定の変更を加えました",
+    "page_create": "ページを作成しました",
+    "page_update": "ページを更新しました",
+    "page_delete": "ページを削除しました",
+    "page_delete_completely": "ページを完全に削除しました",
+    "page_rename": "ページの名前を変更しました",
+    "page_revert": "ページを元に戻しました",
+    "page_duplicate": "ページを複製しました",
+    "page_like": "ページをいいねしました",
+    "comment_create": "コメントを投稿しました"
   },
   "bookmark_folder": {
     "bookmark_folder": "ブックマークフォルダ",

+ 12 - 1
apps/app/public/static/locales/ko_KR/translation.json

@@ -959,7 +959,18 @@
   },
   "user_home_page": {
     "bookmarks": "북마크",
-    "recently_created": "최근 생성됨"
+    "recently_created": "최근 생성됨",
+    "recent_activity": "최근 활동",
+    "unknown_action": "지정되지 않은 변경 사항을 적용했습니다",
+    "page_create": "페이지를 생성했습니다",
+    "page_update": "페이지를 업데이트했습니다",
+    "page_delete": "페이지를 삭제했습니다",
+    "page_delete_completely": "페이지를 완전히 삭제했습니다",
+    "page_rename": "페이지 이름을 변경했습니다",
+    "page_revert": "페이지를 되돌렸습니다",
+    "page_duplicate": "페이지를 복제했습니다",
+    "page_like": "페이지에 좋아요를 눌렀습니다",
+    "comment_create": "댓글을 게시했습니다"
   },
   "bookmark_folder": {
     "bookmark_folder": "북마크 폴더",

+ 12 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -1004,7 +1004,18 @@
   },
   "user_home_page": {
     "bookmarks": "书签",
-    "recently_created": "最近创建页面"
+    "recently_created": "最近创建页面",
+    "recent_activity": "最近动态",
+    "unknown_action": "进行了未指明的更改",
+    "page_create": "创建了页面",
+    "page_update": "更新了页面",
+    "page_delete": "删除了页面",
+    "page_delete_completely": "彻底删除了页面",
+    "page_rename": "重命名了页面",
+    "page_revert": "还原了页面",
+    "page_duplicate": "复制了页面",
+    "page_like": "赞了页面",
+    "comment_create": "发布了评论"
   },
   "bookmark_folder": {
     "bookmark_folder": "书签文件夹",

+ 3 - 2
apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx

@@ -17,6 +17,7 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import { EnvVarsTable } from './EnvVarsTable';
 import SystemInfomationTable from './SystemInfomationTable';
 
+
 const logger = loggerFactory('growi:admin');
 
 const AdminHome = (props) => {
@@ -59,7 +60,7 @@ const AdminHome = (props) => {
         )
       }
       {
-      // Alert message will be displayed in case that V5 migration has not been compleated
+        // Alert message will be displayed in case that V5 migration has not been compleated
         (migrationStatus != null && !migrationStatus.isV5Compatible)
         && (
           <div className={`alert ${migrationStatus.isV5Compatible == null ? 'alert-warning' : 'alert-info'}`}>
@@ -90,7 +91,7 @@ const AdminHome = (props) => {
           <p>{t('admin:admin_top.env_var_priority')}</p>
           {/* eslint-disable-next-line react/no-danger */}
           <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
-          {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
+          <EnvVarsTable envVars={adminHomeContainer.state.envVars} />
         </div>
       </div>
 

+ 9 - 2
apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx

@@ -1,13 +1,20 @@
 import React, { type JSX } from 'react';
 
+import { LoadingSpinner } from '@growi/ui/dist/components';
+
 type EnvVarsTableProps = {
-  envVars: Record<string, string | number | boolean>,
+  envVars?: Record<string, string | number | boolean>,
 }
 
 export const EnvVarsTable: React.FC<EnvVarsTableProps> = (props: EnvVarsTableProps) => {
+  const { envVars } = props;
+  if (envVars == null) {
+    return <LoadingSpinner />;
+  }
+
   const envVarRows: JSX.Element[] = [];
 
-  for (const [key, value] of Object.entries(props.envVars)) {
+  for (const [key, value] of Object.entries(envVars ?? {})) {
     if (value != null) {
       envVarRows.push(
         <tr key={key}>

+ 3 - 1
apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx

@@ -1,5 +1,7 @@
 import React from 'react';
 
+import { LoadingSpinner } from '@growi/ui/dist/components';
+
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -17,7 +19,7 @@ const SystemInformationTable = (props: Props) => {
   } = adminHomeContainer.state;
 
   if (growiVersion == null || nodeVersion == null || npmVersion == null || pnpmVersion == null) {
-    return <></>;
+    return <LoadingSpinner />;
   }
 
   return (

+ 5 - 3
apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx

@@ -43,9 +43,11 @@ const GitHubSecurityManagementContents = (props: Props) => {
 
   const onClickSubmit = useCallback(async(data) => {
     try {
-      await adminGitHubSecurityContainer.changeGitHubClientId(data.githubClientId ?? '');
-      await adminGitHubSecurityContainer.changeGitHubClientSecret(data.githubClientSecret ?? '');
-      await adminGitHubSecurityContainer.updateGitHubSetting();
+      await adminGitHubSecurityContainer.updateGitHubSetting({
+        githubClientId: data.githubClientId ?? '',
+        githubClientSecret: data.githubClientSecret ?? '',
+        isSameUsernameTreatedAsIdenticalUser: adminGitHubSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser,
+      });
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.OAuth.GitHub.updated_github'));
     }

+ 5 - 3
apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx

@@ -42,9 +42,11 @@ const GoogleSecurityManagementContents = (props: Props) => {
 
   const onClickSubmit = useCallback(async(data) => {
     try {
-      await adminGoogleSecurityContainer.changeGoogleClientId(data.googleClientId ?? '');
-      await adminGoogleSecurityContainer.changeGoogleClientSecret(data.googleClientSecret ?? '');
-      await adminGoogleSecurityContainer.updateGoogleSetting();
+      await adminGoogleSecurityContainer.updateGoogleSetting({
+        googleClientId: data.googleClientId ?? '',
+        googleClientSecret: data.googleClientSecret ?? '',
+        isSameEmailTreatedAsIdenticalUser: adminGoogleSecurityContainer.state.isSameEmailTreatedAsIdenticalUser,
+      });
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.OAuth.Google.updated_google'));
     }

+ 14 - 11
apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx

@@ -56,17 +56,20 @@ const LdapSecuritySettingContents = (props: Props) => {
 
   const onSubmit = useCallback(async(data) => {
     try {
-      await adminLdapSecurityContainer.changeServerUrl(data.serverUrl);
-      await adminLdapSecurityContainer.changeBindDN(data.ldapBindDN);
-      await adminLdapSecurityContainer.changeBindDNPassword(data.ldapBindDNPassword);
-      await adminLdapSecurityContainer.changeSearchFilter(data.ldapSearchFilter);
-      await adminLdapSecurityContainer.changeAttrMapUsername(data.ldapAttrMapUsername);
-      await adminLdapSecurityContainer.changeAttrMapMail(data.ldapAttrMapMail);
-      await adminLdapSecurityContainer.changeAttrMapName(data.ldapAttrMapName);
-      await adminLdapSecurityContainer.changeGroupSearchBase(data.ldapGroupSearchBase);
-      await adminLdapSecurityContainer.changeGroupSearchFilter(data.ldapGroupSearchFilter);
-      await adminLdapSecurityContainer.changeGroupDnProperty(data.ldapGroupDnProperty);
-      await adminLdapSecurityContainer.updateLdapSetting();
+      await adminLdapSecurityContainer.updateLdapSetting({
+        serverUrl: data.serverUrl,
+        isUserBind: adminLdapSecurityContainer.state.isUserBind,
+        ldapBindDN: data.ldapBindDN,
+        ldapBindDNPassword: data.ldapBindDNPassword,
+        ldapSearchFilter: data.ldapSearchFilter,
+        ldapAttrMapUsername: data.ldapAttrMapUsername,
+        isSameUsernameTreatedAsIdenticalUser: adminLdapSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser,
+        ldapAttrMapMail: data.ldapAttrMapMail,
+        ldapAttrMapName: data.ldapAttrMapName,
+        ldapGroupSearchBase: data.ldapGroupSearchBase,
+        ldapGroupSearchFilter: data.ldapGroupSearchFilter,
+        ldapGroupDnProperty: data.ldapGroupDnProperty,
+      });
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.ldap.updated_ldap'));
     }

+ 6 - 2
apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx

@@ -38,8 +38,12 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
 
   const onSubmit = useCallback(async(data) => {
     try {
-      await adminLocalSecurityContainer.changeRegistrationWhitelist(data.registrationWhitelist);
-      await adminLocalSecurityContainer.updateLocalSecuritySetting();
+      await adminLocalSecurityContainer.updateLocalSecuritySetting({
+        registrationMode: adminLocalSecurityContainer.state.registrationMode,
+        registrationWhitelist: data.registrationWhitelist.split('\n'),
+        isPasswordResetEnabled: adminLocalSecurityContainer.state.isPasswordResetEnabled,
+        isEmailAuthenticationEnabled: adminLocalSecurityContainer.state.isEmailAuthenticationEnabled,
+      });
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.updated_general_security_setting'));
     }

+ 20 - 17
apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx

@@ -65,23 +65,26 @@ const OidcSecurityManagementContents = (props: Props) => {
 
   const onSubmit = useCallback(async(data) => {
     try {
-      await adminOidcSecurityContainer.changeOidcProviderName(data.oidcProviderName);
-      await adminOidcSecurityContainer.changeOidcIssuerHost(data.oidcIssuerHost);
-      await adminOidcSecurityContainer.changeOidcClientId(data.oidcClientId);
-      await adminOidcSecurityContainer.changeOidcClientSecret(data.oidcClientSecret);
-      await adminOidcSecurityContainer.changeOidcAuthorizationEndpoint(data.oidcAuthorizationEndpoint);
-      await adminOidcSecurityContainer.changeOidcTokenEndpoint(data.oidcTokenEndpoint);
-      await adminOidcSecurityContainer.changeOidcRevocationEndpoint(data.oidcRevocationEndpoint);
-      await adminOidcSecurityContainer.changeOidcIntrospectionEndpoint(data.oidcIntrospectionEndpoint);
-      await adminOidcSecurityContainer.changeOidcUserInfoEndpoint(data.oidcUserInfoEndpoint);
-      await adminOidcSecurityContainer.changeOidcEndSessionEndpoint(data.oidcEndSessionEndpoint);
-      await adminOidcSecurityContainer.changeOidcRegistrationEndpoint(data.oidcRegistrationEndpoint);
-      await adminOidcSecurityContainer.changeOidcJWKSUri(data.oidcJWKSUri);
-      await adminOidcSecurityContainer.changeOidcAttrMapId(data.oidcAttrMapId);
-      await adminOidcSecurityContainer.changeOidcAttrMapUserName(data.oidcAttrMapUserName);
-      await adminOidcSecurityContainer.changeOidcAttrMapName(data.oidcAttrMapName);
-      await adminOidcSecurityContainer.changeOidcAttrMapEmail(data.oidcAttrMapEmail);
-      await adminOidcSecurityContainer.updateOidcSetting();
+      await adminOidcSecurityContainer.updateOidcSetting({
+        oidcProviderName: data.oidcProviderName,
+        oidcIssuerHost: data.oidcIssuerHost,
+        oidcClientId: data.oidcClientId,
+        oidcClientSecret: data.oidcClientSecret,
+        oidcAuthorizationEndpoint: data.oidcAuthorizationEndpoint,
+        oidcTokenEndpoint: data.oidcTokenEndpoint,
+        oidcRevocationEndpoint: data.oidcRevocationEndpoint,
+        oidcIntrospectionEndpoint: data.oidcIntrospectionEndpoint,
+        oidcUserInfoEndpoint: data.oidcUserInfoEndpoint,
+        oidcEndSessionEndpoint: data.oidcEndSessionEndpoint,
+        oidcRegistrationEndpoint: data.oidcRegistrationEndpoint,
+        oidcJWKSUri: data.oidcJWKSUri,
+        oidcAttrMapId: data.oidcAttrMapId,
+        oidcAttrMapUserName: data.oidcAttrMapUserName,
+        oidcAttrMapName: data.oidcAttrMapName,
+        oidcAttrMapEmail: data.oidcAttrMapEmail,
+        isSameUsernameTreatedAsIdenticalUser: adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser: adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser,
+      });
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.OAuth.OIDC.updated_oidc'));
     }

+ 13 - 11
apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx

@@ -46,18 +46,20 @@ const SamlSecurityManagementContents = (props: Props) => {
   }, [adminSamlSecurityContainer.state, reset]);
 
   const onSubmit = useCallback(async(data) => {
-    adminSamlSecurityContainer.changeSamlEntryPoint(data.samlEntryPoint);
-    adminSamlSecurityContainer.changeSamlIssuer(data.samlIssuer);
-    adminSamlSecurityContainer.changeSamlCert(data.samlCert);
-    adminSamlSecurityContainer.changeSamlAttrMapId(data.samlAttrMapId);
-    adminSamlSecurityContainer.changeSamlAttrMapUserName(data.samlAttrMapUsername);
-    adminSamlSecurityContainer.changeSamlAttrMapMail(data.samlAttrMapMail);
-    adminSamlSecurityContainer.changeSamlAttrMapFirstName(data.samlAttrMapFirstName);
-    adminSamlSecurityContainer.changeSamlAttrMapLastName(data.samlAttrMapLastName);
-    adminSamlSecurityContainer.changeSamlABLCRule(data.samlABLCRule);
-
     try {
-      await adminSamlSecurityContainer.updateSamlSetting();
+      await adminSamlSecurityContainer.updateSamlSetting({
+        samlEntryPoint: data.samlEntryPoint,
+        samlIssuer: data.samlIssuer,
+        samlCert: data.samlCert,
+        samlAttrMapId: data.samlAttrMapId,
+        samlAttrMapUsername: data.samlAttrMapUsername,
+        samlAttrMapMail: data.samlAttrMapMail,
+        samlAttrMapFirstName: data.samlAttrMapFirstName,
+        samlAttrMapLastName: data.samlAttrMapLastName,
+        isSameUsernameTreatedAsIdenticalUser: adminSamlSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser: adminSamlSecurityContainer.state.isSameEmailTreatedAsIdenticalUser,
+        samlABLCRule: data.samlABLCRule,
+      });
       toastSuccess(t('security_settings.SAML.updated_saml'));
     }
     catch (err) {

+ 15 - 4
apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx

@@ -36,10 +36,21 @@ const SecuritySettingComponent: React.FC<Props> = ({ adminGeneralSecurityContain
 
   const onSubmit = useCallback(async(data: FormData) => {
     try {
-      // Update sessionMaxAge from form data
-      await adminGeneralSecurityContainer.setSessionMaxAge(data.sessionMaxAge);
-      // Save all security settings
-      await adminGeneralSecurityContainer.updateGeneralSecuritySetting();
+      // Save all security settings with form data
+      await adminGeneralSecurityContainer.updateGeneralSecuritySetting({
+        sessionMaxAge: data.sessionMaxAge,
+        restrictGuestMode: adminGeneralSecurityContainer.state.currentRestrictGuestMode,
+        pageDeletionAuthority: adminGeneralSecurityContainer.state.currentPageDeletionAuthority,
+        pageCompleteDeletionAuthority: adminGeneralSecurityContainer.state.currentPageCompleteDeletionAuthority,
+        pageRecursiveDeletionAuthority: adminGeneralSecurityContainer.state.currentPageRecursiveDeletionAuthority,
+        pageRecursiveCompleteDeletionAuthority: adminGeneralSecurityContainer.state.currentPageRecursiveCompleteDeletionAuthority,
+        isAllGroupMembershipRequiredForPageCompleteDeletion: adminGeneralSecurityContainer.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
+        hideRestrictedByGroup: adminGeneralSecurityContainer.state.currentGroupRestrictionDisplayMode === 'Hidden',
+        hideRestrictedByOwner: adminGeneralSecurityContainer.state.currentOwnerRestrictionDisplayMode === 'Hidden',
+        isUsersHomepageDeletionEnabled: adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled,
+        isForceDeleteUserHomepageOnUserDeletion: adminGeneralSecurityContainer.state.isForceDeleteUserHomepageOnUserDeletion,
+        isRomUserAllowedToComment: adminGeneralSecurityContainer.state.isRomUserAllowedToComment,
+      });
       toastSuccess(t('security_settings.updated_general_security_setting'));
     }
     catch (err) {

+ 3 - 3
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.jsx

@@ -7,7 +7,7 @@ import { EditorMode, useEditorMode } from '~/states/ui/editor';
 
 const EditPage = (props) => {
   const isEditable = useIsEditable();
-  const { mutate: mutateEditorMode } = useEditorMode();
+  const { setEditorMode } = useEditorMode();
 
   // setup effect
   useEffect(() => {
@@ -20,11 +20,11 @@ const EditPage = (props) => {
       return;
     }
 
-    mutateEditorMode(EditorMode.Editor);
+    setEditorMode(EditorMode.Editor);
 
     // remove this
     props.onDeleteRender(this);
-  }, [isEditable, mutateEditorMode, props]);
+  }, [isEditable, props, setEditorMode]);
 
   return null;
 };

+ 27 - 22
apps/app/src/client/components/InvitedForm.tsx

@@ -3,18 +3,23 @@ import React, { useCallback, useState, type JSX } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
+import { useForm } from 'react-hook-form';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { useCurrentUser } from '~/states/global';
 
-
 type InvitedFormProps = {
   invitedFormUsername: string,
   invitedFormName: string,
 }
 
-export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
+type InvitedFormValues = {
+  name: string,
+  username: string,
+  password: string,
+};
 
+export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
   const { t } = useTranslation();
   const router = useRouter();
   const user = useCurrentUser();
@@ -23,22 +28,24 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
 
   const { invitedFormUsername, invitedFormName } = props;
 
-  const submitHandler = useCallback(async(e) => {
-    e.preventDefault();
+  const {
+    register,
+    handleSubmit,
+    formState: { isSubmitting },
+  } = useForm<InvitedFormValues>({
+    defaultValues: {
+      name: invitedFormName,
+      username: invitedFormUsername,
+    },
+  });
+
+  const submitHandler = useCallback(async(values: InvitedFormValues) => {
     setIsLoading(true);
 
-    const formData = e.target.elements;
-
-    const {
-      'invitedForm[name]': { value: name },
-      'invitedForm[password]': { value: password },
-      'invitedForm[username]': { value: username },
-    } = formData;
-
     const invitedForm = {
-      name,
-      password,
-      username,
+      name: values.name,
+      username: values.username,
+      password: values.password,
     };
 
     try {
@@ -79,7 +86,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
   return (
     <div className="nologin-dialog px-3 pb-3 mx-auto" id="nologin-dialog">
       { formNotification() }
-      <form role="form" onSubmit={submitHandler} id="invited-form">
+      <form role="form" onSubmit={handleSubmit(submitHandler)} id="invited-form">
         {/* Email Form */}
         <div className="input-group">
           <span className="input-group-text">
@@ -104,9 +111,8 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
             type="text"
             className="form-control"
             placeholder={t('User ID')}
-            name="invitedForm[username]"
-            value={invitedFormUsername}
             required
+            {...register('username', { required: true })}
           />
         </div>
         {/* Name Form */}
@@ -118,9 +124,8 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
             type="text"
             className="form-control"
             placeholder={t('Name')}
-            name="invitedForm[name]"
-            value={invitedFormName}
             required
+            {...register('name', { required: true })}
           />
         </div>
         {/* Password Form */}
@@ -132,14 +137,14 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
             type="password"
             className="form-control"
             placeholder={t('Password')}
-            name="invitedForm[password]"
             required
             minLength={6}
+            {...register('password', { required: true, minLength: 6 })}
           />
         </div>
         {/* Create Button */}
         <div className="input-group justify-content-center d-flex mt-4">
-          <button type="submit" className="btn btn-fill" id="register" disabled={isLoading}>
+          <button type="submit" className="btn btn-fill" id="register" disabled={isLoading || isSubmitting}>
             <span className="btn-label">
               {isLoading ? (
                 <LoadingSpinner />

+ 3 - 0
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -20,6 +20,7 @@ import Sticky from 'react-stickynode';
 import { DropdownItem, UncontrolledTooltip, Tooltip } from 'reactstrap';
 
 import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
+import { usePrintMode } from '~/client/services/use-print-mode';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { usePageBulkExportSelectModalActions } from '~/features/page-bulk-export/client/states/modal';
@@ -257,6 +258,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { t } = useTranslation();
 
   const router = useRouter();
+  const isPrinting = usePrintMode();
 
   const shareLinkId = useShareLinkId();
   const { fetchCurrentPage } = useFetchCurrentPage();
@@ -389,6 +391,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
       <Sticky
         className="z-1"
+        enabled={!isPrinting}
         onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}
         innerActiveClass="w-100 end-0"
       >

+ 1 - 1
apps/app/src/client/components/PageEditor/DrawioModal/DrawioModal.tsx

@@ -4,6 +4,7 @@ import React, {
 
 import { Lang } from '@growi/core';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
+import { useDrawioModalForEditorStatus, useDrawioModalForEditorActions } from '@growi/editor/dist/states/modal/drawio-for-editor';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import {
   Modal,
@@ -13,7 +14,6 @@ import {
 import { replaceFocusedDrawioWithEditor, getMarkdownDrawioMxfile } from '~/client/components/PageEditor/markdown-drawio-util-for-editor';
 import { useRendererConfig } from '~/states/server-configurations';
 import { useDrawioModalActions, useDrawioModalStatus } from '~/states/ui/modal/drawio';
-import { useDrawioModalForEditorStatus, useDrawioModalForEditorActions } from '~/states/ui/modal/drawio-for-editor';
 import { useSWRxPersonalSettings } from '~/stores/personal-settings';
 import loggerFactory from '~/utils/logger';
 

+ 8 - 1
apps/app/src/client/components/PageEditor/DrawioModal/dynamic.tsx

@@ -1,17 +1,24 @@
 import type { JSX } from 'react';
 
+import { useDrawioModalForEditorStatus } from '@growi/editor/dist/states/modal/drawio-for-editor';
+
 import { useLazyLoader } from '~/components/utils/use-lazy-loader';
 import { useDrawioModalStatus } from '~/states/ui/modal/drawio';
 
+
 type DrawioModalProps = Record<string, unknown>;
 
 export const DrawioModalLazyLoaded = (): JSX.Element => {
   const status = useDrawioModalStatus();
+  const statusForEditor = useDrawioModalForEditorStatus();
+
+  const isOpened = status?.isOpened ?? false;
+  const isOpenedInEditor = statusForEditor?.isOpened ?? false;
 
   const DrawioModal = useLazyLoader<DrawioModalProps>(
     'drawio-modal',
     () => import('./DrawioModal').then(mod => ({ default: mod.DrawioModal })),
-    status?.isOpened ?? false,
+    isOpened || isOpenedInEditor,
   );
 
   return DrawioModal ? <DrawioModal /> : <></>;

+ 3 - 1
apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx

@@ -2,7 +2,7 @@ import React, {
   useState, useCallback, useMemo, type JSX,
 } from 'react';
 
-import { MarkdownTable, useHandsontableModalForEditorStatus } from '@growi/editor';
+import { MarkdownTable, useHandsontableModalForEditorStatus, useHandsontableModalForEditorActions } from '@growi/editor';
 import { HotTable } from '@handsontable/react';
 import type Handsontable from 'handsontable';
 import { useTranslation } from 'next-i18next';
@@ -36,6 +36,7 @@ export const HandsontableModalSubstance = (): JSX.Element => {
   const handsontableModalData = useHandsontableModalStatus();
   const { close: closeHandsontableModal } = useHandsontableModalActions();
   const handsontableModalForEditorData = useHandsontableModalForEditorStatus();
+  const { close: closeHandsontableModalForEditor } = useHandsontableModalForEditorActions();
 
   const isOpened = handsontableModalData?.isOpened ?? false;
   const isOpendInEditor = handsontableModalForEditorData?.isOpened ?? false;
@@ -155,6 +156,7 @@ export const HandsontableModalSubstance = (): JSX.Element => {
 
   const cancel = () => {
     closeHandsontableModal();
+    closeHandsontableModalForEditor();
     setIsDataImportAreaExpanded(false);
     setIsWindowExpanded(false);
   };

+ 0 - 8
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss

@@ -13,11 +13,3 @@
     }
   }
 }
-
-@media print {
-  .grw-page-path-nav-sticky :global {
-    .sticky-inner-wrapper {
-      position: static !important;
-    }
-  }
-}

+ 4 - 1
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx

@@ -6,6 +6,7 @@ import { DevidedPagePath } from '@growi/core/dist/models';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import Sticky from 'react-stickynode';
 
+import { usePrintMode } from '~/client/services/use-print-mode';
 import LinkedPagePath from '~/models/linked-page-path';
 import { usePageControlsX } from '~/states/ui/page';
 import { useSidebarMode, useCurrentProductNavWidth } from '~/states/ui/sidebar';
@@ -26,6 +27,8 @@ const { isTrashPage } = pagePathUtils;
 export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element => {
   const { pagePath } = props;
 
+  const isPrinting = usePrintMode();
+
   const pageControlsX = usePageControlsX();
   const [sidebarWidth] = useCurrentProductNavWidth();
   const { sidebarMode } = useSidebarMode();
@@ -81,7 +84,7 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
     // Controlling pointer-events
     //  1. disable pointer-events with 'pe-none'
     <div ref={pagePathNavRef}>
-      <Sticky className={`${moduleClass} mb-4`} innerClass="pe-none" innerActiveClass="active mt-1">
+      <Sticky className={moduleClass} enabled={!isPrinting} innerClass="pe-none" innerActiveClass="active mt-1">
         {({ status }) => {
           const isParentsCollapsed = status === Sticky.STATUS_FIXED;
 

+ 0 - 3
apps/app/src/client/components/PageSideContents/PageSideContents.module.scss

@@ -1,3 +0,0 @@
-/* stylelint-disable-next-line block-no-empty */
-.grw-page-accessories-controls :global {
-}

+ 1 - 3
apps/app/src/client/components/PageSideContents/PageSideContents.tsx

@@ -25,8 +25,6 @@ import TableOfContents from '../TableOfContents';
 
 import { PageAccessoriesControl } from './PageAccessoriesControl';
 
-import styles from './PageSideContents.module.scss';
-
 
 const { isTopPage, isUsersHomepage, isTrashPage } = pagePathUtils;
 
@@ -123,7 +121,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
         </div>
       )}
 
-      <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
+      <div className=" d-flex flex-column gap-2">
         {/* Page list */}
         {!isSharedUser && (
           <div className="d-flex" data-testid="pageListButton">

+ 81 - 0
apps/app/src/client/components/RecentActivity/ActivityListItem.tsx

@@ -0,0 +1,81 @@
+import { formatDistanceToNow } from 'date-fns';
+import { useTranslation } from 'next-i18next';
+import { type Locale } from 'date-fns/locale';
+import { getLocale } from '~/server/util/locale-utils';
+import type { ActivityHasUserId, SupportedActivityActionType } from '~/interfaces/activity';
+import { ActivityLogActions } from '~/interfaces/activity';
+
+
+export const ActivityActionTranslationMap: Record<
+  SupportedActivityActionType,
+  string
+> = {
+  [ActivityLogActions.ACTION_PAGE_CREATE]: 'page_create',
+  [ActivityLogActions.ACTION_PAGE_UPDATE]: 'page_update',
+  [ActivityLogActions.ACTION_PAGE_DELETE]: 'page_delete',
+  [ActivityLogActions.ACTION_PAGE_DELETE_COMPLETELY]: 'page_delete_completely',
+  [ActivityLogActions.ACTION_PAGE_RENAME]: 'page_rename',
+  [ActivityLogActions.ACTION_PAGE_REVERT]: 'page_revert',
+  [ActivityLogActions.ACTION_PAGE_DUPLICATE]: 'page_duplicate',
+  [ActivityLogActions.ACTION_PAGE_LIKE]: 'page_like',
+  [ActivityLogActions.ACTION_COMMENT_CREATE]: 'comment_create',
+};
+
+export const IconActivityTranslationMap: Record<
+  SupportedActivityActionType,
+  string
+> = {
+  [ActivityLogActions.ACTION_PAGE_CREATE]: 'add_box',
+  [ActivityLogActions.ACTION_PAGE_UPDATE]: 'edit',
+  [ActivityLogActions.ACTION_PAGE_DELETE]: 'delete',
+  [ActivityLogActions.ACTION_PAGE_DELETE_COMPLETELY]: 'delete_forever',
+  [ActivityLogActions.ACTION_PAGE_RENAME]: 'label',
+  [ActivityLogActions.ACTION_PAGE_REVERT]: 'undo',
+  [ActivityLogActions.ACTION_PAGE_DUPLICATE]: 'content_copy',
+  [ActivityLogActions.ACTION_PAGE_LIKE]: 'favorite',
+  [ActivityLogActions.ACTION_COMMENT_CREATE]: 'comment',
+};
+
+const translateAction = (action: SupportedActivityActionType): string => {
+  return ActivityActionTranslationMap[action] || 'unknown_action';
+};
+
+const setIcon = (action: SupportedActivityActionType): string => {
+  return IconActivityTranslationMap[action] || 'question_mark';
+};
+
+const calculateTimePassed = (date: Date, locale: Locale): string => {
+  const timePassed = formatDistanceToNow(date, {
+    addSuffix: true,
+    locale,
+  });
+
+  return timePassed;
+};
+
+
+export const ActivityListItem = ({ activity }: { activity: ActivityHasUserId }): JSX.Element => {
+  const { t, i18n } = useTranslation();
+  const currentLangCode = i18n.language;
+  const dateFnsLocale = getLocale(currentLangCode);
+
+  const action = activity.action as SupportedActivityActionType;
+  const keyToTranslate = translateAction(action);
+  const fullKeyPath = `user_home_page.${keyToTranslate}`;
+
+  return (
+    <div className="activity-row">
+      <p className="mb-1">
+        <span className="material-symbols-outlined me-2">{setIcon(action)}</span>
+
+        <span className="dark:text-white">
+          {' '}{t(fullKeyPath)}
+        </span>
+
+        <span className="text-secondary small ms-3">
+          {calculateTimePassed(activity.createdAt, dateFnsLocale)}
+        </span>
+      </p>
+    </div>
+  );
+};

+ 83 - 0
apps/app/src/client/components/RecentActivity/RecentActivity.tsx

@@ -0,0 +1,83 @@
+import React, {
+  useState, useCallback, useEffect, type JSX,
+} from 'react';
+
+import { toastError } from '~/client/util/toastr';
+import type { IActivityHasId, ActivityHasUserId } from '~/interfaces/activity';
+import { useSWRxRecentActivity } from '~/stores/recent-activity';
+import loggerFactory from '~/utils/logger';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import { ActivityListItem } from './ActivityListItem';
+
+
+const logger = loggerFactory('growi:RecentActivity');
+
+type RecentActivityProps = {
+  userId: string,
+}
+
+const hasUser = (activity: IActivityHasId): activity is ActivityHasUserId => {
+  return activity.user != null
+        && typeof activity.user === 'object';
+};
+
+export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
+  const { userId } = props;
+
+  const [activities, setActivities] = useState<ActivityHasUserId[]>([]);
+  const [activePage, setActivePage] = useState(1);
+  const [limit] = useState(10);
+  const [offset, setOffset] = useState(0);
+
+  const { data: paginatedData, error } = useSWRxRecentActivity(limit, offset, userId);
+
+  const handlePage = useCallback(async(selectedPage: number) => {
+    const newOffset = (selectedPage - 1) * limit;
+
+    setOffset(newOffset);
+    setActivePage(selectedPage);
+  }, [limit]);
+
+  useEffect(() => {
+    if (error) {
+      logger.error('Failed to fetch recent activity data', error);
+      toastError(error);
+      return;
+    }
+
+    if (paginatedData) {
+      const activitiesWithPages = paginatedData.docs
+        .filter(hasUser);
+
+      setActivities(activitiesWithPages);
+    }
+  }, [paginatedData, error]);
+
+  const totalItemsCount = paginatedData?.totalDocs || 0;
+  const needsPagination = totalItemsCount > limit;
+
+  return (
+    <div className="page-list-container-activity">
+      <ul className="page-list-ul page-list-ul-flat mb-3">
+        {activities.map(activity => (
+          <li key={`recent-activity-view:${activity._id}`} className="mt-4">
+            <ActivityListItem activity={activity} />
+          </li>
+        ))}
+      </ul>
+
+      {needsPagination && (
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={handlePage}
+          totalItemsCount={totalItemsCount}
+          pagingLimit={limit}
+          align="center"
+          size="sm"
+        />
+      )}
+    </div>
+  );
+};

+ 9 - 0
apps/app/src/client/components/UsersHomepageFooter.tsx

@@ -2,6 +2,7 @@ import React, { useState, type JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
+import { RecentActivity } from '~/client/components/RecentActivity/RecentActivity';
 import { RecentCreated } from '~/client/components/RecentCreated/RecentCreated';
 import { useCurrentUser } from '~/states/global';
 
@@ -45,6 +46,14 @@ export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Elemen
         <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
           <RecentCreated userId={creatorId} />
         </div>
+
+        <h2 id="user-created-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
+          <span className="growi-custom-icons me-1">recently_created</span>
+          {t('user_home_page.recent_activity')}
+        </h2>
+        <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
+          <RecentActivity userId={creatorId} />
+        </div>
       </div>
     </div>
   );

+ 16 - 3
apps/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -239,9 +239,22 @@ export default class AdminGeneralSecurityContainer extends Container {
    * @memberOf AdminGeneralSecuritySContainer
    * @return {string} Appearance
    */
-  async updateGeneralSecuritySetting() {
-
-    let requestParams = {
+  async updateGeneralSecuritySetting(formData) {
+
+    let requestParams = formData != null ? {
+      sessionMaxAge: formData.sessionMaxAge,
+      restrictGuestMode: formData.restrictGuestMode,
+      pageDeletionAuthority: formData.pageDeletionAuthority,
+      pageCompleteDeletionAuthority: formData.pageCompleteDeletionAuthority,
+      pageRecursiveDeletionAuthority: formData.pageRecursiveDeletionAuthority,
+      pageRecursiveCompleteDeletionAuthority: formData.pageRecursiveCompleteDeletionAuthority,
+      isAllGroupMembershipRequiredForPageCompleteDeletion: formData.isAllGroupMembershipRequiredForPageCompleteDeletion,
+      hideRestrictedByGroup: formData.hideRestrictedByGroup,
+      hideRestrictedByOwner: formData.hideRestrictedByOwner,
+      isUsersHomepageDeletionEnabled: formData.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion: formData.isForceDeleteUserHomepageOnUserDeletion,
+      isRomUserAllowedToComment: formData.isRomUserAllowedToComment,
+    } : {
       sessionMaxAge: this.state.sessionMaxAge,
       restrictGuestMode: this.state.currentRestrictGuestMode,
       pageDeletionAuthority: this.state.currentPageDeletionAuthority,

+ 10 - 18
apps/app/src/client/services/AdminGitHubSecurityContainer.js

@@ -61,20 +61,6 @@ export default class AdminGitHubSecurityContainer extends Container {
     return 'AdminGitHubSecurityContainer';
   }
 
-  /**
-   * Change githubClientId
-   */
-  changeGitHubClientId(value) {
-    this.setState({ githubClientId: value });
-  }
-
-  /**
-   * Change githubClientSecret
-   */
-  changeGitHubClientSecret(value) {
-    this.setState({ githubClientSecret: value });
-  }
-
   /**
    * Switch isSameUsernameTreatedAsIdenticalUser
    */
@@ -85,10 +71,16 @@ export default class AdminGitHubSecurityContainer extends Container {
   /**
    * Update githubSetting
    */
-  async updateGitHubSetting() {
-    const { githubClientId, githubClientSecret, isSameUsernameTreatedAsIdenticalUser } = this.state;
-
-    let requestParams = { githubClientId, githubClientSecret, isSameUsernameTreatedAsIdenticalUser };
+  async updateGitHubSetting(formData) {
+    let requestParams = formData != null ? {
+      githubClientId: formData.githubClientId,
+      githubClientSecret: formData.githubClientSecret,
+      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
+    } : {
+      githubClientId: this.state.githubClientId,
+      githubClientSecret: this.state.githubClientSecret,
+      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
+    };
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     const response = await apiv3Put('/security-setting/github-oauth', requestParams);

+ 9 - 19
apps/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -62,20 +62,6 @@ export default class AdminGoogleSecurityContainer extends Container {
     return 'AdminGoogleSecurityContainer';
   }
 
-  /**
-   * Change googleClientId
-   */
-  changeGoogleClientId(value) {
-    this.setState({ googleClientId: value });
-  }
-
-  /**
-   * Change googleClientSecret
-   */
-  changeGoogleClientSecret(value) {
-    this.setState({ googleClientSecret: value });
-  }
-
   /**
    * Switch isSameEmailTreatedAsIdenticalUser
    */
@@ -87,11 +73,15 @@ export default class AdminGoogleSecurityContainer extends Container {
   /**
    * Update googleSetting
    */
-  async updateGoogleSetting() {
-    const { googleClientId, googleClientSecret, isSameEmailTreatedAsIdenticalUser } = this.state;
-
-    let requestParams = {
-      googleClientId, googleClientSecret, isSameEmailTreatedAsIdenticalUser,
+  async updateGoogleSetting(formData) {
+    let requestParams = formData != null ? {
+      googleClientId: formData.googleClientId,
+      googleClientSecret: formData.googleClientSecret,
+      isSameEmailTreatedAsIdenticalUser: formData.isSameEmailTreatedAsIdenticalUser,
+    } : {
+      googleClientId: this.state.googleClientId,
+      googleClientSecret: this.state.googleClientSecret,
+      isSameEmailTreatedAsIdenticalUser: this.state.isSameEmailTreatedAsIdenticalUser,
     };
 
     requestParams = await removeNullPropertyFromObject(requestParams);

+ 27 - 89
apps/app/src/client/services/AdminLdapSecurityContainer.js

@@ -78,13 +78,6 @@ export default class AdminLdapSecurityContainer extends Container {
     return 'AdminLdapSecurityContainer';
   }
 
-  /**
-   * Change serverUrl
-   */
-  changeServerUrl(serverUrl) {
-    this.setState({ serverUrl });
-  }
-
   /**
    * Change ldapBindMode
    * @param {boolean} isUserBind true: User Bind, false: Admin Bind
@@ -93,34 +86,6 @@ export default class AdminLdapSecurityContainer extends Container {
     this.setState({ isUserBind });
   }
 
-  /**
-   * Change bindDN
-   */
-  changeBindDN(ldapBindDN) {
-    this.setState({ ldapBindDN });
-  }
-
-  /**
-   * Change bindDNPassword
-   */
-  changeBindDNPassword(ldapBindDNPassword) {
-    this.setState({ ldapBindDNPassword });
-  }
-
-  /**
-   * Change ldapSearchFilter
-   */
-  changeSearchFilter(ldapSearchFilter) {
-    this.setState({ ldapSearchFilter });
-  }
-
-  /**
-   * Change ldapAttrMapUsername
-   */
-  changeAttrMapUsername(ldapAttrMapUsername) {
-    this.setState({ ldapAttrMapUsername });
-  }
-
   /**
    * Switch is same username treated as identical user
    */
@@ -128,63 +93,36 @@ export default class AdminLdapSecurityContainer extends Container {
     this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
   }
 
-  /**
-   * Change ldapAttrMapMail
-   */
-  changeAttrMapMail(ldapAttrMapMail) {
-    this.setState({ ldapAttrMapMail });
-  }
-
-  /**
-   * Change ldapAttrMapName
-   */
-  changeAttrMapName(ldapAttrMapName) {
-    this.setState({ ldapAttrMapName });
-  }
-
-  /**
-   * Change ldapGroupSearchBase
-   */
-  changeGroupSearchBase(ldapGroupSearchBase) {
-    this.setState({ ldapGroupSearchBase });
-  }
-
-  /**
-   * Change ldapGroupSearchFilter
-   */
-  changeGroupSearchFilter(ldapGroupSearchFilter) {
-    this.setState({ ldapGroupSearchFilter });
-  }
-
-  /**
-   * Change ldapGroupDnProperty
-   */
-  changeGroupDnProperty(ldapGroupDnProperty) {
-    this.setState({ ldapGroupDnProperty });
-  }
-
   /**
    * Update ldap option
    */
-  async updateLdapSetting() {
-    const {
-      serverUrl, isUserBind, ldapBindDN, ldapBindDNPassword, ldapSearchFilter, ldapAttrMapUsername, isSameUsernameTreatedAsIdenticalUser,
-      ldapAttrMapMail, ldapAttrMapName, ldapGroupSearchBase, ldapGroupSearchFilter, ldapGroupDnProperty,
-    } = this.state;
-
-    let requestParams = {
-      serverUrl,
-      isUserBind,
-      ldapBindDN,
-      ldapBindDNPassword,
-      ldapSearchFilter,
-      ldapAttrMapUsername,
-      isSameUsernameTreatedAsIdenticalUser,
-      ldapAttrMapMail,
-      ldapAttrMapName,
-      ldapGroupSearchBase,
-      ldapGroupSearchFilter,
-      ldapGroupDnProperty,
+  async updateLdapSetting(formData) {
+    let requestParams = formData != null ? {
+      serverUrl: formData.serverUrl,
+      isUserBind: formData.isUserBind,
+      ldapBindDN: formData.ldapBindDN,
+      ldapBindDNPassword: formData.ldapBindDNPassword,
+      ldapSearchFilter: formData.ldapSearchFilter,
+      ldapAttrMapUsername: formData.ldapAttrMapUsername,
+      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
+      ldapAttrMapMail: formData.ldapAttrMapMail,
+      ldapAttrMapName: formData.ldapAttrMapName,
+      ldapGroupSearchBase: formData.ldapGroupSearchBase,
+      ldapGroupSearchFilter: formData.ldapGroupSearchFilter,
+      ldapGroupDnProperty: formData.ldapGroupDnProperty,
+    } : {
+      serverUrl: this.state.serverUrl,
+      isUserBind: this.state.isUserBind,
+      ldapBindDN: this.state.ldapBindDN,
+      ldapBindDNPassword: this.state.ldapBindDNPassword,
+      ldapSearchFilter: this.state.ldapSearchFilter,
+      ldapAttrMapUsername: this.state.ldapAttrMapUsername,
+      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
+      ldapAttrMapMail: this.state.ldapAttrMapMail,
+      ldapAttrMapName: this.state.ldapAttrMapName,
+      ldapGroupSearchBase: this.state.ldapGroupSearchBase,
+      ldapGroupSearchFilter: this.state.ldapGroupSearchFilter,
+      ldapGroupDnProperty: this.state.ldapGroupDnProperty,
     };
 
     requestParams = await removeNullPropertyFromObject(requestParams);

+ 12 - 14
apps/app/src/client/services/AdminLocalSecurityContainer.js

@@ -71,13 +71,6 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({ registrationMode: value });
   }
 
-  /**
-   * Change registration whitelist
-   */
-  changeRegistrationWhitelist(value) {
-    this.setState({ registrationWhitelist: value.split('\n') });
-  }
-
   /**
    * Switch password reset enabled
    */
@@ -95,14 +88,19 @@ export default class AdminLocalSecurityContainer extends Container {
   /**
    * update local security setting
    */
-  async updateLocalSecuritySetting() {
-    const { registrationWhitelist, isPasswordResetEnabled, isEmailAuthenticationEnabled } = this.state;
-    const response = await apiv3Put('/security-setting/local-setting', {
+  async updateLocalSecuritySetting(formData) {
+    const requestParams = formData != null ? {
+      registrationMode: formData.registrationMode,
+      registrationWhitelist: formData.registrationWhitelist,
+      isPasswordResetEnabled: formData.isPasswordResetEnabled,
+      isEmailAuthenticationEnabled: formData.isEmailAuthenticationEnabled,
+    } : {
       registrationMode: this.state.registrationMode,
-      registrationWhitelist,
-      isPasswordResetEnabled,
-      isEmailAuthenticationEnabled,
-    });
+      registrationWhitelist: this.state.registrationWhitelist,
+      isPasswordResetEnabled: this.state.isPasswordResetEnabled,
+      isEmailAuthenticationEnabled: this.state.isEmailAuthenticationEnabled,
+    };
+    const response = await apiv3Put('/security-setting/local-setting', requestParams);
 
     const { localSettingParams } = response.data;
 

+ 39 - 153
apps/app/src/client/services/AdminOidcSecurityContainer.js

@@ -89,118 +89,6 @@ export default class AdminOidcSecurityContainer extends Container {
     return 'AdminOidcSecurityContainer';
   }
 
-  /**
-   * Change oidcProviderName
-   */
-  changeOidcProviderName(inputValue) {
-    this.setState({ oidcProviderName: inputValue });
-  }
-
-  /**
-   * Change oidcIssuerHost
-   */
-  changeOidcIssuerHost(inputValue) {
-    this.setState({ oidcIssuerHost: inputValue });
-  }
-
-  /**
-   * Change oidcAuthorizationEndpoint
-   */
-  changeOidcAuthorizationEndpoint(inputValue) {
-    this.setState({ oidcAuthorizationEndpoint: inputValue });
-  }
-
-  /**
-   * Change oidcTokenEndpoint
-   */
-  changeOidcTokenEndpoint(inputValue) {
-    this.setState({ oidcTokenEndpoint: inputValue });
-  }
-
-  /**
-   * Change oidcRevocationEndpoint
-   */
-  changeOidcRevocationEndpoint(inputValue) {
-    this.setState({ oidcRevocationEndpoint: inputValue });
-  }
-
-  /**
-   * Change oidcIntrospectionEndpoint
-   */
-  changeOidcIntrospectionEndpoint(inputValue) {
-    this.setState({ oidcIntrospectionEndpoint: inputValue });
-  }
-
-  /**
-   * Change oidcUserInfoEndpoint
-   */
-  changeOidcUserInfoEndpoint(inputValue) {
-    this.setState({ oidcUserInfoEndpoint: inputValue });
-  }
-
-  /**
-   * Change oidcEndSessionEndpoint
-   */
-  changeOidcEndSessionEndpoint(inputValue) {
-    this.setState({ oidcEndSessionEndpoint: inputValue });
-  }
-
-  /**
-   * Change oidcRegistrationEndpoint
-   */
-  changeOidcRegistrationEndpoint(inputValue) {
-    this.setState({ oidcRegistrationEndpoint: inputValue });
-  }
-
-  /**
-   * Change oidcJWKSUri
-   */
-  changeOidcJWKSUri(inputValue) {
-    this.setState({ oidcJWKSUri: inputValue });
-  }
-
-  /**
-   * Change oidcClientId
-   */
-  changeOidcClientId(inputValue) {
-    this.setState({ oidcClientId: inputValue });
-  }
-
-  /**
-   * Change oidcClientSecret
-   */
-  changeOidcClientSecret(inputValue) {
-    this.setState({ oidcClientSecret: inputValue });
-  }
-
-  /**
-   * Change oidcAttrMapId
-   */
-  changeOidcAttrMapId(inputValue) {
-    this.setState({ oidcAttrMapId: inputValue });
-  }
-
-  /**
-   * Change oidcAttrMapUserName
-   */
-  changeOidcAttrMapUserName(inputValue) {
-    this.setState({ oidcAttrMapUserName: inputValue });
-  }
-
-  /**
-   * Change oidcAttrMapName
-   */
-  changeOidcAttrMapName(inputValue) {
-    this.setState({ oidcAttrMapName: inputValue });
-  }
-
-  /**
-   * Change oidcAttrMapEmail
-   */
-  changeOidcAttrMapEmail(inputValue) {
-    this.setState({ oidcAttrMapEmail: inputValue });
-  }
-
   /**
    * Switch sameUsernameTreatedAsIdenticalUser
    */
@@ -218,47 +106,45 @@ export default class AdminOidcSecurityContainer extends Container {
   /**
    * Update OpenID Connect
    */
-  async updateOidcSetting() {
-    const {
-      oidcProviderName,
-      oidcIssuerHost,
-      oidcAuthorizationEndpoint,
-      oidcTokenEndpoint,
-      oidcRevocationEndpoint,
-      oidcIntrospectionEndpoint,
-      oidcUserInfoEndpoint,
-      oidcEndSessionEndpoint,
-      oidcRegistrationEndpoint,
-      oidcJWKSUri,
-      oidcClientId,
-      oidcClientSecret,
-      oidcAttrMapId,
-      oidcAttrMapUserName,
-      oidcAttrMapName,
-      oidcAttrMapEmail,
-      isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser,
-    } = this.state;
-
-    let requestParams = {
-      oidcProviderName,
-      oidcIssuerHost,
-      oidcAuthorizationEndpoint,
-      oidcTokenEndpoint,
-      oidcRevocationEndpoint,
-      oidcIntrospectionEndpoint,
-      oidcUserInfoEndpoint,
-      oidcEndSessionEndpoint,
-      oidcRegistrationEndpoint,
-      oidcJWKSUri,
-      oidcClientId,
-      oidcClientSecret,
-      oidcAttrMapId,
-      oidcAttrMapUserName,
-      oidcAttrMapName,
-      oidcAttrMapEmail,
-      isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser,
+  async updateOidcSetting(formData) {
+    let requestParams = formData != null ? {
+      oidcProviderName: formData.oidcProviderName,
+      oidcIssuerHost: formData.oidcIssuerHost,
+      oidcAuthorizationEndpoint: formData.oidcAuthorizationEndpoint,
+      oidcTokenEndpoint: formData.oidcTokenEndpoint,
+      oidcRevocationEndpoint: formData.oidcRevocationEndpoint,
+      oidcIntrospectionEndpoint: formData.oidcIntrospectionEndpoint,
+      oidcUserInfoEndpoint: formData.oidcUserInfoEndpoint,
+      oidcEndSessionEndpoint: formData.oidcEndSessionEndpoint,
+      oidcRegistrationEndpoint: formData.oidcRegistrationEndpoint,
+      oidcJWKSUri: formData.oidcJWKSUri,
+      oidcClientId: formData.oidcClientId,
+      oidcClientSecret: formData.oidcClientSecret,
+      oidcAttrMapId: formData.oidcAttrMapId,
+      oidcAttrMapUserName: formData.oidcAttrMapUserName,
+      oidcAttrMapName: formData.oidcAttrMapName,
+      oidcAttrMapEmail: formData.oidcAttrMapEmail,
+      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser: formData.isSameEmailTreatedAsIdenticalUser,
+    } : {
+      oidcProviderName: this.state.oidcProviderName,
+      oidcIssuerHost: this.state.oidcIssuerHost,
+      oidcAuthorizationEndpoint: this.state.oidcAuthorizationEndpoint,
+      oidcTokenEndpoint: this.state.oidcTokenEndpoint,
+      oidcRevocationEndpoint: this.state.oidcRevocationEndpoint,
+      oidcIntrospectionEndpoint: this.state.oidcIntrospectionEndpoint,
+      oidcUserInfoEndpoint: this.state.oidcUserInfoEndpoint,
+      oidcEndSessionEndpoint: this.state.oidcEndSessionEndpoint,
+      oidcRegistrationEndpoint: this.state.oidcRegistrationEndpoint,
+      oidcJWKSUri: this.state.oidcJWKSUri,
+      oidcClientId: this.state.oidcClientId,
+      oidcClientSecret: this.state.oidcClientSecret,
+      oidcAttrMapId: this.state.oidcAttrMapId,
+      oidcAttrMapUserName: this.state.oidcAttrMapUserName,
+      oidcAttrMapName: this.state.oidcAttrMapName,
+      oidcAttrMapEmail: this.state.oidcAttrMapEmail,
+      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser: this.state.isSameEmailTreatedAsIdenticalUser,
     };
 
     requestParams = await removeNullPropertyFromObject(requestParams);

+ 15 - 66
apps/app/src/client/services/AdminSamlSecurityContainer.js

@@ -98,62 +98,6 @@ export default class AdminSamlSecurityContainer extends Container {
     return 'AdminSamlSecurityContainer';
   }
 
-  /**
-   * Change samlEntryPoint
-   */
-  changeSamlEntryPoint(inputValue) {
-    this.setState({ samlEntryPoint: inputValue });
-  }
-
-  /**
-   * Change samlIssuer
-   */
-  changeSamlIssuer(inputValue) {
-    this.setState({ samlIssuer: inputValue });
-  }
-
-  /**
-   * Change samlCert
-   */
-  changeSamlCert(inputValue) {
-    this.setState({ samlCert: inputValue });
-  }
-
-  /**
-   * Change samlAttrMapId
-   */
-  changeSamlAttrMapId(inputValue) {
-    this.setState({ samlAttrMapId: inputValue });
-  }
-
-  /**
-   * Change samlAttrMapUsername
-   */
-  changeSamlAttrMapUserName(inputValue) {
-    this.setState({ samlAttrMapUsername: inputValue });
-  }
-
-  /**
-   * Change samlAttrMapMail
-   */
-  changeSamlAttrMapMail(inputValue) {
-    this.setState({ samlAttrMapMail: inputValue });
-  }
-
-  /**
-   * Change samlAttrMapFirstName
-   */
-  changeSamlAttrMapFirstName(inputValue) {
-    this.setState({ samlAttrMapFirstName: inputValue });
-  }
-
-  /**
-   * Change samlAttrMapLastName
-   */
-  changeSamlAttrMapLastName(inputValue) {
-    this.setState({ samlAttrMapLastName: inputValue });
-  }
-
   /**
    * Switch isSameUsernameTreatedAsIdenticalUser
    */
@@ -168,19 +112,24 @@ export default class AdminSamlSecurityContainer extends Container {
     this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
   }
 
-  /**
-   * Change samlABLCRule
-   */
-  changeSamlABLCRule(inputValue) {
-    this.setState({ samlABLCRule: inputValue });
-  }
-
   /**
    * Update saml option
    */
-  async updateSamlSetting() {
-
-    let requestParams = {
+  async updateSamlSetting(formData) {
+
+    let requestParams = formData != null ? {
+      entryPoint: formData.samlEntryPoint,
+      issuer: formData.samlIssuer,
+      cert: formData.samlCert,
+      attrMapId: formData.samlAttrMapId,
+      attrMapUsername: formData.samlAttrMapUsername,
+      attrMapMail: formData.samlAttrMapMail,
+      attrMapFirstName: formData.samlAttrMapFirstName,
+      attrMapLastName: formData.samlAttrMapLastName,
+      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser: formData.isSameEmailTreatedAsIdenticalUser,
+      ABLCRule: formData.samlABLCRule,
+    } : {
       entryPoint: this.state.samlEntryPoint,
       issuer: this.state.samlIssuer,
       cert: this.state.samlCert,

+ 28 - 0
apps/app/src/client/services/use-print-mode.ts

@@ -0,0 +1,28 @@
+import { useEffect, useState } from 'react';
+
+import { flushSync } from 'react-dom';
+
+export const usePrintMode = (): boolean => {
+  const [isPrinting, setIsPrinting] = useState(false);
+
+  useEffect(() => {
+    // force re-render on beforeprint
+    const handleBeforePrint = () => flushSync(() => {
+      setIsPrinting(true);
+    });
+
+    const handleAfterPrint = () => {
+      setIsPrinting(false);
+    };
+
+    window.addEventListener('beforeprint', handleBeforePrint);
+    window.addEventListener('afterprint', handleAfterPrint);
+
+    return () => {
+      window.removeEventListener('beforeprint', handleBeforePrint);
+      window.removeEventListener('afterprint', handleAfterPrint);
+    };
+  }, []);
+
+  return isPrinting;
+};

+ 2 - 2
apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx

@@ -1,4 +1,4 @@
-import { type JSX, memo, useCallback } from 'react';
+import { type FC, type JSX, memo, useCallback } from 'react';
 import Link from 'next/link';
 import urljoin from 'url-join';
 
@@ -17,7 +17,7 @@ type PagePathHierarchicalLinkProps = {
   isInnerElem?: boolean;
 };
 
-export const PagePathHierarchicalLink = memo(
+export const PagePathHierarchicalLink: FC<PagePathHierarchicalLinkProps> = memo(
   (props: PagePathHierarchicalLinkProps): JSX.Element => {
     const {
       linkedPagePath,

+ 4 - 6
apps/app/src/components/Common/PagePathNavTitle/PagePathNavTitle.tsx

@@ -42,17 +42,15 @@ export const PagePathNavTitle = (
     setClient(true);
   }, []);
 
+  const className = `${moduleClass} mb-4`;
+
   return isClient ? (
     <PagePathNavSticky
       {...props}
-      className={moduleClass}
+      className={className}
       latterLinkClassName="fs-2"
     />
   ) : (
-    <PagePathNav
-      {...props}
-      className={moduleClass}
-      latterLinkClassName="fs-2"
-    />
+    <PagePathNav {...props} className={className} latterLinkClassName="fs-2" />
   );
 };

+ 2 - 3
apps/app/src/components/PageView/PageContentFooter.tsx

@@ -22,13 +22,12 @@ export const PageContentFooter = (
   const { creator, lastUpdateUser, createdAt, updatedAt } = page;
 
   if (page.isEmpty) {
+    // biome-ignore lint/complexity/noUselessFragments: ignore
     return <></>;
   }
 
   return (
-    <div
-      className={`${styles['page-content-footer']} my-4 pt-4 d-edit-none d-print-none}`}
-    >
+    <div className={`${styles['page-content-footer']} my-4 pt-4 d-edit-none`}>
       <div className="page-meta">
         <AuthorInfo
           user={creator}

+ 7 - 2
apps/app/src/components/PageView/PageViewLayout.tsx

@@ -1,5 +1,8 @@
 import type { JSX, ReactNode } from 'react';
 
+// biome-ignore lint/style/noRestrictedImports: ignore
+import { usePrintMode } from '~/client/services/use-print-mode';
+
 import styles from './PageViewLayout.module.scss';
 
 const pageViewLayoutClass = styles['page-view-layout'] ?? '';
@@ -24,6 +27,8 @@ export const PageViewLayout = (props: Props): JSX.Element => {
     expandContentWidth,
   } = props;
 
+  const isPrinting = usePrintMode();
+
   const fluidLayoutClass = expandContentWidth ? _fluidLayoutClass : '';
 
   return (
@@ -33,13 +38,13 @@ export const PageViewLayout = (props: Props): JSX.Element => {
       >
         <div className="container-lg wide-gutter-x-lg grw-container-convertible flex-expand-vert">
           {headerContents != null && headerContents}
-          {sideContents != null ? (
+          {!isPrinting && sideContents != null ? (
             <div className="flex-expand-horiz gap-3 z-0">
               <div className="flex-expand-vert flex-basis-0 mw-0">
                 {children}
               </div>
               <div
-                className="grw-side-contents-container col-lg-3  d-edit-none d-print-none"
+                className="grw-side-contents-container col-lg-3 d-edit-none"
                 data-vrt-blackout-side-contents
               >
                 <div className="grw-side-contents-sticky-container">

+ 8 - 2
apps/app/src/features/search/client/components/SearchPage/SearchResultList.tsx

@@ -1,4 +1,8 @@
-import type { ForwardRefRenderFunction } from 'react';
+import type {
+  ForwardRefExoticComponent,
+  ForwardRefRenderFunction,
+  RefAttributes,
+} from 'react';
 import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
 import {
   type IPageInfoForListing,
@@ -184,4 +188,6 @@ const SearchResultListSubstance: ForwardRefRenderFunction<
   );
 };
 
-export const SearchResultList = forwardRef(SearchResultListSubstance);
+export const SearchResultList: ForwardRefExoticComponent<
+  Props & RefAttributes<ISelectableAll>
+> = forwardRef<ISelectableAll, Props>(SearchResultListSubstance);

+ 32 - 2
apps/app/src/interfaces/activity.ts

@@ -1,4 +1,12 @@
-import type { HasObjectId, IUser, Ref } from '@growi/core';
+import type {
+  HasObjectId,
+  IPageHasId,
+  IUser,
+  IUserHasId,
+  Ref,
+} from '@growi/core';
+
+import type { PaginateResult } from './mongoose-utils';
 
 // Model
 const MODEL_PAGE = 'Page';
@@ -377,6 +385,7 @@ export const SupportedAction = {
 
 // Action required for notification
 export const EssentialActionGroup = {
+  ACTION_PAGE_CREATE,
   ACTION_PAGE_LIKE,
   ACTION_PAGE_BOOKMARK,
   ACTION_PAGE_UPDATE,
@@ -568,6 +577,18 @@ export const LargeActionGroup = {
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
 } as const;
 
+export const ActivityLogActions = {
+  ACTION_PAGE_CREATE,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_REVERT,
+  ACTION_PAGE_LIKE,
+  ACTION_COMMENT_CREATE,
+} as const;
+
 /*
  * Array
  */
@@ -645,7 +666,8 @@ export type SupportedActionType =
   (typeof SupportedAction)[keyof typeof SupportedAction];
 export type SupportedActionCategoryType =
   (typeof SupportedActionCategory)[keyof typeof SupportedActionCategory];
-
+export type SupportedActivityActionType =
+  (typeof ActivityLogActions)[keyof typeof ActivityLogActions];
 export type ISnapshot = Partial<Pick<IUser, 'username'>>;
 
 export type IActivity = {
@@ -661,6 +683,10 @@ export type IActivity = {
   snapshot?: ISnapshot;
 };
 
+export type ActivityHasUserId = IActivityHasId & {
+  user: IUserHasId;
+};
+
 export type IActivityHasId = IActivity & HasObjectId;
 
 export type ISearchFilter = {
@@ -668,3 +694,7 @@ export type ISearchFilter = {
   dates?: { startDate: string | null; endDate: string | null };
   actions?: SupportedActionType[];
 };
+
+export interface UserActivitiesResult {
+  serializedPaginationResult: PaginateResult<IActivityHasId>;
+}

+ 12 - 4
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -83,12 +83,20 @@ export async function getServerSidePropsForInitial(
 export async function getServerSidePropsForSameRoute(
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2EachProps>> {
-  // Get page data
-  const result = await getPageDataForSameRoute(context);
+  // -- TODO: :https://redmine.weseek.co.jp/issues/174725
+  // Remove getServerSideI18nProps from getServerSidePropsForSameRoute for performance improvement
+  const [i18nPropsResult, pageDataResult] = await Promise.all([
+    getServerSideI18nProps(context, ['translation']),
+    getPageDataForSameRoute(context),
+  ]);
 
   // -- TODO: persist activity
-
   // const mergedProps = await mergedResult.props;
   // await addActivity(context, getActivityAction(mergedProps));
-  return result;
+  const mergedResult = mergeGetServerSidePropsResults(
+    pageDataResult,
+    i18nPropsResult,
+  );
+
+  return mergedResult;
 }

+ 11 - 10
apps/app/src/pages/_document.page.tsx

@@ -42,10 +42,10 @@ const HeadersForGrowiPlugin = (
 };
 
 interface GrowiDocumentProps {
-  themeHref: string;
-  customScript: string | null;
-  customCss: string | null;
-  customNoscript: string | null;
+  themeHref: string | undefined;
+  customScript: string | undefined;
+  customCss: string | undefined;
+  customNoscript: string | undefined;
   pluginResourceEntries: GrowiPluginResourceEntries;
   locale: Locale;
 }
@@ -63,9 +63,10 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const { customizeService } = crowi;
 
     const { themeHref } = customizeService;
-    const customScript: string | null = customizeService.getCustomScript();
-    const customCss: string | null = customizeService.getCustomCss();
-    const customNoscript: string | null = customizeService.getCustomNoscript();
+    const customScript: string | undefined = customizeService.getCustomScript();
+    const customCss: string | undefined = customizeService.getCustomCss();
+    const customNoscript: string | undefined =
+      customizeService.getCustomNoscript();
 
     // retrieve plugin manifests
     const growiPluginService = await import(
@@ -87,7 +88,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     };
   }
 
-  renderCustomScript(customScript: string | null): JSX.Element {
+  renderCustomScript(customScript: string | undefined): JSX.Element {
     if (customScript == null || customScript.length === 0) {
       return <></>;
     }
@@ -100,7 +101,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     );
   }
 
-  renderCustomCss(customCss: string | null): JSX.Element {
+  renderCustomCss(customCss: string | undefined): JSX.Element {
     if (customCss == null || customCss.length === 0) {
       return <></>;
     }
@@ -108,7 +109,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     return <style dangerouslySetInnerHTML={{ __html: customCss }} />;
   }
 
-  renderCustomNoscript(customNoscript: string | null): JSX.Element {
+  renderCustomNoscript(customNoscript: string | undefined): JSX.Element {
     if (customNoscript == null || customNoscript.length === 0) {
       return <></>;
     }

+ 1 - 10
apps/app/src/pages/admin/customize.page.tsx

@@ -3,7 +3,6 @@ import dynamic from 'next/dynamic';
 import { useHydrateAtoms } from 'jotai/utils';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import { _atomsForAdminPagesHydration as atoms } from '~/states/global';
 import { isCustomizedLogoUploadedAtom } from '~/states/server-configurations';
 
 import type { NextPageWithLayout } from '../_app.page';
@@ -21,7 +20,6 @@ const CustomizeSettingContents = dynamic(
 );
 
 type PageProps = {
-  isDefaultBrandLogoUsed: boolean;
   isCustomizedLogoUploaded: boolean;
   customTitleTemplate?: string;
 };
@@ -33,11 +31,7 @@ const AdminCustomizeSettingsPage: NextPageWithLayout<Props> = (
   props: Props,
 ) => {
   useHydrateAtoms(
-    [
-      [atoms.isDefaultLogoAtom, props.isDefaultBrandLogoUsed],
-      [atoms.customTitleTemplateAtom, props.customTitleTemplate],
-      [isCustomizedLogoUploadedAtom, props.isCustomizedLogoUploaded],
-    ],
+    [[isCustomizedLogoUploadedAtom, props.isCustomizedLogoUploaded]],
     { dangerouslyForceHydrate: true },
   );
 
@@ -66,11 +60,8 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
 
   const customizePropsFragment = {
     props: {
-      isDefaultBrandLogoUsed:
-        await crowi.attachmentService.isDefaultBrandLogoUsed(),
       isCustomizedLogoUploaded:
         await crowi.attachmentService.isBrandLogoExist(),
-      customTitleTemplate: crowi.configManager.getConfig('customize:title'),
     },
   } satisfies { props: PageProps };
 

+ 22 - 1
apps/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx

@@ -1,11 +1,14 @@
 import { useMemo } from 'react';
+import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { useHydrateAtoms } from 'jotai/utils';
 
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { isAclEnabledAtom } from '~/states/server-configurations';
 
 import type { NextPageWithLayout } from '../../_app.page';
+import { mergeGetServerSidePropsResults } from '../../utils/server-side-props';
 import type { AdminCommonProps } from '../_shared';
 import {
   createAdminPageLayout,
@@ -45,6 +48,24 @@ AdminUserGroupDetailPage.getLayout = createAdminPageLayout<Props>({
   title: (_p, t) => t('user_group_management.user_group_management'),
 });
 
-export const getServerSideProps = getServerSideAdminCommonProps;
+export const getServerSideProps: GetServerSideProps<Props> = async (
+  context: GetServerSidePropsContext,
+) => {
+  const commonResult = await getServerSideAdminCommonProps(context);
+
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+
+  const UserGroupDetailPropsFragment = {
+    props: {
+      isAclEnabled: crowi.aclService.isAclEnabled(),
+    },
+  } satisfies { props: PageProps };
+
+  return mergeGetServerSidePropsResults(
+    commonResult,
+    UserGroupDetailPropsFragment,
+  );
+};
 
 export default AdminUserGroupDetailPage;

+ 6 - 1
apps/app/src/pages/common-props/commons.ts

@@ -153,7 +153,12 @@ export const getServerSideCommonEachProps = async (
 
   let currentUser: IUserHasId | undefined;
   if (user != null) {
-    currentUser = user.toObject();
+    const User = crowi.model('User');
+    const userData = await User.findById(user.id).populate({
+      path: 'imageAttachment',
+      select: 'filePathProxied',
+    });
+    currentUser = userData.toObject();
   }
 
   // Redirect destination for page transition by next/link

+ 0 - 0
apps/app/src/pages/me/index.page.tsx → apps/app/src/pages/me/[[...path]].page.tsx


+ 3 - 14
apps/app/src/pages/share/[[...path]]/index.page.tsx

@@ -1,5 +1,4 @@
 import type { JSX, ReactNode } from 'react';
-import React from 'react';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
@@ -50,11 +49,12 @@ const isInitialProps = (props: Props): props is InitialProps => {
 
 const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   // Initialize Jotai atoms with initial data - must be called unconditionally
-  const pageData = isInitialProps(props) ? props.page : undefined;
+  const pageData = isInitialProps(props) ? props.pageWithMeta?.data : undefined;
+  const pageMeta = isInitialProps(props) ? props.pageWithMeta?.meta : undefined;
   const shareLink = isInitialProps(props) ? props.shareLink : undefined;
   const isExpired = isInitialProps(props) ? props.isExpired : undefined;
 
-  useHydratePageAtoms(pageData, undefined, {
+  useHydratePageAtoms(pageData, pageMeta, {
     shareLinkId: shareLink?._id,
   });
 
@@ -157,17 +157,6 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
   ) {
     return commonEachPropsResult;
   }
-  const commonEachProps = await commonEachPropsResult.props;
-
-  // Handle redirect destination from common props
-  if (commonEachProps.redirectDestination != null) {
-    return {
-      redirect: {
-        permanent: false,
-        destination: commonEachProps.redirectDestination,
-      },
-    };
-  }
 
   //
   // STAGE 2

+ 47 - 32
apps/app/src/pages/share/[[...path]]/page-data-props.ts

@@ -1,6 +1,6 @@
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 import type { IPage } from '@growi/core';
-import { getIdForRef } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
 import type { model } from 'mongoose';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
@@ -14,11 +14,27 @@ let mongooseModel: typeof model;
 let Page: PageModel;
 let ShareLink: ShareLinkModel;
 
+const notFoundProps: GetServerSidePropsResult<ShareLinkPageStatesProps> = {
+  props: {
+    isNotFound: true,
+    pageWithMeta: {
+      data: null,
+      meta: {
+        isNotFound: true,
+        isForbidden: false,
+      },
+    },
+    isExpired: undefined,
+    shareLink: undefined,
+  },
+};
+
 export const getPageDataForInitial = async (
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<ShareLinkPageStatesProps>> => {
   const req = context.req as CrowiRequest;
   const { crowi, params } = req;
+  const { pageService, configManager } = crowi;
 
   if (mongooseModel == null) {
     mongooseModel = (await import('mongoose')).model;
@@ -36,63 +52,62 @@ export const getPageDataForInitial = async (
 
   // not found
   if (shareLink == null) {
-    return {
-      props: {
-        isNotFound: true,
-        page: null,
-        isExpired: undefined,
-        shareLink: undefined,
-      },
-    };
+    return notFoundProps;
+  }
+
+  const pageId = getIdStringForRef(shareLink.relatedPage);
+  const pageWithMeta = await pageService.findPageAndMetaDataByViewer(
+    pageId,
+    null,
+    undefined, // no user for share link
+    true, // isSharedPage
+  );
+
+  // not found
+  if (pageWithMeta.data == null) {
+    return notFoundProps;
   }
 
   // expired
   if (shareLink.isExpired()) {
+    const populatedPage =
+      await pageWithMeta.data.populateDataToShowRevision(true); //shouldExcludeBody = false,
     return {
       props: {
         isNotFound: false,
-        page: null,
+        pageWithMeta: {
+          data: populatedPage,
+          meta: pageWithMeta.meta,
+        },
         isExpired: true,
-        shareLink,
-      },
-    };
-  }
-
-  // retrieve Page
-  const relatedPage = await Page.findOne({
-    _id: getIdForRef(shareLink.relatedPage),
-  });
-
-  // not found
-  if (relatedPage == null) {
-    return {
-      props: {
-        isNotFound: true,
-        page: null,
-        isExpired: undefined,
-        shareLink: undefined,
+        shareLink: shareLink.toObject(),
       },
     };
   }
 
   // Handle existing page
-  const ssrMaxRevisionBodyLength = crowi.configManager.getConfig(
+  const ssrMaxRevisionBodyLength = configManager.getConfig(
     'app:ssrMaxRevisionBodyLength',
   );
 
   // Check if SSR should be skipped
   const latestRevisionBodyLength =
-    await relatedPage.getLatestRevisionBodyLength();
+    await pageWithMeta.data.getLatestRevisionBodyLength();
   const skipSSR =
     latestRevisionBodyLength != null &&
     ssrMaxRevisionBodyLength < latestRevisionBodyLength;
 
-  const populatedPage = await relatedPage.populateDataToShowRevision(skipSSR);
+  // Populate page data for display
+  const populatedPage =
+    await pageWithMeta.data.populateDataToShowRevision(skipSSR);
 
   return {
     props: {
       isNotFound: false,
-      page: populatedPage,
+      pageWithMeta: {
+        data: populatedPage,
+        meta: pageWithMeta.meta,
+      },
       skipSSR,
       isExpired: false,
       shareLink: shareLink.toObject(),

+ 14 - 5
apps/app/src/pages/share/[[...path]]/types.ts

@@ -1,8 +1,14 @@
-import type { IPagePopulatedToShowRevision } from '@growi/core/dist/interfaces';
+import type {
+  IDataWithRequiredMeta,
+  IPageNotFoundInfo,
+} from '@growi/core/dist/interfaces';
 
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import type { CommonEachProps, CommonInitialProps } from '~/pages/common-props';
-import type { GeneralPageInitialProps } from '~/pages/general-page';
+import type {
+  GeneralPageInitialProps,
+  IPageToShowRevisionWithMeta,
+} from '~/pages/general-page';
 
 export type ShareLinkPageStatesProps = Pick<
   GeneralPageInitialProps,
@@ -10,19 +16,22 @@ export type ShareLinkPageStatesProps = Pick<
 > &
   (
     | {
-        page: null;
+        // not found case
+        pageWithMeta: IDataWithRequiredMeta<null, IPageNotFoundInfo>;
         isNotFound: true;
         isExpired: undefined;
         shareLink: undefined;
       }
     | {
-        page: null;
+        // expired case
+        pageWithMeta: IPageToShowRevisionWithMeta;
         isNotFound: false;
         isExpired: true;
         shareLink: IShareLinkHasId;
       }
     | {
-        page: IPagePopulatedToShowRevision;
+        // normal case
+        pageWithMeta: IPageToShowRevisionWithMeta;
         isNotFound: false;
         isExpired: false;
         shareLink: IShareLinkHasId;

+ 8 - 2
apps/app/src/server/crowi/index.js

@@ -26,7 +26,7 @@ import UserEvent from '../events/user';
 import { accessTokenParser } from '../middlewares/access-token-parser';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
-import AttachmentService from '../service/attachment';
+import { AttachmentService } from '../service/attachment';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import instanciateExportService from '../service/export';
 import instanciateExternalAccountService from '../service/external-account';
@@ -74,6 +74,9 @@ class Crowi {
   /** @type {import('../service/config-manager').IConfigManagerForApp} */
   configManager;
 
+  /** @type {AttachmentService} */
+  attachmentService;
+
   /** @type {import('../service/acl').AclService} */
   aclService;
 
@@ -98,6 +101,9 @@ class Crowi {
   /** @type {import('../service/page-operation').IPageOperationService} */
   pageOperationService;
 
+  /** @type {import('../service/customize').CustomizeService} */
+  customizeService;
+
   /** @type {PassportService} */
   passportService;
 
@@ -632,7 +638,7 @@ Crowi.prototype.setUpAcl = async function () {
  * setup CustomizeService
  */
 Crowi.prototype.setUpCustomize = async function () {
-  const CustomizeService = require('../service/customize');
+  const { CustomizeService } = await import('../service/customize');
   if (this.customizeService == null) {
     this.customizeService = new CustomizeService(this);
     this.customizeService.initCustomCss();

+ 25 - 16
apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts

@@ -1,9 +1,9 @@
 import { faker } from '@faker-js/faker';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Response } from 'express';
 import { mock } from 'vitest-mock-extended';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import type UserEvent from '~/server/events/user';
 import { AccessToken } from '~/server/models/access-token';
@@ -13,12 +13,11 @@ import type { AccessTokenParserReq } from './interfaces';
 
 vi.mock('@growi/core/dist/models/serializers', { spy: true });
 
-
 describe('access-token-parser middleware for access token with scopes', () => {
-
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
   let User;
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     const crowiMock = mock<Crowi>({
       event: vi.fn().mockImplementation((eventName) => {
         if (eventName === 'user') {
@@ -32,7 +31,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
     User = userModelFactory(crowiMock);
   });
 
-  it('should call next if no access token is provided', async() => {
+  it('should call next if no access token is provided', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -44,7 +43,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
     expect(reqMock.user).toBeUndefined();
   });
 
-  it('should not authenticate with no scopes', async() => {
+  it('should not authenticate with no scopes', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -76,7 +75,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
     expect(serializeUserSecurely).not.toHaveBeenCalled();
   });
 
-  it('should authenticate with specific scope', async() => {
+  it('should authenticate with specific scope', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -102,7 +101,10 @@ describe('access-token-parser middleware for access token with scopes', () => {
 
     // act
     reqMock.query.access_token = token;
-    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(reqMock, resMock);
+    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(
+      reqMock,
+      resMock,
+    );
 
     // assert
     expect(reqMock.user).toBeDefined();
@@ -110,7 +112,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
   });
 
-  it('should reject with insufficient scopes', async() => {
+  it('should reject with insufficient scopes', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -119,7 +121,6 @@ describe('access-token-parser middleware for access token with scopes', () => {
 
     expect(reqMock.user).toBeUndefined();
 
-
     // prepare a user
     const targetUser = await User.create({
       name: faker.person.fullName(),
@@ -137,14 +138,17 @@ describe('access-token-parser middleware for access token with scopes', () => {
 
     // act - try to access with write:user:info scope
     reqMock.query.access_token = token;
-    await parserForAccessToken([SCOPE.WRITE.USER_SETTINGS.INFO])(reqMock, resMock);
+    await parserForAccessToken([SCOPE.WRITE.USER_SETTINGS.INFO])(
+      reqMock,
+      resMock,
+    );
 
     // // assert
     expect(reqMock.user).toBeUndefined();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
   });
 
-  it('should authenticate with write scope implying read scope', async() => {
+  it('should authenticate with write scope implying read scope', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -170,7 +174,10 @@ describe('access-token-parser middleware for access token with scopes', () => {
 
     // act - try to access with read:user:info scope
     reqMock.query.access_token = token;
-    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(reqMock, resMock);
+    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(
+      reqMock,
+      resMock,
+    );
 
     // assert
     expect(reqMock.user).toBeDefined();
@@ -178,7 +185,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
   });
 
-  it('should authenticate with wildcard scope', async() => {
+  it('should authenticate with wildcard scope', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -202,12 +209,14 @@ describe('access-token-parser middleware for access token with scopes', () => {
 
     // act - try to access with read:user:info scope
     reqMock.query.access_token = token;
-    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO, SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN])(reqMock, resMock);
+    await parserForAccessToken([
+      SCOPE.READ.USER_SETTINGS.INFO,
+      SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN,
+    ])(reqMock, resMock);
 
     // assert
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
   });
-
 });

+ 10 - 7
apps/app/src/server/middlewares/access-token-parser/access-token.ts

@@ -8,15 +8,18 @@ import loggerFactory from '~/utils/logger';
 import { extractBearerToken } from './extract-bearer-token';
 import type { AccessTokenParserReq } from './interfaces';
 
-const logger = loggerFactory('growi:middleware:access-token-parser:access-token');
+const logger = loggerFactory(
+  'growi:middleware:access-token-parser:access-token',
+);
 
 export const parserForAccessToken = (scopes: Scope[]) => {
-  return async(req: AccessTokenParserReq, res: Response): Promise<void> => {
+  return async (req: AccessTokenParserReq, res: Response): Promise<void> => {
     // Extract token from Authorization header first
     // It is more efficient to call it only once in "AccessTokenParser," which is the caller of the method
     const bearerToken = extractBearerToken(req.headers.authorization);
 
-    const accessToken = bearerToken ?? req.query.access_token ?? req.body.access_token;
+    const accessToken =
+      bearerToken ?? req.query.access_token ?? req.body.access_token;
     if (accessToken == null || typeof accessToken !== 'string') {
       return;
     }
@@ -33,14 +36,15 @@ export const parserForAccessToken = (scopes: Scope[]) => {
     }
 
     // check the user is valid
-    const { user: userByAccessToken }: {user: IUserHasId} = await userId.populate('user');
+    const { user: userByAccessToken }: { user: IUserHasId } =
+      await userId.populate('user');
     if (userByAccessToken == null) {
-      logger.debug('The access token\'s associated user is invalid');
+      logger.debug("The access token's associated user is invalid");
       return;
     }
 
     if (userByAccessToken.readOnly) {
-      logger.debug('The access token\'s associated user is read-only');
+      logger.debug("The access token's associated user is read-only");
       return;
     }
 
@@ -52,6 +56,5 @@ export const parserForAccessToken = (scopes: Scope[]) => {
 
     logger.debug('Access token parsed.');
     return;
-
   };
 };

+ 8 - 12
apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts

@@ -1,4 +1,3 @@
-
 import { faker } from '@faker-js/faker';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Response } from 'express';
@@ -10,15 +9,13 @@ import type UserEvent from '~/server/events/user';
 import { parserForApiToken } from './api-token';
 import type { AccessTokenParserReq } from './interfaces';
 
-
 vi.mock('@growi/core/dist/models/serializers', { spy: true });
 
-
 describe('access-token-parser middleware', () => {
-
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
   let User;
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     const crowiMock = mock<Crowi>({
       event: vi.fn().mockImplementation((eventName) => {
         if (eventName === 'user') {
@@ -32,7 +29,7 @@ describe('access-token-parser middleware', () => {
     User = userModelFactory(crowiMock);
   });
 
-  it('should call next if no access token is provided', async() => {
+  it('should call next if no access token is provided', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -49,7 +46,7 @@ describe('access-token-parser middleware', () => {
     expect(serializeUserSecurely).not.toHaveBeenCalled();
   });
 
-  it('should call next if the given access token is invalid', async() => {
+  it('should call next if the given access token is invalid', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -67,7 +64,7 @@ describe('access-token-parser middleware', () => {
     expect(serializeUserSecurely).not.toHaveBeenCalled();
   });
 
-  it('should set req.user with a valid api token in query', async() => {
+  it('should set req.user with a valid api token in query', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -96,7 +93,7 @@ describe('access-token-parser middleware', () => {
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
   });
 
-  it('should set req.user with a valid api token in body', async() => {
+  it('should set req.user with a valid api token in body', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -124,7 +121,7 @@ describe('access-token-parser middleware', () => {
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
   });
 
-  it('should set req.user with a valid Bearer token in Authorization header', async() => {
+  it('should set req.user with a valid Bearer token in Authorization header', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -155,7 +152,7 @@ describe('access-token-parser middleware', () => {
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
   });
 
-  it('should ignore non-Bearer Authorization header', async() => {
+  it('should ignore non-Bearer Authorization header', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -178,5 +175,4 @@ describe('access-token-parser middleware', () => {
     expect(reqMock.user).toBeUndefined();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
   });
-
 });

+ 9 - 4
apps/app/src/server/middlewares/access-token-parser/api-token.ts

@@ -11,14 +11,17 @@ import type { AccessTokenParserReq } from './interfaces';
 
 const logger = loggerFactory('growi:middleware:access-token-parser:api-token');
 
-
-export const parserForApiToken = async(req: AccessTokenParserReq, res: Response): Promise<void> => {
+export const parserForApiToken = async (
+  req: AccessTokenParserReq,
+  res: Response,
+): Promise<void> => {
   // Extract token from Authorization header first
   // It is more efficient to call it only once in "AccessTokenParser," which is the caller of the method
   const bearerToken = extractBearerToken(req.headers.authorization);
 
   // Try all possible token sources in order of priority
-  const accessToken = bearerToken ?? req.query.access_token ?? req.body.access_token;
+  const accessToken =
+    bearerToken ?? req.query.access_token ?? req.body.access_token;
 
   if (accessToken == null || typeof accessToken !== 'string') {
     return;
@@ -26,7 +29,9 @@ export const parserForApiToken = async(req: AccessTokenParserReq, res: Response)
 
   logger.debug('accessToken is', accessToken);
 
-  const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>('User');
+  const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>(
+    'User',
+  );
   const userByApiToken: IUserHasId = await User.findUserByApiToken(accessToken);
 
   if (userByApiToken == null) {

+ 3 - 1
apps/app/src/server/middlewares/access-token-parser/extract-bearer-token.ts

@@ -1,4 +1,6 @@
-export const extractBearerToken = (authHeader: string | undefined): string | null => {
+export const extractBearerToken = (
+  authHeader: string | undefined,
+): string | null => {
   if (authHeader == null) {
     return null;
   }

+ 9 - 3
apps/app/src/server/middlewares/access-token-parser/index.ts

@@ -9,11 +9,17 @@ import type { AccessTokenParserReq } from './interfaces';
 
 const logger = loggerFactory('growi:middleware:access-token-parser');
 
-export type AccessTokenParser = (scopes?: Scope[], opts?: {acceptLegacy: boolean})
-  => (req: AccessTokenParserReq, res: Response, next: NextFunction) => Promise<void>
+export type AccessTokenParser = (
+  scopes?: Scope[],
+  opts?: { acceptLegacy: boolean },
+) => (
+  req: AccessTokenParserReq,
+  res: Response,
+  next: NextFunction,
+) => Promise<void>;
 
 export const accessTokenParser: AccessTokenParser = (scopes, opts) => {
-  return async(req, res, next): Promise<void> => {
+  return async (req, res, next): Promise<void> => {
     if (scopes == null || scopes.length === 0) {
       logger.warn('scopes is empty');
       return next();

+ 7 - 6
apps/app/src/server/middlewares/access-token-parser/interfaces.ts

@@ -3,12 +3,13 @@ import type { IUserSerializedSecurely } from '@growi/core/dist/models/serializer
 import type { Request } from 'express';
 
 type ReqQuery = {
-  access_token?: string,
-}
+  access_token?: string;
+};
 type ReqBody = {
-  access_token?: string,
-}
+  access_token?: string;
+};
 
-export interface AccessTokenParserReq extends Request<undefined, undefined, ReqBody, ReqQuery> {
-  user?: IUserSerializedSecurely<IUserHasId>,
+export interface AccessTokenParserReq
+  extends Request<undefined, undefined, ReqBody, ReqQuery> {
+  user?: IUserSerializedSecurely<IUserHasId>;
 }

+ 30 - 26
apps/app/src/server/middlewares/add-activity.ts

@@ -5,36 +5,40 @@ import { SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:middlewares:add-activity');
 
 interface AuthorizedRequest extends Request {
-  user?: IUserHasId
+  user?: IUserHasId;
 }
 
-export const generateAddActivityMiddleware = () => async(req: AuthorizedRequest, res: Response, next: NextFunction): Promise<void> => {
-  if (req.method === 'GET') {
-    logger.warn('This middleware is not available for GET requests');
-    return next();
-  }
+export const generateAddActivityMiddleware =
+  () =>
+  async (
+    req: AuthorizedRequest,
+    res: Response,
+    next: NextFunction,
+  ): Promise<void> => {
+    if (req.method === 'GET') {
+      logger.warn('This middleware is not available for GET requests');
+      return next();
+    }
+
+    const parameter = {
+      ip: req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_UNSETTLED,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+
+    try {
+      const activity = await Activity.createByParameters(parameter);
+      res.locals.activity = activity;
+    } catch (err) {
+      logger.error('Create activity failed', err);
+    }
 
-  const parameter = {
-    ip:  req.ip,
-    endpoint: req.originalUrl,
-    action: SupportedAction.ACTION_UNSETTLED,
-    user: req.user?._id,
-    snapshot: {
-      username: req.user?.username,
-    },
+    return next();
   };
-
-  try {
-    const activity = await Activity.createByParameters(parameter);
-    res.locals.activity = activity;
-  }
-  catch (err) {
-    logger.error('Create activity failed', err);
-  }
-
-  return next();
-};

+ 2 - 4
apps/app/src/server/middlewares/admin-required.js

@@ -4,10 +4,8 @@ const logger = loggerFactory('growi:middleware:admin-required');
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi, fallback = null) => {
-
-  return function(req, res, next) {
-
-    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
+  return (req, res, next) => {
+    if (req.user != null && req.user instanceof Object && '_id' in req.user) {
       if (req.user.admin) {
         return next();
       }

+ 2 - 1
apps/app/src/server/middlewares/apiv1-form-validator.ts

@@ -14,7 +14,8 @@ export default (req: Request, res: Response, next: NextFunction): void => {
 
   const errObjArray = validationResult(req);
   if (errObjArray.isEmpty()) {
-    return next();
+    next();
+    return;
   }
 
   const errs = errObjArray.array().map((err) => {

+ 8 - 3
apps/app/src/server/middlewares/apiv3-form-validator.ts

@@ -6,14 +6,19 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:middlewares:ApiV3FormValidator');
 const { validationResult } = require('express-validator');
 
-export const apiV3FormValidator = (req: Request, res: Response & { apiv3Err }, next: NextFunction): void => {
+export const apiV3FormValidator = (
+  req: Request,
+  res: Response & { apiv3Err },
+  next: NextFunction,
+): void => {
   logger.debug('req.query', req.query);
   logger.debug('req.params', req.params);
   logger.debug('req.body', req.body);
 
   const errObjArray = validationResult(req);
   if (errObjArray.isEmpty()) {
-    return next();
+    next();
+    return;
   }
 
   const errs = errObjArray.array().map((err) => {
@@ -21,5 +26,5 @@ export const apiV3FormValidator = (req: Request, res: Response & { apiv3Err }, n
     return new ErrorV3(`${err.param}: ${err.msg}`, 'validation_failed');
   });
 
-  return res.apiv3Err(errs);
+  res.apiv3Err(errs);
 };

+ 3 - 2
apps/app/src/server/middlewares/application-installed.ts

@@ -2,7 +2,7 @@
 module.exports = (crowi) => {
   const { appService } = crowi;
 
-  return async(req, res, next) => {
+  return async (req, res, next) => {
     const isDBInitialized = await appService.isDBInitialized();
 
     // when already installed
@@ -11,7 +11,8 @@ module.exports = (crowi) => {
     }
 
     // when other server have initialized DB
-    const isDBInitializedAfterForceReload = await appService.isDBInitialized(true);
+    const isDBInitializedAfterForceReload =
+      await appService.isDBInitialized(true);
     if (isDBInitializedAfterForceReload) {
       await appService.setupAfterInstall();
       return res.safeRedirect(req.originalUrl);

+ 32 - 14
apps/app/src/server/middlewares/application-not-installed.ts

@@ -6,31 +6,43 @@ import type Crowi from '../crowi';
 /**
  * Middleware factory to check if the application is already installed
  */
-export const generateCheckerMiddleware = (crowi: Crowi) => async(req: Request, res: Response, next: NextFunction): Promise<void> => {
-  const { appService } = crowi;
+export const generateCheckerMiddleware =
+  (crowi: Crowi) =>
+  async (req: Request, res: Response, next: NextFunction): Promise<void> => {
+    const { appService } = crowi;
 
-  const isDBInitialized = await appService.isDBInitialized(true);
+    const isDBInitialized = await appService.isDBInitialized(true);
 
-  if (isDBInitialized) {
-    return next(createError(409, 'Application is already installed'));
-  }
+    if (isDBInitialized) {
+      return next(createError(409, 'Application is already installed'));
+    }
 
-  return next();
-};
+    return next();
+  };
 
 /**
  * Middleware to return HttpError 409 if the application is already installed
  */
-export const allreadyInstalledMiddleware = async(req: Request, res: Response, next: NextFunction): Promise<void> => {
+export const allreadyInstalledMiddleware = async (
+  req: Request,
+  res: Response,
+  next: NextFunction,
+): Promise<void> => {
   return next(createError(409, 'Application is already installed'));
 };
 
 /**
  * Error handler to handle errors as API errors
  */
-export const handleAsApiError = (error: Error, req: Request, res: Response, next: NextFunction): void => {
+export const handleAsApiError = (
+  error: Error,
+  req: Request,
+  res: Response,
+  next: NextFunction,
+): void => {
   if (error == null) {
-    return next();
+    next();
+    return;
   }
 
   if (isHttpError(error)) {
@@ -45,9 +57,15 @@ export const handleAsApiError = (error: Error, req: Request, res: Response, next
 /**
  * Error handler to redirect to top page on error
  */
-export const redirectToTopOnError = (error: Error, req: Request, res: Response, next: NextFunction): void => {
+export const redirectToTopOnError = (
+  error: Error,
+  req: Request,
+  res: Response,
+  next: NextFunction,
+): void => {
   if (error != null) {
-    return res.redirect('/');
+    res.redirect('/');
+    return;
   }
-  return next();
+  next();
 };

+ 4 - 1
apps/app/src/server/middlewares/auto-reconnect-to-s2s-msg-server.js

@@ -3,7 +3,10 @@ module.exports = (crowi) => {
   const { s2sMessagingService } = crowi;
 
   return (req, res, next) => {
-    if (s2sMessagingService != null && s2sMessagingService.shouldResubscribe()) {
+    if (
+      s2sMessagingService != null &&
+      s2sMessagingService.shouldResubscribe()
+    ) {
       s2sMessagingService.subscribe();
     }
 

+ 11 - 5
apps/app/src/server/middlewares/auto-reconnect-to-search.js

@@ -1,6 +1,9 @@
 import loggerFactory from '~/utils/logger';
 
-const { ReconnectContext, nextTick } = require('../service/search-reconnect-context/reconnect-context');
+const {
+  ReconnectContext,
+  nextTick,
+} = require('../service/search-reconnect-context/reconnect-context');
 
 const logger = loggerFactory('growi:middlewares:auto-reconnect-to-search');
 
@@ -9,12 +12,11 @@ module.exports = (crowi) => {
   const { searchService } = crowi;
   const reconnectContext = new ReconnectContext();
 
-  const reconnectHandler = async() => {
+  const reconnectHandler = async () => {
     try {
       logger.info('Auto reconnection is started.');
       await searchService.reconnectClient();
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Auto reconnection failed.', err);
     }
 
@@ -22,7 +24,11 @@ module.exports = (crowi) => {
   };
 
   return (req, res, next) => {
-    if (searchService != null && searchService.isConfigured && !searchService.isReachable) {
+    if (
+      searchService != null &&
+      searchService.isConfigured &&
+      !searchService.isReachable
+    ) {
       // NON-BLOCKING CALL
       // for the latency of the response
       nextTick(reconnectContext, reconnectHandler);

+ 1 - 3
apps/app/src/server/middlewares/certify-brand-logo.ts

@@ -1,10 +1,8 @@
 import type Crowi from '../crowi';
 
 export const generateCertifyBrandLogoMiddleware = (crowi: Crowi) => {
-
-  return async(req, res, next) => {
+  return async (req, res, next) => {
     req.isBrandLogo = true;
     next();
   };
-
 };

+ 13 - 9
apps/app/src/server/middlewares/certify-origin.ts

@@ -4,37 +4,41 @@ import type { NextFunction, Response } from 'express';
 import loggerFactory from '../../utils/logger';
 import { configManager } from '../service/config-manager';
 import isSimpleRequest from '../util/is-simple-request';
-
 import type { AccessTokenParserReq } from './access-token-parser/interfaces';
 
-
 const logger = loggerFactory('growi:middleware:certify-origin');
 
 type Apiv3ErrFunction = (error: ErrorV3) => void;
 
-const certifyOrigin = (req: AccessTokenParserReq, res: Response & { apiv3Err: Apiv3ErrFunction }, next: NextFunction): void => {
-
+const certifyOrigin = (
+  req: AccessTokenParserReq,
+  res: Response & { apiv3Err: Apiv3ErrFunction },
+  next: NextFunction,
+): void => {
   const appSiteUrl = configManager.getConfig('app:siteUrl');
   const configuredOrigin = appSiteUrl ? new URL(appSiteUrl).origin : null;
   const requestOrigin = req.headers.origin;
   const runtimeOrigin = `${req.protocol}://${req.get('host')}`;
 
-  const isSameOriginReq = requestOrigin == null
-  || requestOrigin === configuredOrigin
-  || requestOrigin === runtimeOrigin;
+  const isSameOriginReq =
+    requestOrigin == null ||
+    requestOrigin === configuredOrigin ||
+    requestOrigin === runtimeOrigin;
 
   const accessToken = req.query.access_token ?? req.body.access_token;
 
   if (!isSameOriginReq && req.headers.origin != null && isSimpleRequest(req)) {
     const message = 'Invalid request (origin check failed but simple request)';
     logger.error(message);
-    return res.apiv3Err(new ErrorV3(message));
+    res.apiv3Err(new ErrorV3(message));
+    return;
   }
 
   if (!isSameOriginReq && accessToken == null && !isSimpleRequest(req)) {
     const message = 'Invalid request (origin check failed and no access token)';
     logger.error(message);
-    return res.apiv3Err(new ErrorV3(message));
+    res.apiv3Err(new ErrorV3(message));
+    return;
   }
 
   next();

+ 38 - 19
apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.spec.ts

@@ -3,7 +3,10 @@ import { mock } from 'vitest-mock-extended';
 
 import type { ShareLinkDocument } from '~/server/models/share-link';
 
-import { certifySharedPageAttachmentMiddleware, type RequestToAllowShareLink } from './certify-shared-page-attachment';
+import {
+  certifySharedPageAttachmentMiddleware,
+  type RequestToAllowShareLink,
+} from './certify-shared-page-attachment';
 import type { ValidReferer } from './interfaces';
 
 const mocks = vi.hoisted(() => {
@@ -14,19 +17,22 @@ const mocks = vi.hoisted(() => {
   };
 });
 
-vi.mock('./validate-referer', () => ({ validateReferer: mocks.validateRefererMock }));
-vi.mock('./retrieve-valid-share-link', () => ({ retrieveValidShareLinkByReferer: mocks.retrieveValidShareLinkByRefererMock }));
-vi.mock('./validate-attachment', () => ({ validateAttachment: mocks.validateAttachmentMock }));
-
+vi.mock('./validate-referer', () => ({
+  validateReferer: mocks.validateRefererMock,
+}));
+vi.mock('./retrieve-valid-share-link', () => ({
+  retrieveValidShareLinkByReferer: mocks.retrieveValidShareLinkByRefererMock,
+}));
+vi.mock('./validate-attachment', () => ({
+  validateAttachment: mocks.validateAttachmentMock,
+}));
 
 describe('certifySharedPageAttachmentMiddleware', () => {
-
   const res = mock<Response>();
   const next = vi.fn();
 
   describe('should called next() without req.isSharedPage set', () => {
-
-    it('when the fileId param is null', async() => {
+    it('when the fileId param is null', async () => {
       // setup
       const req = mock<RequestToAllowShareLink>();
       req.params = {}; // id: undefined
@@ -41,7 +47,7 @@ describe('certifySharedPageAttachmentMiddleware', () => {
       expect(next).toHaveBeenCalledOnce();
     });
 
-    it('when validateReferer returns null', async() => {
+    it('when validateReferer returns null', async () => {
       // setup
       const req = mock<RequestToAllowShareLink>();
       req.params = { id: 'file id string' };
@@ -57,7 +63,7 @@ describe('certifySharedPageAttachmentMiddleware', () => {
       expect(next).toHaveBeenCalledOnce();
     });
 
-    it('when retrieveValidShareLinkByReferer returns null', async() => {
+    it('when retrieveValidShareLinkByReferer returns null', async () => {
       // setup
       const req = mock<RequestToAllowShareLink>();
       req.params = { id: 'file id string' };
@@ -78,12 +84,14 @@ describe('certifySharedPageAttachmentMiddleware', () => {
       expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
       expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
       expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
-      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(
+        validReferer,
+      );
       expect(req.isSharedPage === true).toBeFalsy();
       expect(next).toHaveBeenCalledOnce();
     });
 
-    it('when validateAttachment returns false', async() => {
+    it('when validateAttachment returns false', async () => {
       // setup
       const req = mock<RequestToAllowShareLink>();
       req.params = { id: 'file id string' };
@@ -93,7 +101,9 @@ describe('certifySharedPageAttachmentMiddleware', () => {
       mocks.validateRefererMock.mockImplementation(() => validReferer);
 
       const shareLinkMock = mock<ShareLinkDocument>();
-      mocks.retrieveValidShareLinkByRefererMock.mockResolvedValue(shareLinkMock);
+      mocks.retrieveValidShareLinkByRefererMock.mockResolvedValue(
+        shareLinkMock,
+      );
 
       mocks.validateAttachmentMock.mockResolvedValue(false);
 
@@ -104,16 +114,20 @@ describe('certifySharedPageAttachmentMiddleware', () => {
       expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
       expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
       expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
-      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(
+        validReferer,
+      );
       expect(mocks.validateAttachmentMock).toHaveBeenCalledOnce();
-      expect(mocks.validateAttachmentMock).toHaveBeenCalledWith('file id string', shareLinkMock);
+      expect(mocks.validateAttachmentMock).toHaveBeenCalledWith(
+        'file id string',
+        shareLinkMock,
+      );
       expect(req.isSharedPage === true).toBeFalsy();
       expect(next).toHaveBeenCalledOnce();
     });
-
   });
 
-  it('should set req.isSharedPage true', async() => {
+  it('should set req.isSharedPage true', async () => {
     // setup
     const req = mock<RequestToAllowShareLink>();
     req.params = { id: 'file id string' };
@@ -134,9 +148,14 @@ describe('certifySharedPageAttachmentMiddleware', () => {
     expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
     expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
     expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
-    expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+    expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(
+      validReferer,
+    );
     expect(mocks.validateAttachmentMock).toHaveBeenCalledOnce();
-    expect(mocks.validateAttachmentMock).toHaveBeenCalledWith('file id string', shareLinkMock);
+    expect(mocks.validateAttachmentMock).toHaveBeenCalledWith(
+      'file id string',
+      shareLinkMock,
+    );
 
     expect(req.isSharedPage === true).toBeTruthy();
 

+ 15 - 9
apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts

@@ -6,21 +6,24 @@ import { retrieveValidShareLinkByReferer } from './retrieve-valid-share-link';
 import { validateAttachment } from './validate-attachment';
 import { validateReferer } from './validate-referer';
 
-
 const logger = loggerFactory('growi:middleware:certify-shared-page-attachment');
 
-
 export interface RequestToAllowShareLink extends Request {
-  isSharedPage?: boolean,
+  isSharedPage?: boolean;
 }
 
-export const certifySharedPageAttachmentMiddleware = async(req: RequestToAllowShareLink, res: Response, next: NextFunction): Promise<void> => {
-
+export const certifySharedPageAttachmentMiddleware = async (
+  req: RequestToAllowShareLink,
+  res: Response,
+  next: NextFunction,
+): Promise<void> => {
   const fileId: string | undefined = req.params.id;
   const { referer } = req.headers;
 
   if (fileId == null) {
-    logger.error('The param fileId is required. Please confirm to usage of this middleware.');
+    logger.error(
+      'The param fileId is required. Please confirm to usage of this middleware.',
+    );
     return next();
   }
 
@@ -31,16 +34,19 @@ export const certifySharedPageAttachmentMiddleware = async(req: RequestToAllowSh
 
   const shareLink = await retrieveValidShareLinkByReferer(validReferer);
   if (shareLink == null) {
-    logger.warn(`No valid ShareLink document found by the referer (${validReferer.referer}})`);
+    logger.warn(
+      `No valid ShareLink document found by the referer (${validReferer.referer}})`,
+    );
     return next();
   }
 
   if (!(await validateAttachment(fileId, shareLink))) {
-    logger.warn(`No valid ShareLink document found by the fileId (${fileId}) and referer (${validReferer.referer}})`);
+    logger.warn(
+      `No valid ShareLink document found by the fileId (${fileId}) and referer (${validReferer.referer}})`,
+    );
     return next();
   }
 
   req.isSharedPage = true;
   next();
-
 };

+ 2 - 2
apps/app/src/server/middlewares/certify-shared-page-attachment/interfaces.ts

@@ -1,4 +1,4 @@
 export type ValidReferer = {
-  referer: string,
-  shareLinkId: string,
+  referer: string;
+  shareLinkId: string;
 };

+ 19 - 8
apps/app/src/server/middlewares/certify-shared-page-attachment/retrieve-valid-share-link.ts

@@ -1,17 +1,26 @@
-import type { ShareLinkDocument, ShareLinkModel } from '~/server/models/share-link';
+import type {
+  ShareLinkDocument,
+  ShareLinkModel,
+} from '~/server/models/share-link';
 import { getModelSafely } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 import type { ValidReferer } from './interfaces';
 
+const logger = loggerFactory(
+  'growi:middleware:certify-shared-page-attachment:retrieve-valid-share-link',
+);
 
-const logger = loggerFactory('growi:middleware:certify-shared-page-attachment:retrieve-valid-share-link');
-
-
-export const retrieveValidShareLinkByReferer = async(referer: ValidReferer): Promise<ShareLinkDocument | null> => {
-  const ShareLink = getModelSafely<ShareLinkDocument, ShareLinkModel>('ShareLink');
+export const retrieveValidShareLinkByReferer = async (
+  referer: ValidReferer,
+): Promise<ShareLinkDocument | null> => {
+  const ShareLink = getModelSafely<ShareLinkDocument, ShareLinkModel>(
+    'ShareLink',
+  );
   if (ShareLink == null) {
-    logger.warn('Could not get ShareLink model. next() will be called without processing anything.');
+    logger.warn(
+      'Could not get ShareLink model. next() will be called without processing anything.',
+    );
     return null;
   }
 
@@ -20,7 +29,9 @@ export const retrieveValidShareLinkByReferer = async(referer: ValidReferer): Pro
     _id: shareLinkId,
   });
   if (shareLink == null || shareLink.isExpired()) {
-    logger.info(`ShareLink ('${shareLinkId}') is not found or has already expired.`);
+    logger.info(
+      `ShareLink ('${shareLinkId}') is not found or has already expired.`,
+    );
     return null;
   }
 

+ 10 - 5
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-attachment.ts

@@ -4,14 +4,19 @@ import type { ShareLinkDocument } from '~/server/models/share-link';
 import { getModelSafely } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
+const logger = loggerFactory(
+  'growi:middleware:certify-shared-page-attachment:validate-attachment',
+);
 
-const logger = loggerFactory('growi:middleware:certify-shared-page-attachment:validate-attachment');
-
-
-export const validateAttachment = async(fileId: string, shareLink: ShareLinkDocument): Promise<boolean> => {
+export const validateAttachment = async (
+  fileId: string,
+  shareLink: ShareLinkDocument,
+): Promise<boolean> => {
   const Attachment = getModelSafely<IAttachment>('Attachment');
   if (Attachment == null) {
-    logger.warn('Could not get Attachment model. next() will be called without processing anything.');
+    logger.warn(
+      'Could not get Attachment model. next() will be called without processing anything.',
+    );
     return false;
   }
 

+ 0 - 4
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.spec.ts

@@ -12,11 +12,8 @@ vi.mock('~/server/service/config-manager', () => {
   return { configManager: mocks.configManagerMock };
 });
 
-
 describe('retrieveSiteUrl', () => {
-
   describe('returns null', () => {
-
     it('when the siteUrl is not set', () => {
       // setup
       mocks.configManagerMock.getConfig.mockImplementation(() => {
@@ -55,5 +52,4 @@ describe('retrieveSiteUrl', () => {
     // then
     expect(result).toEqual(new URL(siteUrl));
   });
-
 });

+ 7 - 6
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.ts

@@ -1,21 +1,22 @@
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
-
-const logger = loggerFactory('growi:middlewares:certify-shared-page-attachment:validate-referer:retrieve-site-url');
-
+const logger = loggerFactory(
+  'growi:middlewares:certify-shared-page-attachment:validate-referer:retrieve-site-url',
+);
 
 export const retrieveSiteUrl = (): URL | null => {
   const siteUrlString = configManager.getConfig('app:siteUrl');
   if (siteUrlString == null) {
-    logger.warn("Verification referer does not work because 'Site URL' is NOT set. All of attachments in share link page is invisible.");
+    logger.warn(
+      "Verification referer does not work because 'Site URL' is NOT set. All of attachments in share link page is invisible.",
+    );
     return null;
   }
 
   try {
     return new URL(siteUrlString);
-  }
-  catch (err) {
+  } catch (err) {
     logger.error(`Parsing 'app:siteUrl' ('${siteUrlString}') has failed.`);
     return null;
   }

+ 5 - 8
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.spec.ts

@@ -8,11 +8,11 @@ const mocks = vi.hoisted(() => {
   };
 });
 
-vi.mock('./retrieve-site-url', () => ({ retrieveSiteUrl: mocks.retrieveSiteUrlMock }));
-
+vi.mock('./retrieve-site-url', () => ({
+  retrieveSiteUrl: mocks.retrieveSiteUrlMock,
+}));
 
 describe('validateReferer', () => {
-
   const isValidObjectIdSpy = vi.spyOn(objectIdUtils, 'isValidObjectId');
 
   beforeEach(() => {
@@ -20,7 +20,6 @@ describe('validateReferer', () => {
   });
 
   describe('refurns false', () => {
-
     it('when the referer argument is undefined', () => {
       // setup
 
@@ -98,7 +97,8 @@ describe('validateReferer', () => {
       });
 
       // when
-      const refererString = 'https://example.com/share/FFFFFFFFFFFFFFFFFFFFFFFF';
+      const refererString =
+        'https://example.com/share/FFFFFFFFFFFFFFFFFFFFFFFF';
       const result = validateReferer(refererString);
 
       // then
@@ -106,7 +106,6 @@ describe('validateReferer', () => {
       expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
       expect(isValidObjectIdSpy).toHaveBeenCalledOnce();
     });
-
   });
 
   it('returns ValidReferer instance', () => {
@@ -115,7 +114,6 @@ describe('validateReferer', () => {
       return new URL('https://example.com');
     });
 
-
     // when
     const shareLinkId = '65436ba09ae6983bd608b89c';
     const refererString = `https://example.com/share/${shareLinkId}`;
@@ -127,5 +125,4 @@ describe('validateReferer', () => {
       shareLinkId,
     });
   });
-
 });

+ 17 - 10
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.ts

@@ -3,14 +3,15 @@ import { objectIdUtils } from '@growi/core/dist/utils';
 import loggerFactory from '~/utils/logger';
 
 import type { ValidReferer } from '../interfaces';
-
 import { retrieveSiteUrl } from './retrieve-site-url';
 
+const logger = loggerFactory(
+  'growi:middlewares:certify-shared-page-attachment:validate-referer',
+);
 
-const logger = loggerFactory('growi:middlewares:certify-shared-page-attachment:validate-referer');
-
-
-export const validateReferer = (referer: string | undefined): ValidReferer | false => {
+export const validateReferer = (
+  referer: string | undefined,
+): ValidReferer | false => {
   // not null
   if (referer == null) {
     logger.info('The referer string is undefined');
@@ -20,8 +21,7 @@ export const validateReferer = (referer: string | undefined): ValidReferer | fal
   let refererUrl: URL;
   try {
     refererUrl = new URL(referer);
-  }
-  catch (err) {
+  } catch (err) {
     logger.info(`Parsing referer ('${referer}') has failed`);
     return false;
   }
@@ -34,7 +34,10 @@ export const validateReferer = (referer: string | undefined): ValidReferer | fal
   }
 
   // validate hostname and port
-  if (refererUrl.hostname !== siteUrl.hostname || refererUrl.port !== siteUrl.port) {
+  if (
+    refererUrl.hostname !== siteUrl.hostname ||
+    refererUrl.port !== siteUrl.port
+  ) {
     logger.warn('The hostname or port mismatched.', {
       refererUrl: {
         hostname: refererUrl.hostname,
@@ -50,7 +53,9 @@ export const validateReferer = (referer: string | undefined): ValidReferer | fal
 
   // validate pathname
   // https://regex101.com/r/M5Bp6E/1
-  const match = refererUrl.pathname.match(/^\/share\/(?<shareLinkId>[a-f0-9]{24})$/i);
+  const match = refererUrl.pathname.match(
+    /^\/share\/(?<shareLinkId>[a-f0-9]{24})$/i,
+  );
   if (match == null) {
     return false;
   }
@@ -61,7 +66,9 @@ export const validateReferer = (referer: string | undefined): ValidReferer | fal
 
   // validate shareLinkId is an correct ObjectId
   if (!objectIdUtils.isValidObjectId(match.groups.shareLinkId)) {
-    logger.warn(`The shareLinkId ('${match.groups.shareLinkId}') is invalid as an ObjectId.`);
+    logger.warn(
+      `The shareLinkId ('${match.groups.shareLinkId}') is invalid as an ObjectId.`,
+    );
     return false;
   }
 

+ 5 - 4
apps/app/src/server/middlewares/certify-shared-page.js

@@ -5,15 +5,17 @@ const logger = loggerFactory('growi:middleware:certify-shared-page');
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-
-  return async(req, res, next) => {
+  return async (req, res, next) => {
     const pageId = req.query.pageId || req.body.pageId || null;
     const shareLinkId = req.query.shareLinkId || req.body.shareLinkId || null;
     if (pageId == null || shareLinkId == null) {
       return next();
     }
 
-    const sharelink = await ShareLink.findOne({ _id: shareLinkId, relatedPage: pageId });
+    const sharelink = await ShareLink.findOne({
+      _id: { $eq: shareLinkId },
+      relatedPage: { $eq: pageId },
+    });
 
     // check sharelink enabled
     if (sharelink == null || sharelink.isExpired()) {
@@ -28,5 +30,4 @@ module.exports = (crowi) => {
 
     next();
   };
-
 };

+ 8 - 6
apps/app/src/server/middlewares/exclude-read-only-user.spec.ts

@@ -1,20 +1,22 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import type { NextFunction, Response } from 'express';
+import type { Request } from 'express-validator/src/base';
 
 import { excludeReadOnlyUser } from './exclude-read-only-user';
 
 describe('excludeReadOnlyUser', () => {
-  let req;
-  let res;
-  let next;
+  let req: Request;
+  let res: Response & { apiv3Err: ReturnType<typeof vi.fn> };
+  let next: NextFunction;
 
   beforeEach(() => {
     req = {
       user: {},
-    };
+    } as unknown as Request;
     res = {
       apiv3Err: vi.fn(),
-    };
-    next = vi.fn();
+    } as unknown as Response & { apiv3Err: ReturnType<typeof vi.fn> };
+    next = vi.fn() as unknown as NextFunction;
   });
 
   test('should call next if user is not found', () => {

+ 23 - 11
apps/app/src/server/middlewares/exclude-read-only-user.ts

@@ -6,44 +6,56 @@ import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../service/config-manager';
 
-
 const logger = loggerFactory('growi:middleware:exclude-read-only-user');
 
-export const excludeReadOnlyUser = (req: Request, res: Response & { apiv3Err }, next: () => NextFunction): NextFunction => {
+export const excludeReadOnlyUser = (
+  req: Request,
+  res: Response & { apiv3Err },
+  next: NextFunction,
+): void => {
   const user = req.user;
 
   if (user == null) {
     logger.warn('req.user is null');
-    return next();
+    next();
+    return;
   }
 
   if (user.readOnly) {
     const message = 'This user is read only user';
     logger.warn(message);
 
-    return res.apiv3Err(new ErrorV3(message, 'validation_failed'));
+    res.apiv3Err(new ErrorV3(message, 'validation_failed'));
+    return;
   }
 
-  return next();
+  next();
 };
 
-export const excludeReadOnlyUserIfCommentNotAllowed = (req: Request, res: Response & { apiv3Err }, next: () => NextFunction): NextFunction => {
+export const excludeReadOnlyUserIfCommentNotAllowed = (
+  req: Request,
+  res: Response & { apiv3Err },
+  next: NextFunction,
+): void => {
   const user = req.user;
 
-  const isRomUserAllowedToComment = configManager.getConfig('security:isRomUserAllowedToComment');
+  const isRomUserAllowedToComment = configManager.getConfig(
+    'security:isRomUserAllowedToComment',
+  );
 
   if (user == null) {
     logger.warn('req.user is null');
-    return next();
+    next();
+    return;
   }
 
   if (user.readOnly && !isRomUserAllowedToComment) {
     const message = 'This user is read only user and comment is not allowed';
     logger.warn(message);
 
-    return res.apiv3Err(new ErrorV3(message, 'validation_failed'));
+    res.apiv3Err(new ErrorV3(message, 'validation_failed'));
+    return;
   }
 
-  return next();
-
+  next();
 };

+ 6 - 9
apps/app/src/server/middlewares/http-error-handler.js

@@ -4,20 +4,17 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middleware:htto-error-handler');
 
-module.exports = async(err, req, res, next) => {
+module.exports = async (err, req, res, next) => {
   // handle if the err is a HttpError instance
   if (isHttpError(err)) {
     const httpError = err;
 
     try {
-      return res
-        .status(httpError.status)
-        .send({
-          status: httpError.status,
-          message: httpError.message,
-        });
-    }
-    catch (err) {
+      return res.status(httpError.status).send({
+        status: httpError.status,
+        message: httpError.message,
+      });
+    } catch (err) {
       logger.error('Cannot call res.send() twice:', err);
     }
   }

+ 24 - 10
apps/app/src/server/middlewares/inject-reset-order-by-token-middleware.ts

@@ -10,29 +10,43 @@ import PasswordResetOrder from '../models/password-reset-order';
 const logger = loggerFactory('growi:routes:forgot-password');
 
 export type ReqWithPasswordResetOrder = Request & {
-  passwordResetOrder: IPasswordResetOrder,
+  passwordResetOrder: IPasswordResetOrder;
 };
 
 // eslint-disable-next-line import/no-anonymous-default-export
-export default async(req: ReqWithPasswordResetOrder, res: Response, next: NextFunction): Promise<void> => {
+export default async (
+  req: ReqWithPasswordResetOrder,
+  res: Response,
+  next: NextFunction,
+): Promise<void> => {
   const token: string = req.params.token || req.body.token;
 
   if (token == null) {
     logger.error('Token not found');
-    return next(createError(400, 'Token not found', { code: forgotPasswordErrorCode.TOKEN_NOT_FOUND }));
+    return next(
+      createError(400, 'Token not found', {
+        code: forgotPasswordErrorCode.TOKEN_NOT_FOUND,
+      }),
+    );
   }
 
-  const passwordResetOrder = await PasswordResetOrder.findOne({ token: { $eq: token } });
+  const passwordResetOrder = await PasswordResetOrder.findOne({
+    token: { $eq: token },
+  });
 
   // check if the token is valid
-  if (passwordResetOrder == null || passwordResetOrder.isExpired() || passwordResetOrder.isRevoked) {
+  if (
+    passwordResetOrder == null ||
+    passwordResetOrder.isExpired() ||
+    passwordResetOrder.isRevoked
+  ) {
     const message = 'passwordResetOrder is null or expired or revoked';
     logger.error(message);
-    return next(createError(
-      400,
-      'passwordResetOrder is null or expired or revoked',
-      { code: forgotPasswordErrorCode.PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE },
-    ));
+    return next(
+      createError(400, 'passwordResetOrder is null or expired or revoked', {
+        code: forgotPasswordErrorCode.PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE,
+      }),
+    );
   }
 
   req.passwordResetOrder = passwordResetOrder;

+ 26 - 8
apps/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts

@@ -1,4 +1,4 @@
-import type { Request, Response, NextFunction } from 'express';
+import type { NextFunction, Request, Response } from 'express';
 import createError from 'http-errors';
 
 import { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
@@ -10,33 +10,51 @@ import UserRegistrationOrder from '../models/user-registration-order';
 const logger = loggerFactory('growi:routes:user-activation');
 
 export type ReqWithUserRegistrationOrder = Request & {
-  userRegistrationOrder: IUserRegistrationOrder
+  userRegistrationOrder: IUserRegistrationOrder;
 };
 
 // eslint-disable-next-line import/no-anonymous-default-export
-export default async(req: ReqWithUserRegistrationOrder, res: Response, next: NextFunction): Promise<void> => {
+export default async (
+  req: ReqWithUserRegistrationOrder,
+  res: Response,
+  next: NextFunction,
+): Promise<void> => {
   const token = req.params.token || req.body.token;
 
   if (token == null) {
     const msg = 'Token not found';
     logger.error(msg);
-    return next(createError(400, msg, { code: UserActivationErrorCode.TOKEN_NOT_FOUND }));
+    return next(
+      createError(400, msg, { code: UserActivationErrorCode.TOKEN_NOT_FOUND }),
+    );
   }
 
   if (typeof token !== 'string') {
     const msg = 'Invalid token format';
     logger.error(msg);
-    return next(createError(400, msg, { code: UserActivationErrorCode.INVALID_TOKEN }));
+    return next(
+      createError(400, msg, { code: UserActivationErrorCode.INVALID_TOKEN }),
+    );
   }
 
   // exec query safely with $eq
-  const userRegistrationOrder = await UserRegistrationOrder.findOne({ token: { $eq: token } });
+  const userRegistrationOrder = await UserRegistrationOrder.findOne({
+    token: { $eq: token },
+  });
 
   // check if the token is valid
-  if (userRegistrationOrder == null || userRegistrationOrder.isExpired() || userRegistrationOrder.isRevoked) {
+  if (
+    userRegistrationOrder == null ||
+    userRegistrationOrder.isExpired() ||
+    userRegistrationOrder.isRevoked
+  ) {
     const msg = 'userRegistrationOrder is null or expired or revoked';
     logger.error(msg);
-    return next(createError(400, msg, { code: UserActivationErrorCode.USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE }));
+    return next(
+      createError(400, msg, {
+        code: UserActivationErrorCode.USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE,
+      }),
+    );
   }
 
   req.userRegistrationOrder = userRegistrationOrder;

+ 10 - 5
apps/app/src/server/middlewares/invited-form-validator.ts

@@ -21,23 +21,28 @@ export const invitedRules = (): ValidationChain[] => {
       .matches(/^[\x20-\x7F]*$/)
       .withMessage('message.Password has invalid character')
       .isLength({ min: MININUM_PASSWORD_LENGTH })
-      .withMessage(`message.Password minimum character should be more than ${MININUM_PASSWORD_LENGTH} characters`)
+      .withMessage(
+        `message.Password minimum character should be more than ${MININUM_PASSWORD_LENGTH} characters`,
+      )
       .not()
       .isEmpty()
       .withMessage('message.Password field is required'),
   ];
 };
 
-export const invitedValidation = (req: Request, _res: Response, next: () => NextFunction): any => {
+export const invitedValidation = (
+  req: Request,
+  _res: Response,
+  next: () => NextFunction,
+): any => {
   const form = req.body;
   const errors = validationResult(req);
   const extractedErrors: string[] = [];
 
   if (errors.isEmpty()) {
     Object.assign(form, { isValid: true });
-  }
-  else {
-    errors.array().map(err => extractedErrors.push(err.msg));
+  } else {
+    errors.array().map((err) => extractedErrors.push(err.msg));
     Object.assign(form, { isValid: false, errors: extractedErrors });
   }
 

+ 6 - 3
apps/app/src/server/middlewares/login-form-validator.ts

@@ -1,7 +1,10 @@
-import { body, validationResult, type ValidationChain } from 'express-validator';
+import {
+  body,
+  type ValidationChain,
+  validationResult,
+} from 'express-validator';
 // form rules
 export const loginRules = (): ValidationChain[] => {
-
   return [
     body('loginForm.username')
       .matches(/^[\da-zA-Z\-_.+@]+$/)
@@ -30,7 +33,7 @@ export const loginValidation = (req, res, next): ValidationChain[] => {
   }
 
   const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
+  errors.array().map((err) => extractedErrors.push(err.msg));
 
   Object.assign(form, {
     isValid: false,

+ 4 - 6
apps/app/src/server/middlewares/login-required.js

@@ -10,19 +10,18 @@ const logger = loggerFactory('growi:middleware:login-required');
  * @param {function} fallback fallback function which will be triggered when the check cannot be passed
  */
 module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
-
-  return function(req, res, next) {
-
+  return (req, res, next) => {
     const User = crowi.model('User');
 
     // check the user logged in
-    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
+    if (req.user != null && req.user instanceof Object && '_id' in req.user) {
       if (req.user.status === User.STATUS_ACTIVE) {
         // Active の人だけ先に進める
         return next();
       }
 
-      const redirectTo = createRedirectToForUnauthenticated(req.user.status) ?? '/login';
+      const redirectTo =
+        createRedirectToForUnauthenticated(req.user.status) ?? '/login';
       return res.redirect(redirectTo);
     }
 
@@ -59,5 +58,4 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
     req.session.redirectTo = req.originalUrl;
     return res.redirect('/login');
   };
-
 };

+ 18 - 4
apps/app/src/server/middlewares/register-form-validator.ts

@@ -1,5 +1,9 @@
 import { ErrorV3 } from '@growi/core/dist/models';
-import { body, validationResult, type ValidationChain } from 'express-validator';
+import {
+  body,
+  type ValidationChain,
+  validationResult,
+} from 'express-validator';
 
 // form rules
 export const registerRules = (minPasswordLength: number): ValidationChain[] => {
@@ -10,7 +14,10 @@ export const registerRules = (minPasswordLength: number): ValidationChain[] => {
       .not()
       .isEmpty()
       .withMessage('message.Username field is required'),
-    body('registerForm.name').not().isEmpty().withMessage('message.Name field is required'),
+    body('registerForm.name')
+      .not()
+      .isEmpty()
+      .withMessage('message.Name field is required'),
     body('registerForm.email')
       .isEmail()
       .withMessage('message.Email format is invalid')
@@ -20,7 +27,14 @@ export const registerRules = (minPasswordLength: number): ValidationChain[] => {
       .matches(/^[\x20-\x7F]*$/)
       .withMessage('message.Password has invalid character')
       .isLength({ min: minPasswordLength })
-      .withMessage(new ErrorV3('message.Password minimum character should be more than n characters', undefined, undefined, { number: minPasswordLength }))
+      .withMessage(
+        new ErrorV3(
+          'message.Password minimum character should be more than n characters',
+          undefined,
+          undefined,
+          { number: minPasswordLength },
+        ),
+      )
       .not()
       .isEmpty()
       .withMessage('message.Password field is required'),
@@ -40,7 +54,7 @@ export const registerValidation = (req, res, next): ValidationChain[] => {
   }
 
   const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
+  errors.array().map((err) => extractedErrors.push(err.msg));
 
   Object.assign(form, {
     isValid: false,

+ 18 - 13
apps/app/src/server/middlewares/safe-redirect.spec.ts

@@ -1,12 +1,11 @@
 import type { Request } from 'express';
 
-import registerSafeRedirectFactory, { type ResWithSafeRedirect } from './safe-redirect';
+import registerSafeRedirectFactory, {
+  type ResWithSafeRedirect,
+} from './safe-redirect';
 
 describe('safeRedirect', () => {
-  const whitelistOfHosts = [
-    'white1.example.com:8080',
-    'white2.example.com',
-  ];
+  const whitelistOfHosts = ['white1.example.com:8080', 'white2.example.com'];
   const registerSafeRedirect = registerSafeRedirectFactory(whitelistOfHosts);
 
   describe('res.safeRedirect', () => {
@@ -24,7 +23,7 @@ describe('safeRedirect', () => {
     } as any as ResWithSafeRedirect;
     const next = vi.fn();
 
-    test('redirects to \'/\' because specified url causes open redirect vulnerability', () => {
+    test("redirects to '/' because specified url causes open redirect vulnerability", () => {
       registerSafeRedirect(req, res, next);
 
       res.safeRedirect('//evil.example.com');
@@ -35,7 +34,7 @@ describe('safeRedirect', () => {
       expect(res.redirect).toHaveBeenCalledWith('/');
     });
 
-    test('redirects to \'/\' because specified host without port is not in whitelist', () => {
+    test("redirects to '/' because specified host without port is not in whitelist", () => {
       registerSafeRedirect(req, res, next);
 
       res.safeRedirect('http://white1.example.com/path/to/page');
@@ -54,7 +53,9 @@ describe('safeRedirect', () => {
       expect(next).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
-      expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
+      expect(res.redirect).toHaveBeenCalledWith(
+        'http://example.com/path/to/page',
+      );
     });
 
     test('redirects to the specified local url (fqdn)', () => {
@@ -65,7 +66,9 @@ describe('safeRedirect', () => {
       expect(next).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
-      expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
+      expect(res.redirect).toHaveBeenCalledWith(
+        'http://example.com/path/to/page',
+      );
     });
 
     test('redirects to the specified whitelisted url (white1.example.com:8080)', () => {
@@ -76,7 +79,9 @@ describe('safeRedirect', () => {
       expect(next).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
-      expect(res.redirect).toHaveBeenCalledWith('http://white1.example.com:8080/path/to/page');
+      expect(res.redirect).toHaveBeenCalledWith(
+        'http://white1.example.com:8080/path/to/page',
+      );
     });
 
     test('redirects to the specified whitelisted url (white2.example.com:8080)', () => {
@@ -87,9 +92,9 @@ describe('safeRedirect', () => {
       expect(next).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
-      expect(res.redirect).toHaveBeenCalledWith('http://white2.example.com:8080/path/to/page');
+      expect(res.redirect).toHaveBeenCalledWith(
+        'http://white2.example.com:8080/path/to/page',
+      );
     });
-
   });
-
 });

+ 29 - 21
apps/app/src/server/middlewares/safe-redirect.ts

@@ -4,9 +4,7 @@
  * Usage: app.use(require('middlewares/safe-redirect')(['example.com', 'some.example.com:8080']))
  */
 
-import type {
-  Request, Response, NextFunction,
-} from 'express';
+import type { NextFunction, Request, Response } from 'express';
 
 import loggerFactory from '~/utils/logger';
 
@@ -15,39 +13,44 @@ const logger = loggerFactory('growi:middleware:safe-redirect');
 /**
  * Check whether the redirect url host is in specified whitelist
  */
-function isInWhitelist(whitelistOfHosts: string[], redirectToFqdn: string): boolean {
+function isInWhitelist(
+  whitelistOfHosts: string[],
+  redirectToFqdn: string,
+): boolean {
   if (whitelistOfHosts == null || whitelistOfHosts.length === 0) {
     return false;
   }
 
   try {
     const redirectUrl = new URL(redirectToFqdn);
-    return whitelistOfHosts.includes(redirectUrl.hostname) || whitelistOfHosts.includes(redirectUrl.host);
-  }
-  catch (err) {
+    return (
+      whitelistOfHosts.includes(redirectUrl.hostname) ||
+      whitelistOfHosts.includes(redirectUrl.host)
+    );
+  } catch (err) {
     logger.warn(err);
     return false;
   }
 }
 
-
 export type ResWithSafeRedirect = Response & {
-  safeRedirect: (redirectTo?: string) => void,
-}
+  safeRedirect: (redirectTo?: string) => void;
+};
 
 const factory = (whitelistOfHosts: string[]) => {
-
   return (req: Request, res: ResWithSafeRedirect, next: NextFunction): void => {
-
     // extend res object
-    res.safeRedirect = function(redirectTo?: string) {
+    res.safeRedirect = (redirectTo?: string) => {
       if (redirectTo == null) {
         return res.redirect('/');
       }
 
       try {
         // check inner redirect
-        const redirectUrl = new URL(redirectTo, `${req.protocol}://${req.get('host')}`);
+        const redirectUrl = new URL(
+          redirectTo,
+          `${req.protocol}://${req.get('host')}`,
+        );
         if (redirectUrl.hostname === req.hostname) {
           logger.debug(`Requested redirect URL (${redirectTo}) is local.`);
           return res.redirect(redirectUrl.href);
@@ -57,23 +60,28 @@ const factory = (whitelistOfHosts: string[]) => {
         // check whitelisted redirect
         const isWhitelisted = isInWhitelist(whitelistOfHosts, redirectTo);
         if (isWhitelisted) {
-          logger.debug(`Requested redirect URL (${redirectTo}) is in whitelist.`, `whitelist=${whitelistOfHosts}`);
+          logger.debug(
+            `Requested redirect URL (${redirectTo}) is in whitelist.`,
+            `whitelist=${whitelistOfHosts}`,
+          );
           return res.redirect(redirectTo);
         }
-        logger.debug(`Requested redirect URL (${redirectTo}) is NOT in whitelist.`, `whitelist=${whitelistOfHosts}`);
-      }
-      catch (err) {
+        logger.debug(
+          `Requested redirect URL (${redirectTo}) is NOT in whitelist.`,
+          `whitelist=${whitelistOfHosts}`,
+        );
+      } catch (err) {
         logger.warn(`Requested redirect URL (${redirectTo}) is invalid.`, err);
       }
 
-      logger.warn(`Requested redirect URL (${redirectTo}) is UNSAFE, redirecting to root page.`);
+      logger.warn(
+        `Requested redirect URL (${redirectTo}) is UNSAFE, redirecting to root page.`,
+      );
       return res.redirect('/');
     };
 
     next();
-
   };
-
 };
 
 export default factory;

+ 18 - 8
apps/app/src/server/middlewares/unavailable-when-maintenance-mode.ts

@@ -4,16 +4,24 @@ import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
 
-const logger = loggerFactory('growi:middlewares:unavailable-when-maintenance-mode');
+const logger = loggerFactory(
+  'growi:middlewares:unavailable-when-maintenance-mode',
+);
 
 type CrowiReq = Request & {
-  crowi: Crowi,
-}
+  crowi: Crowi;
+};
 
-type IMiddleware = (req: CrowiReq, res: Response, next: NextFunction) => Promise<void>;
+type IMiddleware = (
+  req: CrowiReq,
+  res: Response,
+  next: NextFunction,
+) => Promise<void>;
 
-export const generateUnavailableWhenMaintenanceModeMiddleware = (crowi: Crowi): IMiddleware => {
-  return async(req, res, next) => {
+export const generateUnavailableWhenMaintenanceModeMiddleware = (
+  crowi: Crowi,
+): IMiddleware => {
+  return async (req, res, next) => {
     const isMaintenanceMode = crowi.appService.isMaintenanceMode();
 
     if (!isMaintenanceMode) {
@@ -27,8 +35,10 @@ export const generateUnavailableWhenMaintenanceModeMiddleware = (crowi: Crowi):
   };
 };
 
-export const generateUnavailableWhenMaintenanceModeMiddlewareForApi = (crowi: Crowi): IMiddleware => {
-  return async(req, res, next) => {
+export const generateUnavailableWhenMaintenanceModeMiddlewareForApi = (
+  crowi: Crowi,
+): IMiddleware => {
+  return async (req, res, next) => {
     const isMaintenanceMode = crowi.appService.isMaintenanceMode();
 
     if (!isMaintenanceMode) {

+ 75 - 44
apps/app/src/server/routes/admin.js

@@ -8,7 +8,7 @@ const logger = loggerFactory('growi:routes:admin');
 
 /* eslint-disable no-use-before-define */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
-module.exports = function(crowi, app) {
+module.exports = (crowi, app) => {
   const ApiResponse = require('../util/apiResponse');
   const importer = require('../util/importer')(crowi);
 
@@ -20,40 +20,52 @@ module.exports = function(crowi, app) {
 
   const api = {};
 
-
   // Importer management
   actions.importer = {};
   actions.importer.api = api;
   api.validators = {};
   api.validators.importer = {};
 
-  api.validators.importer.esa = function() {
+  api.validators.importer.esa = () => {
     const validator = [
-      check('importer:esa:team_name').not().isEmpty().withMessage('Error. Empty esa:team_name'),
-      check('importer:esa:access_token').not().isEmpty().withMessage('Error. Empty esa:access_token'),
+      check('importer:esa:team_name')
+        .not()
+        .isEmpty()
+        .withMessage('Error. Empty esa:team_name'),
+      check('importer:esa:access_token')
+        .not()
+        .isEmpty()
+        .withMessage('Error. Empty esa:access_token'),
     ];
     return validator;
   };
 
-  api.validators.importer.qiita = function() {
+  api.validators.importer.qiita = () => {
     const validator = [
-      check('importer:qiita:team_name').not().isEmpty().withMessage('Error. Empty qiita:team_name'),
-      check('importer:qiita:access_token').not().isEmpty().withMessage('Error. Empty qiita:access_token'),
+      check('importer:qiita:team_name')
+        .not()
+        .isEmpty()
+        .withMessage('Error. Empty qiita:team_name'),
+      check('importer:qiita:access_token')
+        .not()
+        .isEmpty()
+        .withMessage('Error. Empty qiita:access_token'),
     ];
     return validator;
   };
 
-
   // Export management
   actions.export = {};
   actions.export.api = api;
   api.validators.export = {};
 
-  api.validators.export.download = function() {
+  api.validators.export.download = () => {
     const validator = [
       // https://regex101.com/r/mD4eZs/6
       // prevent from pass traversal attack
-      param('fileName').not().matches(/(\.\.\/|\.\.\\)/),
+      param('fileName')
+        .not()
+        .matches(/(\.\.\/|\.\.\\)/),
     ];
     return validator;
   };
@@ -63,13 +75,15 @@ module.exports = function(crowi, app) {
     const { validationResult } = require('express-validator');
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
-      return res.status(422).json({ errors: `${fileName} is invalid. Do not use path like '../'.` });
+      return res.status(422).json({
+        errors: `${fileName} is invalid. Do not use path like '../'.`,
+      });
     }
 
     try {
       const zipFile = exportService.getFile(fileName);
       const parameters = {
-        ip:  req.ip,
+        ip: req.ip,
         endpoint: req.originalUrl,
         action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_DOWNLOAD,
         user: req.user?._id,
@@ -79,8 +93,7 @@ module.exports = function(crowi, app) {
       };
       crowi.activityService.createActivity(parameters);
       return res.download(zipFile);
-    }
-    catch (err) {
+    } catch (err) {
       // TODO: use ApiV3Error
       logger.error(err);
       return res.json(ApiResponse.error());
@@ -100,10 +113,14 @@ module.exports = function(crowi, app) {
    */
   function isValidFormKeys(form, allowedKeys, res) {
     const receivedKeys = Object.keys(form);
-    const unexpectedKeys = receivedKeys.filter(key => !allowedKeys.includes(key));
+    const unexpectedKeys = receivedKeys.filter(
+      (key) => !allowedKeys.includes(key),
+    );
 
     if (unexpectedKeys.length > 0) {
-      logger.warn('Unexpected keys were found in request body.', { unexpectedKeys });
+      logger.warn('Unexpected keys were found in request body.', {
+        unexpectedKeys,
+      });
       res.json(ApiResponse.error('Invalid config keys provided.'));
       return false;
     }
@@ -117,7 +134,7 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} res
    */
-  actions.api.importerSettingEsa = async(req, res) => {
+  actions.api.importerSettingEsa = async (req, res) => {
     const form = req.body;
 
     const { validationResult } = require('express-validator');
@@ -126,12 +143,17 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('esa.io form is blank'));
     }
 
-    const ALLOWED_KEYS = ['importer:esa:team_name', 'importer:esa:access_token'];
+    const ALLOWED_KEYS = [
+      'importer:esa:team_name',
+      'importer:esa:access_token',
+    ];
     if (!isValidFormKeys(form, ALLOWED_KEYS, res)) return;
 
     await configManager.updateConfigs(form);
     importer.initializeEsaClient(); // let it run in the back aftert res
-    const parameters = { action: SupportedAction.ACTION_ADMIN_ESA_DATA_UPDATED };
+    const parameters = {
+      action: SupportedAction.ACTION_ADMIN_ESA_DATA_UPDATED,
+    };
     activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.json(ApiResponse.success());
   };
@@ -142,7 +164,7 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} res
    */
-  actions.api.importerSettingQiita = async(req, res) => {
+  actions.api.importerSettingQiita = async (req, res) => {
     const form = req.body;
 
     const { validationResult } = require('express-validator');
@@ -151,12 +173,17 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Qiita form is blank'));
     }
 
-    const ALLOWED_KEYS = ['importer:qiita:team_name', 'importer:qiita:access_token'];
+    const ALLOWED_KEYS = [
+      'importer:qiita:team_name',
+      'importer:qiita:access_token',
+    ];
     if (!isValidFormKeys(form, ALLOWED_KEYS, res)) return;
 
     await configManager.updateConfigs(form);
     importer.initializeQiitaClient(); // let it run in the back aftert res
-    const parameters = { action: SupportedAction.ACTION_ADMIN_QIITA_DATA_UPDATED };
+    const parameters = {
+      action: SupportedAction.ACTION_ADMIN_QIITA_DATA_UPDATED,
+    };
     activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.json(ApiResponse.success());
   };
@@ -167,16 +194,17 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} res
    */
-  actions.api.importDataFromEsa = async(req, res) => {
+  actions.api.importDataFromEsa = async (req, res) => {
     const user = req.user;
     let errors;
 
     try {
       errors = await importer.importDataFromEsa(user);
-      const parameters = { action: SupportedAction.ACTION_ADMIN_ESA_DATA_IMPORTED };
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_ESA_DATA_IMPORTED,
+      };
       activityEvent.emit('update', res.locals.activity._id, parameters);
-    }
-    catch (err) {
+    } catch (err) {
       errors = [err];
     }
 
@@ -192,16 +220,17 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} res
    */
-  actions.api.importDataFromQiita = async(req, res) => {
+  actions.api.importDataFromQiita = async (req, res) => {
     const user = req.user;
     let errors;
 
     try {
       errors = await importer.importDataFromQiita(user);
-      const parameters = { action: SupportedAction.ACTION_ADMIN_QIITA_DATA_IMPORTED };
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_QIITA_DATA_IMPORTED,
+      };
       activityEvent.emit('update', res.locals.activity._id, parameters);
-    }
-    catch (err) {
+    } catch (err) {
       errors = [err];
     }
 
@@ -217,14 +246,15 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} res
    */
-  actions.api.testEsaAPI = async(req, res) => {
+  actions.api.testEsaAPI = async (req, res) => {
     try {
       await importer.testConnectionToEsa();
-      const parameters = { action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA };
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA,
+      };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.json(ApiResponse.success());
-    }
-    catch (err) {
+    } catch (err) {
       return res.json(ApiResponse.error(err));
     }
   };
@@ -235,29 +265,30 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} res
    */
-  actions.api.testQiitaAPI = async(req, res) => {
+  actions.api.testQiitaAPI = async (req, res) => {
     try {
       await importer.testConnectionToQiita();
-      const parameters = { action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA };
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA,
+      };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.json(ApiResponse.success());
-    }
-    catch (err) {
+    } catch (err) {
       return res.json(ApiResponse.error(err));
     }
   };
 
-
-  actions.api.searchBuildIndex = async function(req, res) {
+  actions.api.searchBuildIndex = async (req, res) => {
     const search = crowi.getSearcher();
     if (!search) {
-      return res.json(ApiResponse.error('ElasticSearch Integration is not set up.'));
+      return res.json(
+        ApiResponse.error('ElasticSearch Integration is not set up.'),
+      );
     }
 
     try {
       search.buildIndex();
-    }
-    catch (err) {
+    } catch (err) {
       return res.json(ApiResponse.error(err));
     }
 

+ 50 - 37
apps/app/src/server/routes/apiv3/activity.ts

@@ -1,11 +1,11 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
-import { parseISO, addMinutes, isValid } from 'date-fns';
+import { addMinutes, isValid, parseISO } from 'date-fns';
 import type { Request, Router } from 'express';
 import express from 'express';
 import { query } from 'express-validator';
 
 import type { IActivity, ISearchFilter } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import Activity from '~/server/models/activity';
 import { configManager } from '~/server/service/config-manager';
@@ -13,18 +13,21 @@ import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:activity');
 
-
 const validator = {
   list: [
-    query('limit').optional().isInt({ max: 100 }).withMessage('limit must be a number less than or equal to 100'),
+    query('limit')
+      .optional()
+      .isInt({ max: 100 })
+      .withMessage('limit must be a number less than or equal to 100'),
     query('offset').optional().isInt().withMessage('page must be a number'),
-    query('searchFilter').optional().isString().withMessage('query must be a string'),
+    query('searchFilter')
+      .optional()
+      .isString()
+      .withMessage('query must be a string'),
   ],
 };
 
@@ -171,7 +174,9 @@ const validator = {
 
 module.exports = (crowi: Crowi): Router => {
   const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
 
   const router = express.Router();
 
@@ -209,9 +214,14 @@ module.exports = (crowi: Crowi): Router => {
    *             schema:
    *               $ref: '#/components/schemas/ActivityResponse'
    */
-  router.get('/',
+  router.get(
+    '/',
     accessTokenParser([SCOPE.READ.ADMIN.AUDIT_LOG], { acceptLegacy: true }),
-    loginRequiredStrictly, adminRequired, validator.list, apiV3FormValidator, async(req: Request, res: ApiV3Response) => {
+    loginRequiredStrictly,
+    adminRequired,
+    validator.list,
+    apiV3FormValidator,
+    async (req: Request, res: ApiV3Response) => {
       const auditLogEnabled = configManager.getConfig('app:auditLogEnabled');
       if (!auditLogEnabled) {
         const msg = 'AuditLog is not enabled';
@@ -219,28 +229,36 @@ module.exports = (crowi: Crowi): Router => {
         return res.apiv3Err(msg, 405);
       }
 
-      const limit = req.query.limit || configManager.getConfig('customize:showPageLimitationS');
+      const limit =
+        req.query.limit ||
+        configManager.getConfig('customize:showPageLimitationS');
       const offset = req.query.offset || 1;
 
       const query = {};
 
       try {
-        const parsedSearchFilter = JSON.parse(req.query.searchFilter as string) as ISearchFilter;
+        const parsedSearchFilter = JSON.parse(
+          req.query.searchFilter as string,
+        ) as ISearchFilter;
 
         // add username to query
-        const canContainUsernameFilterToQuery = (
-          parsedSearchFilter.usernames != null
-        && parsedSearchFilter.usernames.length > 0
-        && parsedSearchFilter.usernames.every(u => typeof u === 'string')
-        );
+        const canContainUsernameFilterToQuery =
+          parsedSearchFilter.usernames != null &&
+          parsedSearchFilter.usernames.length > 0 &&
+          parsedSearchFilter.usernames.every((u) => typeof u === 'string');
         if (canContainUsernameFilterToQuery) {
-          Object.assign(query, { 'snapshot.username': parsedSearchFilter.usernames });
+          Object.assign(query, {
+            'snapshot.username': parsedSearchFilter.usernames,
+          });
         }
 
         // add action to query
         if (parsedSearchFilter.actions != null) {
-          const availableActions = crowi.activityService.getAvailableActions(false);
-          const searchableActions = parsedSearchFilter.actions.filter(action => availableActions.includes(action));
+          const availableActions =
+            crowi.activityService.getAvailableActions(false);
+          const searchableActions = parsedSearchFilter.actions.filter(
+            (action) => availableActions.includes(action),
+          );
           Object.assign(query, { action: searchableActions });
         }
 
@@ -255,8 +273,7 @@ module.exports = (crowi: Crowi): Router => {
               $lt: addMinutes(endDate, 1439),
             },
           });
-        }
-        else if (isValid(startDate) && !isValid(endDate)) {
+        } else if (isValid(startDate) && !isValid(endDate)) {
           Object.assign(query, {
             createdAt: {
               $gte: startDate,
@@ -265,23 +282,19 @@ module.exports = (crowi: Crowi): Router => {
             },
           });
         }
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Invalid value', err);
         return res.apiv3Err(err, 400);
       }
 
       try {
-        const paginateResult = await Activity.paginate(
-          query,
-          {
-            lean: true,
-            limit,
-            offset,
-            sort: { createdAt: -1 },
-            populate: 'user',
-          },
-        );
+        const paginateResult = await Activity.paginate(query, {
+          lean: true,
+          limit,
+          offset,
+          sort: { createdAt: -1 },
+          populate: 'user',
+        });
 
         const serializedDocs = paginateResult.docs.map((doc: IActivity) => {
           const { user, ...rest } = doc;
@@ -297,12 +310,12 @@ module.exports = (crowi: Crowi): Router => {
         };
 
         return res.apiv3({ serializedPaginationResult });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Failed to get paginated activity', err);
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
 
   return router;
 };

+ 26 - 15
apps/app/src/server/routes/apiv3/admin-home.ts

@@ -1,4 +1,5 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
+
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
@@ -60,7 +61,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);
 
   /**
@@ -83,22 +86,30 @@ module.exports = (crowi) => {
    *                    adminHomeParams:
    *                      $ref: "#/components/schemas/SystemInformationParams"
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.TOP]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const { getRuntimeVersions } = await import('~/server/util/runtime-versions');
-    const runtimeVersions = await getRuntimeVersions();
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.TOP]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const { getRuntimeVersions } = await import(
+        '~/server/util/runtime-versions'
+      );
+      const runtimeVersions = await getRuntimeVersions();
 
-    const adminHomeParams = {
-      growiVersion: getGrowiVersion(),
-      nodeVersion: runtimeVersions.node ?? '-',
-      npmVersion: runtimeVersions.npm ?? '-',
-      pnpmVersion: runtimeVersions.pnpm ?? '-',
-      envVars: configManager.getManagedEnvVars(),
-      isV5Compatible: configManager.getConfig('app:isV5Compatible'),
-      isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
-    };
+      const adminHomeParams = {
+        growiVersion: getGrowiVersion(),
+        nodeVersion: runtimeVersions.node ?? '-',
+        npmVersion: runtimeVersions.npm ?? '-',
+        pnpmVersion: runtimeVersions.pnpm ?? '-',
+        envVars: configManager.getManagedEnvVars(),
+        isV5Compatible: configManager.getConfig('app:isV5Compatible'),
+        isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
+      };
 
-    return res.apiv3({ adminHomeParams });
-  });
+      return res.apiv3({ adminHomeParams });
+    },
+  );
 
   return router;
 };

+ 157 - 94
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -1,9 +1,9 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 import type { Types } from 'mongoose';
 
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
@@ -99,29 +99,44 @@ const router = express.Router();
 const validator = {
   bookmarkFolder: [
     body('name').isString().withMessage('name must be a string'),
-    body('parent').isMongoId().optional({ nullable: true })
-      .custom(async(parent: string) => {
+    body('parent')
+      .isMongoId()
+      .optional({ nullable: true })
+      .custom(async (parent: string) => {
         const parentFolder = await BookmarkFolder.findById(parent);
         if (parentFolder == null || parentFolder.parent != null) {
           throw new Error('Maximum folder hierarchy of 2 levels');
         }
       }),
-    body('childFolder').optional().isArray().withMessage('Children must be an array'),
-    body('bookmarkFolderId').optional().isMongoId().withMessage('Bookark Folder ID must be a valid mongo ID'),
+    body('childFolder')
+      .optional()
+      .isArray()
+      .withMessage('Children must be an array'),
+    body('bookmarkFolderId')
+      .optional()
+      .isMongoId()
+      .withMessage('Bookark Folder ID must be a valid mongo ID'),
   ],
   bookmarkPage: [
     body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
-    body('folderId').optional({ nullable: true }).isMongoId().withMessage('Folder ID must be a valid mongo ID'),
+    body('folderId')
+      .optional({ nullable: true })
+      .isMongoId()
+      .withMessage('Folder ID must be a valid mongo ID'),
   ],
   bookmark: [
     body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
-    body('status').isBoolean().withMessage('status must be one of true or false'),
+    body('status')
+      .isBoolean()
+      .withMessage('status must be one of true or false'),
   ],
 };
 
 /** @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,
+  );
 
   /**
    * @swagger
@@ -157,28 +172,36 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.post('/',
+  router.post(
+    '/',
     accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
-    loginRequiredStrictly, validator.bookmarkFolder, apiV3FormValidator, async(req, res) => {
+    loginRequiredStrictly,
+    validator.bookmarkFolder,
+    apiV3FormValidator,
+    async (req, res) => {
       const owner = req.user?._id;
       const { name, parent } = req.body;
       const params = {
-        name, owner, parent,
+        name,
+        owner,
+        parent,
       };
 
       try {
         const bookmarkFolder = await BookmarkFolder.createByParameters(params);
         logger.debug('bookmark folder created', bookmarkFolder);
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         if (err instanceof InvalidParentBookmarkFolderError) {
-          return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_bookmark_folder'));
+          return res.apiv3Err(
+            new ErrorV3(err.message, 'failed_to_create_bookmark_folder'),
+          );
         }
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -211,63 +234,75 @@ module.exports = (crowi) => {
    *                        type: object
    *                        $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.get('/list/:userId', accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
-    const { userId } = req.params;
+  router.get(
+    '/list/:userId',
+    accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    async (req, res) => {
+      const { userId } = req.params;
 
-    const getBookmarkFolders = async(
+      const getBookmarkFolders = async (
         userId: Types.ObjectId | string,
         parentFolderId?: Types.ObjectId | string,
-    ) => {
-      const folders = await BookmarkFolder.find({ owner: userId, parent: parentFolderId })
-        .populate('childFolder')
-        .populate({
-          path: 'bookmarks',
-          model: 'Bookmark',
-          populate: {
-            path: 'page',
-            model: 'Page',
+      ) => {
+        const folders = (await BookmarkFolder.find({
+          owner: userId,
+          parent: parentFolderId,
+        })
+          .populate('childFolder')
+          .populate({
+            path: 'bookmarks',
+            model: 'Bookmark',
             populate: {
-              path: 'lastUpdateUser',
-              model: 'User',
+              path: 'page',
+              model: 'Page',
+              populate: {
+                path: 'lastUpdateUser',
+                model: 'User',
+              },
             },
-          },
-        }).exec() as never as BookmarkFolderItems[];
+          })
+          .exec()) as never as BookmarkFolderItems[];
 
-      const returnValue: BookmarkFolderItems[] = [];
+        const returnValue: BookmarkFolderItems[] = [];
 
-      const promises = folders.map(async(folder: BookmarkFolderItems) => {
-        const childFolder = await getBookmarkFolders(userId, folder._id);
+        const promises = folders.map(async (folder: BookmarkFolderItems) => {
+          const childFolder = await getBookmarkFolders(userId, folder._id);
 
-        // !! DO NOT THIS SERIALIZING OUTSIDE OF PROMISES !! -- 05.23.2023 ryoji-s
-        // Serializing outside of promises will cause not populated.
-        const bookmarks = folder.bookmarks.map(bookmark => serializeBookmarkSecurely(bookmark));
+          // !! DO NOT THIS SERIALIZING OUTSIDE OF PROMISES !! -- 05.23.2023 ryoji-s
+          // Serializing outside of promises will cause not populated.
+          const bookmarks = folder.bookmarks.map((bookmark) =>
+            serializeBookmarkSecurely(bookmark),
+          );
 
-        const res = {
-          _id: folder._id.toString(),
-          name: folder.name,
-          owner: folder.owner,
-          bookmarks,
-          childFolder,
-          parent: folder.parent,
-        };
-        return res;
-      });
+          const res = {
+            _id: folder._id.toString(),
+            name: folder.name,
+            owner: folder.owner,
+            bookmarks,
+            childFolder,
+            parent: folder.parent,
+          };
+          return res;
+        });
 
-      const results = await Promise.all(promises) as unknown as BookmarkFolderItems[];
-      returnValue.push(...results);
-      return returnValue;
-    };
+        const results = (await Promise.all(
+          promises,
+        )) as unknown as BookmarkFolderItems[];
+        returnValue.push(...results);
+        return returnValue;
+      };
 
-    try {
-      const bookmarkFolderItems = await getBookmarkFolders(userId, undefined);
+      try {
+        const bookmarkFolderItems = await getBookmarkFolders(userId, undefined);
 
-      return res.apiv3({ bookmarkFolderItems });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+        return res.apiv3({ bookmarkFolderItems });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -299,18 +334,22 @@ module.exports = (crowi) => {
    *                      description: Number of deleted folders
    *                      example: 1
    */
-  router.delete('/:id', accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
-    const { id } = req.params;
-    try {
-      const result = await BookmarkFolder.deleteFolderAndChildren(id);
-      const { deletedCount } = result;
-      return res.apiv3({ deletedCount });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.delete(
+    '/:id',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    async (req, res) => {
+      const { id } = req.params;
+      try {
+        const result = await BookmarkFolder.deleteFolderAndChildren(id);
+        const { deletedCount } = result;
+        return res.apiv3({ deletedCount });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -355,20 +394,27 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.put('/',
-    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
-      const {
-        bookmarkFolderId, name, parent, childFolder,
-      } = req.body;
+  router.put(
+    '/',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator.bookmarkFolder,
+    async (req, res) => {
+      const { bookmarkFolderId, name, parent, childFolder } = req.body;
       try {
-        const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, childFolder);
+        const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(
+          bookmarkFolderId,
+          name,
+          parent,
+          childFolder,
+        );
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -405,22 +451,31 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.post('/add-bookmark-to-folder',
-    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, validator.bookmarkPage, apiV3FormValidator,
-    async(req, res) => {
+  router.post(
+    '/add-bookmark-to-folder',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator.bookmarkPage,
+    apiV3FormValidator,
+    async (req, res) => {
       const userId = req.user?._id;
       const { pageId, folderId } = req.body;
 
       try {
-        const bookmarkFolder = await BookmarkFolder.insertOrUpdateBookmarkedPage(pageId, userId, folderId);
+        const bookmarkFolder =
+          await BookmarkFolder.insertOrUpdateBookmarkedPage(
+            pageId,
+            userId,
+            folderId,
+          );
         logger.debug('bookmark added to folder', bookmarkFolder);
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -456,18 +511,26 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.put('/update-bookmark',
-    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, validator.bookmark, async(req, res) => {
+  router.put(
+    '/update-bookmark',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator.bookmark,
+    async (req, res) => {
       const { pageId, status } = req.body;
       const userId = req.user?._id;
       try {
-        const bookmarkFolder = await BookmarkFolder.updateBookmark(pageId, status, userId);
+        const bookmarkFolder = await BookmarkFolder.updateBookmark(
+          pageId,
+          status,
+          userId,
+        );
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
   return router;
 };

+ 315 - 157
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -1,13 +1,13 @@
-import { createReadStream } from 'fs';
-import path from 'path';
-
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { NextFunction, Request, Router } from 'express';
 import express from 'express';
 import { body } from 'express-validator';
+import { createReadStream } from 'fs';
 import multer from 'multer';
+import path from 'path';
 
+import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { configManager } from '~/server/service/config-manager';
@@ -19,15 +19,13 @@ import { getImportService } from '~/server/service/import';
 import loggerFactory from '~/utils/logger';
 import { TransferKey } from '~/utils/vo/transfer-key';
 
-
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { Attachment } from '../../models/attachment';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 interface AuthorizedRequest extends Request {
-  user?: any
+  user?: any;
 }
 
 const logger = loggerFactory('growi:routes:apiv3:transfer');
@@ -76,20 +74,27 @@ const validator = {
  *                 type: string
  *               containerName:
  *                 type: string
-*/
+ */
 /*
  * Routes
  */
 module.exports = (crowi: Crowi): Router => {
   const {
-    g2gTransferPusherService, g2gTransferReceiverService,
+    g2gTransferPusherService,
+    g2gTransferReceiverService,
     growiBridgeService,
   } = crowi;
 
   const importService = getImportService();
 
-  if (g2gTransferPusherService == null || g2gTransferReceiverService == null || exportService == null || importService == null
-    || growiBridgeService == null || configManager == null) {
+  if (
+    g2gTransferPusherService == null ||
+    g2gTransferReceiverService == null ||
+    exportService == null ||
+    importService == null ||
+    growiBridgeService == null ||
+    configManager == null
+  ) {
     throw Error('GROWI is not ready for g2g transfer');
   }
 
@@ -126,10 +131,16 @@ module.exports = (crowi: Crowi): Router => {
   const isInstalled = configManager.getConfig('app:installed');
 
   const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
 
   // Middleware
-  const adminRequiredIfInstalled = (req: Request, res: ApiV3Response, next: NextFunction) => {
+  const adminRequiredIfInstalled = (
+    req: Request,
+    res: ApiV3Response,
+    next: NextFunction,
+  ) => {
     if (!isInstalled) {
       next();
       return;
@@ -139,29 +150,47 @@ module.exports = (crowi: Crowi): Router => {
   };
 
   // Middleware
-  const appSiteUrlRequiredIfNotInstalled = (req: Request, res: ApiV3Response, next: NextFunction) => {
+  const appSiteUrlRequiredIfNotInstalled = (
+    req: Request,
+    res: ApiV3Response,
+    next: NextFunction,
+  ) => {
     if (!isInstalled && req.body.appSiteUrl != null) {
       next();
       return;
     }
 
-    if (configManager.getConfig('app:siteUrl') != null || req.body.appSiteUrl != null) {
+    if (
+      configManager.getConfig('app:siteUrl') != null ||
+      req.body.appSiteUrl != null
+    ) {
       next();
       return;
     }
 
-    return res.apiv3Err(new ErrorV3('Body param "appSiteUrl" is required when GROWI is NOT installed yet'), 400);
+    return res.apiv3Err(
+      new ErrorV3(
+        'Body param "appSiteUrl" is required when GROWI is NOT installed yet',
+      ),
+      400,
+    );
   };
 
   // Local middleware to check if key is valid or not
-  const validateTransferKey = async(req: Request, res: ApiV3Response, next: NextFunction) => {
+  const validateTransferKey = async (
+    req: Request,
+    res: ApiV3Response,
+    next: NextFunction,
+  ) => {
     const transferKey = req.headers[X_GROWI_TRANSFER_KEY_HEADER_NAME] as string;
 
     try {
       await g2gTransferReceiverService.validateTransferKey(transferKey);
-    }
-    catch (err) {
-      return res.apiv3Err(new ErrorV3('Invalid transfer key', 'invalid_transfer_key'), 403);
+    } catch (err) {
+      return res.apiv3Err(
+        new ErrorV3('Invalid transfer key', 'invalid_transfer_key'),
+        403,
+      );
     }
 
     next();
@@ -200,10 +229,14 @@ module.exports = (crowi: Crowi): Router => {
    *                          type: number
    *                          description: The size of the file
    */
-  receiveRouter.get('/files', validateTransferKey, async(req: Request, res: ApiV3Response) => {
-    const files = await crowi.fileUploadService.listFiles();
-    return res.apiv3({ files });
-  });
+  receiveRouter.get(
+    '/files',
+    validateTransferKey,
+    async (req: Request, res: ApiV3Response) => {
+      const files = await crowi.fileUploadService.listFiles();
+      return res.apiv3({ files });
+    },
+  );
 
   /**
    * @swagger
@@ -251,88 +284,122 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    description: The message of the result
    */
-  receiveRouter.post('/', validateTransferKey, uploads.single('transferDataZipFile'), async(req: Request & { file: any; }, res: ApiV3Response) => {
-    const { file } = req;
-    const {
-      collections: strCollections,
-      optionsMap: strOptionsMap,
-      operatorUserId,
-      uploadConfigs: strUploadConfigs,
-    } = req.body;
-
-    /*
-     * parse multipart form data
-     */
-    let collections;
-    let optionsMap;
-    let sourceGROWIUploadConfigs;
-    try {
-      collections = JSON.parse(strCollections);
-      optionsMap = JSON.parse(strOptionsMap);
-      sourceGROWIUploadConfigs = JSON.parse(strUploadConfigs);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Failed to parse request body.', 'parse_failed'), 500);
-    }
+  receiveRouter.post(
+    '/',
+    validateTransferKey,
+    uploads.single('transferDataZipFile'),
+    async (req: Request & { file: any }, res: ApiV3Response) => {
+      const { file } = req;
+      const {
+        collections: strCollections,
+        optionsMap: strOptionsMap,
+        operatorUserId,
+        uploadConfigs: strUploadConfigs,
+      } = req.body;
+
+      /*
+       * parse multipart form data
+       */
+      let collections: string[];
+      let optionsMap: { [key: string]: GrowiArchiveImportOption };
+      let sourceGROWIUploadConfigs: any;
+      try {
+        collections = JSON.parse(strCollections);
+        optionsMap = JSON.parse(strOptionsMap);
+        sourceGROWIUploadConfigs = JSON.parse(strUploadConfigs);
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3('Failed to parse request body.', 'parse_failed'),
+          500,
+        );
+      }
 
-    /*
-     * unzip and parse
-     */
-    let meta;
-    let innerFileStats;
-    try {
-      const zipFile = importService.getFile(file.filename);
-      await importService.unzip(zipFile);
+      /*
+       * unzip and parse
+       */
+      let meta: object | undefined;
+      let innerFileStats: {
+        fileName: string;
+        collectionName: string;
+        size: number;
+      }[];
+      try {
+        const zipFile = importService.getFile(file.filename);
+        await importService.unzip(zipFile);
 
-      const zipFileStat = await growiBridgeService.parseZipFile(zipFile);
-      innerFileStats = zipFileStat?.innerFileStats;
-      meta = zipFileStat?.meta;
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Failed to validate transfer data file.', 'validation_failed'), 500);
-    }
+        const zipFileStat = await growiBridgeService.parseZipFile(zipFile);
+        innerFileStats = zipFileStat?.innerFileStats ?? [];
+        meta = zipFileStat?.meta;
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Failed to validate transfer data file.',
+            'validation_failed',
+          ),
+          500,
+        );
+      }
 
-    /*
-     * validate meta.json
-     */
-    try {
-      importService.validate(meta);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(
-        new ErrorV3(
-          'The version of this GROWI and the uploaded GROWI data are not the same',
-          'version_incompatible',
-        ),
-        500,
-      );
-    }
+      /*
+       * validate meta.json
+       */
+      try {
+        importService.validate(meta);
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3(
+            'The version of this GROWI and the uploaded GROWI data are not the same',
+            'version_incompatible',
+          ),
+          500,
+        );
+      }
 
-    /*
-     * generate maps of ImportSettings to import
-     */
-    let importSettingsMap: Map<string, ImportSettings>;
-    try {
-      importSettingsMap = g2gTransferReceiverService.getImportSettingMap(innerFileStats, optionsMap, operatorUserId);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Import settings are invalid. See GROWI docs about details.', 'import_settings_invalid'));
-    }
+      /*
+       * generate maps of ImportSettings to import
+       */
+      let importSettingsMap: Map<string, ImportSettings>;
+      try {
+        importSettingsMap = g2gTransferReceiverService.getImportSettingMap(
+          innerFileStats,
+          optionsMap,
+          operatorUserId,
+        );
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Import settings are invalid. See GROWI docs about details.',
+            'import_settings_invalid',
+          ),
+        );
+      }
 
-    try {
-      await g2gTransferReceiverService.importCollections(collections, importSettingsMap, sourceGROWIUploadConfigs);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Failed to import MongoDB collections', 'mongo_collection_import_failure'), 500);
-    }
+      try {
+        await g2gTransferReceiverService.importCollections(
+          collections,
+          importSettingsMap,
+          sourceGROWIUploadConfigs,
+        );
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Failed to import MongoDB collections',
+            'mongo_collection_import_failure',
+          ),
+          500,
+        );
+      }
 
-    return res.apiv3({ message: 'Successfully started to receive transfer data.' });
-  });
+      return res.apiv3({
+        message: 'Successfully started to receive transfer data.',
+      });
+    },
+  );
 
   /**
    * @swagger
@@ -370,54 +437,101 @@ module.exports = (crowi: Crowi): Router => {
    *                    description: The message of the result
    */
   // This endpoint uses multer's MemoryStorage since the received data should be persisted directly on attachment storage.
-  receiveRouter.post('/attachment', validateTransferKey, uploadsForAttachment.single('content'),
-    async(req: Request & { file: any; }, res: ApiV3Response) => {
+  receiveRouter.post(
+    '/attachment',
+    validateTransferKey,
+    uploadsForAttachment.single('content'),
+    async (req: Request & { file: any }, res: ApiV3Response) => {
       const { file } = req;
       const { attachmentMetadata } = req.body;
 
-      let attachmentMap;
+      let attachmentMap: { fileName: any; fileSize: any };
       try {
         attachmentMap = JSON.parse(attachmentMetadata);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Failed to parse body.', 'parse_failed'), 500);
+        return res.apiv3Err(
+          new ErrorV3('Failed to parse body.', 'parse_failed'),
+          500,
+        );
       }
 
       try {
         const { fileName, fileSize } = attachmentMap;
-        if (typeof fileName !== 'string' || fileName.length === 0 || fileName.length > 256) {
+        if (
+          typeof fileName !== 'string' ||
+          fileName.length === 0 ||
+          fileName.length > 256
+        ) {
           logger.warn('Invalid fileName in attachment metadata.', { fileName });
-          return res.apiv3Err(new ErrorV3('Invalid fileName in attachment metadata.', 'invalid_metadata'), 400);
+          return res.apiv3Err(
+            new ErrorV3(
+              'Invalid fileName in attachment metadata.',
+              'invalid_metadata',
+            ),
+            400,
+          );
         }
-        if (typeof fileSize !== 'number' || !Number.isInteger(fileSize) || fileSize < 0) {
+        if (
+          typeof fileSize !== 'number' ||
+          !Number.isInteger(fileSize) ||
+          fileSize < 0
+        ) {
           logger.warn('Invalid fileSize in attachment metadata.', { fileSize });
-          return res.apiv3Err(new ErrorV3('Invalid fileSize in attachment metadata.', 'invalid_metadata'), 400);
+          return res.apiv3Err(
+            new ErrorV3(
+              'Invalid fileSize in attachment metadata.',
+              'invalid_metadata',
+            ),
+            400,
+          );
         }
         const count = await Attachment.countDocuments({ fileName, fileSize });
         if (count === 0) {
-          logger.warn('Attachment not found in collection.', { fileName, fileSize });
-          return res.apiv3Err(new ErrorV3('Attachment not found in collection.', 'attachment_not_found'), 404);
+          logger.warn('Attachment not found in collection.', {
+            fileName,
+            fileSize,
+          });
+          return res.apiv3Err(
+            new ErrorV3(
+              'Attachment not found in collection.',
+              'attachment_not_found',
+            ),
+            404,
+          );
         }
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Failed to check attachment existence.', 'attachment_check_failed'), 500);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Failed to check attachment existence.',
+            'attachment_check_failed',
+          ),
+          500,
+        );
       }
 
       const fileStream = createReadStream(file.path, {
-        flags: 'r', mode: 0o666, autoClose: true,
+        flags: 'r',
+        mode: 0o666,
+        autoClose: true,
       });
       try {
-        await g2gTransferReceiverService.receiveAttachment(fileStream, attachmentMap);
-      }
-      catch (err) {
+        await g2gTransferReceiverService.receiveAttachment(
+          fileStream,
+          attachmentMap,
+        );
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Failed to upload.', 'upload_failed'), 500);
+        return res.apiv3Err(
+          new ErrorV3('Failed to upload.', 'upload_failed'),
+          500,
+        );
       }
 
       return res.apiv3({ message: 'Successfully imported attached file.' });
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -439,23 +553,32 @@ module.exports = (crowi: Crowi): Router => {
    *                  growiInfo:
    *                    $ref: '#/components/schemas/GrowiInfo'
    */
-  receiveRouter.get('/growi-info', validateTransferKey, async(req: Request, res: ApiV3Response) => {
-    let growiInfo: IDataGROWIInfo;
-    try {
-      growiInfo = await g2gTransferReceiverService.answerGROWIInfo();
-    }
-    catch (err) {
-      logger.error(err);
+  receiveRouter.get(
+    '/growi-info',
+    validateTransferKey,
+    async (req: Request, res: ApiV3Response) => {
+      let growiInfo: IDataGROWIInfo;
+      try {
+        growiInfo = await g2gTransferReceiverService.answerGROWIInfo();
+      } catch (err) {
+        logger.error(err);
 
-      if (!isG2GTransferError(err)) {
-        return res.apiv3Err(new ErrorV3('Failed to prepare GROWI info', 'failed_to_prepare_growi_info'), 500);
-      }
+        if (!isG2GTransferError(err)) {
+          return res.apiv3Err(
+            new ErrorV3(
+              'Failed to prepare GROWI info',
+              'failed_to_prepare_growi_info',
+            ),
+            500,
+          );
+        }
 
-      return res.apiv3Err(new ErrorV3(err.message, err.code), 500);
-    }
+        return res.apiv3Err(new ErrorV3(err.message, err.code), 500);
+      }
 
-    return res.apiv3({ growiInfo });
-  });
+      return res.apiv3({ growiInfo });
+    },
+  );
 
   /**
    * @swagger
@@ -489,32 +612,46 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    description: The transfer key
    */
-  receiveRouter.post('/generate-key',
+  receiveRouter.post(
+    '/generate-key',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
-    adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
-      const appSiteUrl = req.body.appSiteUrl ?? configManager.getConfig('app:siteUrl');
+    adminRequiredIfInstalled,
+    appSiteUrlRequiredIfNotInstalled,
+    async (req: Request, res: ApiV3Response) => {
+      const appSiteUrl =
+        req.body.appSiteUrl ?? configManager.getConfig('app:siteUrl');
 
       let appSiteUrlOrigin: string;
       try {
         appSiteUrlOrigin = new URL(appSiteUrl).origin;
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('appSiteUrl may be wrong', 'failed_to_generate_key_string'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'appSiteUrl may be wrong',
+            'failed_to_generate_key_string',
+          ),
+        );
       }
 
       // Save TransferKey document
       let transferKeyString: string;
       try {
-        transferKeyString = await g2gTransferReceiverService.createTransferKey(appSiteUrlOrigin);
-      }
-      catch (err) {
+        transferKeyString =
+          await g2gTransferReceiverService.createTransferKey(appSiteUrlOrigin);
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Error occurred while generating transfer key.', 'failed_to_generate_key'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'Error occurred while generating transfer key.',
+            'failed_to_generate_key',
+          ),
+        );
       }
 
       return res.apiv3({ transferKey: transferKeyString });
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -556,44 +693,65 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    description: The message of the result
    */
-  pushRouter.post('/transfer',
+  pushRouter.post(
+    '/transfer',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
-    loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    loginRequiredStrictly,
+    adminRequired,
+    validator.transfer,
+    apiV3FormValidator,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const { transferKey, collections, optionsMap } = req.body;
 
       // Parse transfer key
       let tk: TransferKey;
       try {
         tk = TransferKey.parse(transferKey);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Transfer key is invalid', 'transfer_key_invalid'), 400);
+        return res.apiv3Err(
+          new ErrorV3('Transfer key is invalid', 'transfer_key_invalid'),
+          400,
+        );
       }
 
       // get growi info
       let destGROWIInfo: IDataGROWIInfo;
       try {
         destGROWIInfo = await g2gTransferPusherService.askGROWIInfo(tk);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Error occurred while asking GROWI info.', 'failed_to_ask_growi_info'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'Error occurred while asking GROWI info.',
+            'failed_to_ask_growi_info',
+          ),
+        );
       }
 
       // Check if can transfer
-      const transferability = await g2gTransferPusherService.getTransferability(destGROWIInfo);
+      const transferability =
+        await g2gTransferPusherService.getTransferability(destGROWIInfo);
       if (!transferability.canTransfer) {
-        return res.apiv3Err(new ErrorV3(transferability.reason, 'growi_incompatible_to_transfer'));
+        return res.apiv3Err(
+          new ErrorV3(transferability.reason, 'growi_incompatible_to_transfer'),
+        );
       }
 
       // Start transfer
       // DO NOT "await". Let it run in the background.
       // Errors should be emitted through websocket.
-      g2gTransferPusherService.startTransfer(tk, req.user, collections, optionsMap, destGROWIInfo);
+      g2gTransferPusherService.startTransfer(
+        tk,
+        req.user,
+        collections,
+        optionsMap,
+        destGROWIInfo,
+      );
 
       return res.apiv3({ message: 'Successfully requested auto transfer.' });
-    });
+    },
+  );
 
   // Merge receiveRouter and pushRouter
   router.use(receiveRouter, pushRouter);

+ 26 - 13
apps/app/src/server/routes/apiv3/healthcheck.ts

@@ -5,10 +5,8 @@ import nocache from 'nocache';
 import loggerFactory from '~/utils/logger';
 
 import { Config } from '../../models/config';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:healthcheck');
 
 const router = express.Router();
@@ -79,15 +77,19 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-
   async function checkMongo(errors, info) {
     try {
       await Config.findOne({});
 
       info.mongo = 'OK';
-    }
-    catch (err) {
-      errors.push(new ErrorV3(`MongoDB is not connectable - ${err.message}`, 'healthcheck-mongodb-unhealthy', err.stack));
+    } catch (err) {
+      errors.push(
+        new ErrorV3(
+          `MongoDB is not connectable - ${err.message}`,
+          'healthcheck-mongodb-unhealthy',
+          err.stack,
+        ),
+      );
     }
   }
 
@@ -97,9 +99,14 @@ module.exports = (crowi) => {
       try {
         info.searchInfo = await searchService.getInfoForHealth();
         searchService.resetErrorStatus();
-      }
-      catch (err) {
-        errors.push(new ErrorV3(`The Search Service is not connectable - ${err.message}`, 'healthcheck-search-unhealthy', err.stack));
+      } catch (err) {
+        errors.push(
+          new ErrorV3(
+            `The Search Service is not connectable - ${err.message}`,
+            'healthcheck-search-unhealthy',
+            err.stack,
+          ),
+        );
       }
     }
   }
@@ -165,20 +172,26 @@ module.exports = (crowi) => {
    *                  info:
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    */
-  router.get('/', nocache(), async(req, res: ApiV3Response) => {
+  router.get('/', nocache(), async (req, res: ApiV3Response) => {
     let checkServices = (() => {
       if (req.query.checkServices == null) return [];
-      return Array.isArray(req.query.checkServices) ? req.query.checkServices : [req.query.checkServices];
+      return Array.isArray(req.query.checkServices)
+        ? req.query.checkServices
+        : [req.query.checkServices];
     })();
     let isStrictly = req.query.strictly != null;
 
     // for backward compatibility
     if (req.query.connectToMiddlewares != null) {
-      logger.warn('The param \'connectToMiddlewares\' is deprecated. Use \'checkServices[]\' instead.');
+      logger.warn(
+        "The param 'connectToMiddlewares' is deprecated. Use 'checkServices[]' instead.",
+      );
       checkServices = ['mongo', 'search'];
     }
     if (req.query.checkMiddlewaresStrictly != null) {
-      logger.warn('The param \'checkMiddlewaresStrictly\' is deprecated. Use \'checkServices[]\' and \'strictly\' instead.');
+      logger.warn(
+        "The param 'checkMiddlewaresStrictly' is deprecated. Use 'checkServices[]' and 'strictly' instead.",
+      );
       checkServices = ['mongo', 'search'];
       isStrictly = true;
     }

Some files were not shown because too many files changed in this diff