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

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

Yuki Takei 4 месяцев назад
Родитель
Сommit
43dad48ca0
69 измененных файлов с 3654 добавлено и 2417 удалено
  1. 22 1
      CHANGELOG.md
  2. 6 0
      apps/app/.eslintrc.js
  3. 3 2
      apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx
  4. 9 2
      apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx
  5. 3 1
      apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx
  6. 5 3
      apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx
  7. 5 3
      apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx
  8. 14 11
      apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx
  9. 6 2
      apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx
  10. 20 17
      apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx
  11. 13 11
      apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx
  12. 15 4
      apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx
  13. 27 22
      apps/app/src/client/components/InvitedForm.tsx
  14. 16 3
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  15. 10 18
      apps/app/src/client/services/AdminGitHubSecurityContainer.js
  16. 9 19
      apps/app/src/client/services/AdminGoogleSecurityContainer.js
  17. 27 89
      apps/app/src/client/services/AdminLdapSecurityContainer.js
  18. 12 14
      apps/app/src/client/services/AdminLocalSecurityContainer.js
  19. 39 153
      apps/app/src/client/services/AdminOidcSecurityContainer.js
  20. 15 66
      apps/app/src/client/services/AdminSamlSecurityContainer.js
  21. 12 4
      apps/app/src/pages/[[...path]]/server-side-props.ts
  22. 11 10
      apps/app/src/pages/_document.page.tsx
  23. 1 10
      apps/app/src/pages/admin/customize.page.tsx
  24. 22 1
      apps/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  25. 6 1
      apps/app/src/pages/common-props/commons.ts
  26. 0 0
      apps/app/src/pages/me/[[...path]].page.tsx
  27. 3 14
      apps/app/src/pages/share/[[...path]]/index.page.tsx
  28. 47 32
      apps/app/src/pages/share/[[...path]]/page-data-props.ts
  29. 14 5
      apps/app/src/pages/share/[[...path]]/types.ts
  30. 8 2
      apps/app/src/server/crowi/index.js
  31. 50 37
      apps/app/src/server/routes/apiv3/activity.ts
  32. 26 15
      apps/app/src/server/routes/apiv3/admin-home.ts
  33. 157 94
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  34. 315 157
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  35. 26 13
      apps/app/src/server/routes/apiv3/healthcheck.ts
  36. 199 141
      apps/app/src/server/routes/apiv3/import.ts
  37. 88 59
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  38. 68 49
      apps/app/src/server/routes/apiv3/installer.ts
  39. 2 2
      apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts
  40. 48 36
      apps/app/src/server/routes/apiv3/invited.ts
  41. 114 52
      apps/app/src/server/routes/apiv3/page-listing.ts
  42. 458 234
      apps/app/src/server/routes/apiv3/pages/index.js
  43. 44 32
      apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts
  44. 40 32
      apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts
  45. 51 42
      apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts
  46. 18 11
      apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts
  47. 277 157
      apps/app/src/server/routes/apiv3/personal-setting/index.js
  48. 13 5
      apps/app/src/server/routes/apiv3/security-settings/checkSetupStrategiesHasAdmin.ts
  49. 687 291
      apps/app/src/server/routes/apiv3/security-settings/index.js
  50. 145 84
      apps/app/src/server/routes/apiv3/user-activation.ts
  51. 46 26
      apps/app/src/server/routes/apiv3/user-activities.ts
  52. 51 45
      apps/app/src/server/routes/apiv3/user-ui-settings.ts
  53. 17 10
      apps/app/src/server/routes/apiv3/user/get-related-groups.ts
  54. 39 18
      apps/app/src/server/service/attachment.ts
  55. 1 3
      apps/app/src/server/service/customize.ts
  56. 44 51
      apps/app/src/server/service/file-uploader/aws/index.ts
  57. 21 28
      apps/app/src/server/service/file-uploader/azure.ts
  58. 19 12
      apps/app/src/server/service/file-uploader/file-uploader.ts
  59. 30 43
      apps/app/src/server/service/file-uploader/gcs/index.ts
  60. 42 47
      apps/app/src/server/service/file-uploader/gridfs.ts
  61. 31 44
      apps/app/src/server/service/file-uploader/local.ts
  62. 1 0
      apps/pdf-converter/.env
  63. 2 2
      apps/pdf-converter/docker/README.md
  64. 1 1
      apps/pdf-converter/package.json
  65. 48 11
      apps/pdf-converter/src/service/pdf-convert.ts
  66. 1 1
      apps/slackbot-proxy/package.json
  67. 3 1
      biome.json
  68. 22 11
      packages/editor/index.html
  69. 9 0
      packages/editor/src/main.scss

+ 22 - 1
CHANGELOG.md

@@ -1,9 +1,30 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/growilabs/compare/v7.3.5...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.3.7...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *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
 ## [v7.3.5](https://github.com/growilabs/compare/v7.3.4...v7.3.5) - 2025-11-10
 
 
 ### 💎 Features
 ### 💎 Features

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

@@ -64,6 +64,12 @@ module.exports = {
     'src/server/routes/*.js',
     'src/server/routes/*.js',
     'src/server/routes/*.ts',
     'src/server/routes/*.ts',
     'src/server/routes/attachment/**',
     '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: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript

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

@@ -17,6 +17,7 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import { EnvVarsTable } from './EnvVarsTable';
 import { EnvVarsTable } from './EnvVarsTable';
 import SystemInfomationTable from './SystemInfomationTable';
 import SystemInfomationTable from './SystemInfomationTable';
 
 
+
 const logger = loggerFactory('growi:admin');
 const logger = loggerFactory('growi:admin');
 
 
 const AdminHome = (props) => {
 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)
         (migrationStatus != null && !migrationStatus.isV5Compatible)
         && (
         && (
           <div className={`alert ${migrationStatus.isV5Compatible == null ? 'alert-warning' : 'alert-info'}`}>
           <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>
           <p>{t('admin:admin_top.env_var_priority')}</p>
           {/* eslint-disable-next-line react/no-danger */}
           {/* eslint-disable-next-line react/no-danger */}
           <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
           <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
-          {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
+          <EnvVarsTable envVars={adminHomeContainer.state.envVars} />
         </div>
         </div>
       </div>
       </div>
 
 

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

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

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

@@ -1,5 +1,7 @@
 import React from 'react';
 import React from 'react';
 
 
+import { LoadingSpinner } from '@growi/ui/dist/components';
+
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -17,7 +19,7 @@ const SystemInformationTable = (props: Props) => {
   } = adminHomeContainer.state;
   } = adminHomeContainer.state;
 
 
   if (growiVersion == null || nodeVersion == null || npmVersion == null || pnpmVersion == null) {
   if (growiVersion == null || nodeVersion == null || npmVersion == null || pnpmVersion == null) {
-    return <></>;
+    return <LoadingSpinner />;
   }
   }
 
 
   return (
   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) => {
   const onClickSubmit = useCallback(async(data) => {
     try {
     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();
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.OAuth.GitHub.updated_github'));
       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) => {
   const onClickSubmit = useCallback(async(data) => {
     try {
     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();
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.OAuth.Google.updated_google'));
       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) => {
   const onSubmit = useCallback(async(data) => {
     try {
     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();
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.ldap.updated_ldap'));
       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) => {
   const onSubmit = useCallback(async(data) => {
     try {
     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();
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.updated_general_security_setting'));
       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) => {
   const onSubmit = useCallback(async(data) => {
     try {
     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();
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.OAuth.OIDC.updated_oidc'));
       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]);
   }, [adminSamlSecurityContainer.state, reset]);
 
 
   const onSubmit = useCallback(async(data) => {
   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 {
     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'));
       toastSuccess(t('security_settings.SAML.updated_saml'));
     }
     }
     catch (err) {
     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) => {
   const onSubmit = useCallback(async(data: FormData) => {
     try {
     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'));
       toastSuccess(t('security_settings.updated_general_security_setting'));
     }
     }
     catch (err) {
     catch (err) {

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

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

@@ -239,9 +239,22 @@ export default class AdminGeneralSecurityContainer extends Container {
    * @memberOf AdminGeneralSecuritySContainer
    * @memberOf AdminGeneralSecuritySContainer
    * @return {string} Appearance
    * @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,
       sessionMaxAge: this.state.sessionMaxAge,
       restrictGuestMode: this.state.currentRestrictGuestMode,
       restrictGuestMode: this.state.currentRestrictGuestMode,
       pageDeletionAuthority: this.state.currentPageDeletionAuthority,
       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';
     return 'AdminGitHubSecurityContainer';
   }
   }
 
 
-  /**
-   * Change githubClientId
-   */
-  changeGitHubClientId(value) {
-    this.setState({ githubClientId: value });
-  }
-
-  /**
-   * Change githubClientSecret
-   */
-  changeGitHubClientSecret(value) {
-    this.setState({ githubClientSecret: value });
-  }
-
   /**
   /**
    * Switch isSameUsernameTreatedAsIdenticalUser
    * Switch isSameUsernameTreatedAsIdenticalUser
    */
    */
@@ -85,10 +71,16 @@ export default class AdminGitHubSecurityContainer extends Container {
   /**
   /**
    * Update githubSetting
    * 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);
     requestParams = await removeNullPropertyFromObject(requestParams);
     const response = await apiv3Put('/security-setting/github-oauth', 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';
     return 'AdminGoogleSecurityContainer';
   }
   }
 
 
-  /**
-   * Change googleClientId
-   */
-  changeGoogleClientId(value) {
-    this.setState({ googleClientId: value });
-  }
-
-  /**
-   * Change googleClientSecret
-   */
-  changeGoogleClientSecret(value) {
-    this.setState({ googleClientSecret: value });
-  }
-
   /**
   /**
    * Switch isSameEmailTreatedAsIdenticalUser
    * Switch isSameEmailTreatedAsIdenticalUser
    */
    */
@@ -87,11 +73,15 @@ export default class AdminGoogleSecurityContainer extends Container {
   /**
   /**
    * Update googleSetting
    * 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);
     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';
     return 'AdminLdapSecurityContainer';
   }
   }
 
 
-  /**
-   * Change serverUrl
-   */
-  changeServerUrl(serverUrl) {
-    this.setState({ serverUrl });
-  }
-
   /**
   /**
    * Change ldapBindMode
    * Change ldapBindMode
    * @param {boolean} isUserBind true: User Bind, false: Admin Bind
    * @param {boolean} isUserBind true: User Bind, false: Admin Bind
@@ -93,34 +86,6 @@ export default class AdminLdapSecurityContainer extends Container {
     this.setState({ isUserBind });
     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
    * Switch is same username treated as identical user
    */
    */
@@ -128,63 +93,36 @@ export default class AdminLdapSecurityContainer extends Container {
     this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
     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
    * 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);
     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 });
     this.setState({ registrationMode: value });
   }
   }
 
 
-  /**
-   * Change registration whitelist
-   */
-  changeRegistrationWhitelist(value) {
-    this.setState({ registrationWhitelist: value.split('\n') });
-  }
-
   /**
   /**
    * Switch password reset enabled
    * Switch password reset enabled
    */
    */
@@ -95,14 +88,19 @@ export default class AdminLocalSecurityContainer extends Container {
   /**
   /**
    * update local security setting
    * 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,
       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;
     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';
     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
    * Switch sameUsernameTreatedAsIdenticalUser
    */
    */
@@ -218,47 +106,45 @@ export default class AdminOidcSecurityContainer extends Container {
   /**
   /**
    * Update OpenID Connect
    * 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);
     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';
     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
    * Switch isSameUsernameTreatedAsIdenticalUser
    */
    */
@@ -168,19 +112,24 @@ export default class AdminSamlSecurityContainer extends Container {
     this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
     this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
   }
   }
 
 
-  /**
-   * Change samlABLCRule
-   */
-  changeSamlABLCRule(inputValue) {
-    this.setState({ samlABLCRule: inputValue });
-  }
-
   /**
   /**
    * Update saml option
    * 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,
       entryPoint: this.state.samlEntryPoint,
       issuer: this.state.samlIssuer,
       issuer: this.state.samlIssuer,
       cert: this.state.samlCert,
       cert: this.state.samlCert,

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

@@ -83,12 +83,20 @@ export async function getServerSidePropsForInitial(
 export async function getServerSidePropsForSameRoute(
 export async function getServerSidePropsForSameRoute(
   context: GetServerSidePropsContext,
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2EachProps>> {
 ): 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
   // -- TODO: persist activity
-
   // const mergedProps = await mergedResult.props;
   // const mergedProps = await mergedResult.props;
   // await addActivity(context, getActivityAction(mergedProps));
   // 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 {
 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;
   pluginResourceEntries: GrowiPluginResourceEntries;
   locale: Locale;
   locale: Locale;
 }
 }
@@ -63,9 +63,10 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const { customizeService } = crowi;
     const { customizeService } = crowi;
 
 
     const { themeHref } = customizeService;
     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
     // retrieve plugin manifests
     const growiPluginService = await import(
     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) {
     if (customScript == null || customScript.length === 0) {
       return <></>;
       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) {
     if (customCss == null || customCss.length === 0) {
       return <></>;
       return <></>;
     }
     }
@@ -108,7 +109,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     return <style dangerouslySetInnerHTML={{ __html: customCss }} />;
     return <style dangerouslySetInnerHTML={{ __html: customCss }} />;
   }
   }
 
 
-  renderCustomNoscript(customNoscript: string | null): JSX.Element {
+  renderCustomNoscript(customNoscript: string | undefined): JSX.Element {
     if (customNoscript == null || customNoscript.length === 0) {
     if (customNoscript == null || customNoscript.length === 0) {
       return <></>;
       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 { useHydrateAtoms } from 'jotai/utils';
 
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import { _atomsForAdminPagesHydration as atoms } from '~/states/global';
 import { isCustomizedLogoUploadedAtom } from '~/states/server-configurations';
 import { isCustomizedLogoUploadedAtom } from '~/states/server-configurations';
 
 
 import type { NextPageWithLayout } from '../_app.page';
 import type { NextPageWithLayout } from '../_app.page';
@@ -21,7 +20,6 @@ const CustomizeSettingContents = dynamic(
 );
 );
 
 
 type PageProps = {
 type PageProps = {
-  isDefaultBrandLogoUsed: boolean;
   isCustomizedLogoUploaded: boolean;
   isCustomizedLogoUploaded: boolean;
   customTitleTemplate?: string;
   customTitleTemplate?: string;
 };
 };
@@ -33,11 +31,7 @@ const AdminCustomizeSettingsPage: NextPageWithLayout<Props> = (
   props: Props,
   props: Props,
 ) => {
 ) => {
   useHydrateAtoms(
   useHydrateAtoms(
-    [
-      [atoms.isDefaultLogoAtom, props.isDefaultBrandLogoUsed],
-      [atoms.customTitleTemplateAtom, props.customTitleTemplate],
-      [isCustomizedLogoUploadedAtom, props.isCustomizedLogoUploaded],
-    ],
+    [[isCustomizedLogoUploadedAtom, props.isCustomizedLogoUploaded]],
     { dangerouslyForceHydrate: true },
     { dangerouslyForceHydrate: true },
   );
   );
 
 
@@ -66,11 +60,8 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
 
 
   const customizePropsFragment = {
   const customizePropsFragment = {
     props: {
     props: {
-      isDefaultBrandLogoUsed:
-        await crowi.attachmentService.isDefaultBrandLogoUsed(),
       isCustomizedLogoUploaded:
       isCustomizedLogoUploaded:
         await crowi.attachmentService.isBrandLogoExist(),
         await crowi.attachmentService.isBrandLogoExist(),
-      customTitleTemplate: crowi.configManager.getConfig('customize:title'),
     },
     },
   } satisfies { props: PageProps };
   } 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 { useMemo } from 'react';
+import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import { useHydrateAtoms } from 'jotai/utils';
 import { useHydrateAtoms } from 'jotai/utils';
 
 
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { isAclEnabledAtom } from '~/states/server-configurations';
 import { isAclEnabledAtom } from '~/states/server-configurations';
 
 
 import type { NextPageWithLayout } from '../../_app.page';
 import type { NextPageWithLayout } from '../../_app.page';
+import { mergeGetServerSidePropsResults } from '../../utils/server-side-props';
 import type { AdminCommonProps } from '../_shared';
 import type { AdminCommonProps } from '../_shared';
 import {
 import {
   createAdminPageLayout,
   createAdminPageLayout,
@@ -45,6 +48,24 @@ AdminUserGroupDetailPage.getLayout = createAdminPageLayout<Props>({
   title: (_p, t) => t('user_group_management.user_group_management'),
   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;
 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;
   let currentUser: IUserHasId | undefined;
   if (user != null) {
   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
   // 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 type { JSX, ReactNode } from 'react';
-import React from 'react';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
 import Head from 'next/head';
@@ -50,11 +49,12 @@ const isInitialProps = (props: Props): props is InitialProps => {
 
 
 const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
 const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   // Initialize Jotai atoms with initial data - must be called unconditionally
   // 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 shareLink = isInitialProps(props) ? props.shareLink : undefined;
   const isExpired = isInitialProps(props) ? props.isExpired : undefined;
   const isExpired = isInitialProps(props) ? props.isExpired : undefined;
 
 
-  useHydratePageAtoms(pageData, undefined, {
+  useHydratePageAtoms(pageData, pageMeta, {
     shareLinkId: shareLink?._id,
     shareLinkId: shareLink?._id,
   });
   });
 
 
@@ -157,17 +157,6 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
   ) {
   ) {
     return commonEachPropsResult;
     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
   // 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 { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 import type { IPage } from '@growi/core';
 import type { IPage } from '@growi/core';
-import { getIdForRef } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
 import type { model } from 'mongoose';
 import type { model } from 'mongoose';
 
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
@@ -14,11 +14,27 @@ let mongooseModel: typeof model;
 let Page: PageModel;
 let Page: PageModel;
 let ShareLink: ShareLinkModel;
 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 (
 export const getPageDataForInitial = async (
   context: GetServerSidePropsContext,
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<ShareLinkPageStatesProps>> => {
 ): Promise<GetServerSidePropsResult<ShareLinkPageStatesProps>> => {
   const req = context.req as CrowiRequest;
   const req = context.req as CrowiRequest;
   const { crowi, params } = req;
   const { crowi, params } = req;
+  const { pageService, configManager } = crowi;
 
 
   if (mongooseModel == null) {
   if (mongooseModel == null) {
     mongooseModel = (await import('mongoose')).model;
     mongooseModel = (await import('mongoose')).model;
@@ -36,63 +52,62 @@ export const getPageDataForInitial = async (
 
 
   // not found
   // not found
   if (shareLink == null) {
   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
   // expired
   if (shareLink.isExpired()) {
   if (shareLink.isExpired()) {
+    const populatedPage =
+      await pageWithMeta.data.populateDataToShowRevision(true); //shouldExcludeBody = false,
     return {
     return {
       props: {
       props: {
         isNotFound: false,
         isNotFound: false,
-        page: null,
+        pageWithMeta: {
+          data: populatedPage,
+          meta: pageWithMeta.meta,
+        },
         isExpired: true,
         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
   // Handle existing page
-  const ssrMaxRevisionBodyLength = crowi.configManager.getConfig(
+  const ssrMaxRevisionBodyLength = configManager.getConfig(
     'app:ssrMaxRevisionBodyLength',
     'app:ssrMaxRevisionBodyLength',
   );
   );
 
 
   // Check if SSR should be skipped
   // Check if SSR should be skipped
   const latestRevisionBodyLength =
   const latestRevisionBodyLength =
-    await relatedPage.getLatestRevisionBodyLength();
+    await pageWithMeta.data.getLatestRevisionBodyLength();
   const skipSSR =
   const skipSSR =
     latestRevisionBodyLength != null &&
     latestRevisionBodyLength != null &&
     ssrMaxRevisionBodyLength < latestRevisionBodyLength;
     ssrMaxRevisionBodyLength < latestRevisionBodyLength;
 
 
-  const populatedPage = await relatedPage.populateDataToShowRevision(skipSSR);
+  // Populate page data for display
+  const populatedPage =
+    await pageWithMeta.data.populateDataToShowRevision(skipSSR);
 
 
   return {
   return {
     props: {
     props: {
       isNotFound: false,
       isNotFound: false,
-      page: populatedPage,
+      pageWithMeta: {
+        data: populatedPage,
+        meta: pageWithMeta.meta,
+      },
       skipSSR,
       skipSSR,
       isExpired: false,
       isExpired: false,
       shareLink: shareLink.toObject(),
       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 { IShareLinkHasId } from '~/interfaces/share-link';
 import type { CommonEachProps, CommonInitialProps } from '~/pages/common-props';
 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<
 export type ShareLinkPageStatesProps = Pick<
   GeneralPageInitialProps,
   GeneralPageInitialProps,
@@ -10,19 +16,22 @@ export type ShareLinkPageStatesProps = Pick<
 > &
 > &
   (
   (
     | {
     | {
-        page: null;
+        // not found case
+        pageWithMeta: IDataWithRequiredMeta<null, IPageNotFoundInfo>;
         isNotFound: true;
         isNotFound: true;
         isExpired: undefined;
         isExpired: undefined;
         shareLink: undefined;
         shareLink: undefined;
       }
       }
     | {
     | {
-        page: null;
+        // expired case
+        pageWithMeta: IPageToShowRevisionWithMeta;
         isNotFound: false;
         isNotFound: false;
         isExpired: true;
         isExpired: true;
         shareLink: IShareLinkHasId;
         shareLink: IShareLinkHasId;
       }
       }
     | {
     | {
-        page: IPagePopulatedToShowRevision;
+        // normal case
+        pageWithMeta: IPageToShowRevisionWithMeta;
         isNotFound: false;
         isNotFound: false;
         isExpired: false;
         isExpired: false;
         shareLink: IShareLinkHasId;
         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 { accessTokenParser } from '../middlewares/access-token-parser';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AppService from '../service/app';
-import AttachmentService from '../service/attachment';
+import { AttachmentService } from '../service/attachment';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import instanciateExportService from '../service/export';
 import instanciateExportService from '../service/export';
 import instanciateExternalAccountService from '../service/external-account';
 import instanciateExternalAccountService from '../service/external-account';
@@ -74,6 +74,9 @@ class Crowi {
   /** @type {import('../service/config-manager').IConfigManagerForApp} */
   /** @type {import('../service/config-manager').IConfigManagerForApp} */
   configManager;
   configManager;
 
 
+  /** @type {AttachmentService} */
+  attachmentService;
+
   /** @type {import('../service/acl').AclService} */
   /** @type {import('../service/acl').AclService} */
   aclService;
   aclService;
 
 
@@ -98,6 +101,9 @@ class Crowi {
   /** @type {import('../service/page-operation').IPageOperationService} */
   /** @type {import('../service/page-operation').IPageOperationService} */
   pageOperationService;
   pageOperationService;
 
 
+  /** @type {import('../service/customize').CustomizeService} */
+  customizeService;
+
   /** @type {PassportService} */
   /** @type {PassportService} */
   passportService;
   passportService;
 
 
@@ -632,7 +638,7 @@ Crowi.prototype.setUpAcl = async function () {
  * setup CustomizeService
  * setup CustomizeService
  */
  */
 Crowi.prototype.setUpCustomize = async function () {
 Crowi.prototype.setUpCustomize = async function () {
-  const CustomizeService = require('../service/customize');
+  const { CustomizeService } = await import('../service/customize');
   if (this.customizeService == null) {
   if (this.customizeService == null) {
     this.customizeService = new CustomizeService(this);
     this.customizeService = new CustomizeService(this);
     this.customizeService.initCustomCss();
     this.customizeService.initCustomCss();

+ 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 { 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 type { Request, Router } from 'express';
 import express from 'express';
 import express from 'express';
 import { query } from 'express-validator';
 import { query } from 'express-validator';
 
 
 import type { IActivity, ISearchFilter } from '~/interfaces/activity';
 import type { IActivity, ISearchFilter } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import Activity from '~/server/models/activity';
 import Activity from '~/server/models/activity';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
@@ -13,18 +13,21 @@ import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:activity');
 const logger = loggerFactory('growi:routes:apiv3:activity');
 
 
-
 const validator = {
 const validator = {
   list: [
   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('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 => {
 module.exports = (crowi: Crowi): Router => {
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   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();
   const router = express.Router();
 
 
@@ -209,9 +214,14 @@ module.exports = (crowi: Crowi): Router => {
    *             schema:
    *             schema:
    *               $ref: '#/components/schemas/ActivityResponse'
    *               $ref: '#/components/schemas/ActivityResponse'
    */
    */
-  router.get('/',
+  router.get(
+    '/',
     accessTokenParser([SCOPE.READ.ADMIN.AUDIT_LOG], { acceptLegacy: true }),
     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');
       const auditLogEnabled = configManager.getConfig('app:auditLogEnabled');
       if (!auditLogEnabled) {
       if (!auditLogEnabled) {
         const msg = 'AuditLog is not enabled';
         const msg = 'AuditLog is not enabled';
@@ -219,28 +229,36 @@ module.exports = (crowi: Crowi): Router => {
         return res.apiv3Err(msg, 405);
         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 offset = req.query.offset || 1;
 
 
       const query = {};
       const query = {};
 
 
       try {
       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
         // 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) {
         if (canContainUsernameFilterToQuery) {
-          Object.assign(query, { 'snapshot.username': parsedSearchFilter.usernames });
+          Object.assign(query, {
+            'snapshot.username': parsedSearchFilter.usernames,
+          });
         }
         }
 
 
         // add action to query
         // add action to query
         if (parsedSearchFilter.actions != null) {
         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 });
           Object.assign(query, { action: searchableActions });
         }
         }
 
 
@@ -255,8 +273,7 @@ module.exports = (crowi: Crowi): Router => {
               $lt: addMinutes(endDate, 1439),
               $lt: addMinutes(endDate, 1439),
             },
             },
           });
           });
-        }
-        else if (isValid(startDate) && !isValid(endDate)) {
+        } else if (isValid(startDate) && !isValid(endDate)) {
           Object.assign(query, {
           Object.assign(query, {
             createdAt: {
             createdAt: {
               $gte: startDate,
               $gte: startDate,
@@ -265,23 +282,19 @@ module.exports = (crowi: Crowi): Router => {
             },
             },
           });
           });
         }
         }
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Invalid value', err);
         logger.error('Invalid value', err);
         return res.apiv3Err(err, 400);
         return res.apiv3Err(err, 400);
       }
       }
 
 
       try {
       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 serializedDocs = paginateResult.docs.map((doc: IActivity) => {
           const { user, ...rest } = doc;
           const { user, ...rest } = doc;
@@ -297,12 +310,12 @@ module.exports = (crowi: Crowi): Router => {
         };
         };
 
 
         return res.apiv3({ serializedPaginationResult });
         return res.apiv3({ serializedPaginationResult });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Failed to get paginated activity', err);
         logger.error('Failed to get paginated activity', err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
-    });
+    },
+  );
 
 
   return router;
   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 { SCOPE } from '@growi/core/dist/interfaces';
+
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
 import { getGrowiVersion } from '~/utils/growi-version';
@@ -60,7 +61,9 @@ const router = express.Router();
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
 
 
   /**
   /**
@@ -83,22 +86,30 @@ module.exports = (crowi) => {
    *                    adminHomeParams:
    *                    adminHomeParams:
    *                      $ref: "#/components/schemas/SystemInformationParams"
    *                      $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;
   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 { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 import type { Types } from 'mongoose';
 import type { Types } from 'mongoose';
 
 
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
@@ -99,29 +99,44 @@ const router = express.Router();
 const validator = {
 const validator = {
   bookmarkFolder: [
   bookmarkFolder: [
     body('name').isString().withMessage('name must be a string'),
     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);
         const parentFolder = await BookmarkFolder.findById(parent);
         if (parentFolder == null || parentFolder.parent != null) {
         if (parentFolder == null || parentFolder.parent != null) {
           throw new Error('Maximum folder hierarchy of 2 levels');
           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: [
   bookmarkPage: [
     body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
     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: [
   bookmark: [
     body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
     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 */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -157,28 +172,36 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
    */
-  router.post('/',
+  router.post(
+    '/',
     accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
     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 owner = req.user?._id;
       const { name, parent } = req.body;
       const { name, parent } = req.body;
       const params = {
       const params = {
-        name, owner, parent,
+        name,
+        owner,
+        parent,
       };
       };
 
 
       try {
       try {
         const bookmarkFolder = await BookmarkFolder.createByParameters(params);
         const bookmarkFolder = await BookmarkFolder.createByParameters(params);
         logger.debug('bookmark folder created', bookmarkFolder);
         logger.debug('bookmark folder created', bookmarkFolder);
         return res.apiv3({ bookmarkFolder });
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         if (err instanceof InvalidParentBookmarkFolderError) {
         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);
         return res.apiv3Err(err, 500);
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -211,63 +234,75 @@ module.exports = (crowi) => {
    *                        type: object
    *                        type: object
    *                        $ref: '#/components/schemas/BookmarkFolder'
    *                        $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,
         userId: Types.ObjectId | string,
         parentFolderId?: 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: {
             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
    * @swagger
@@ -299,18 +334,22 @@ module.exports = (crowi) => {
    *                      description: Number of deleted folders
    *                      description: Number of deleted folders
    *                      example: 1
    *                      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
    * @swagger
@@ -355,20 +394,27 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    *                      $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 {
       try {
-        const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, childFolder);
+        const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(
+          bookmarkFolderId,
+          name,
+          parent,
+          childFolder,
+        );
         return res.apiv3({ bookmarkFolder });
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -405,22 +451,31 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    *                      $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 userId = req.user?._id;
       const { pageId, folderId } = req.body;
       const { pageId, folderId } = req.body;
 
 
       try {
       try {
-        const bookmarkFolder = await BookmarkFolder.insertOrUpdateBookmarkedPage(pageId, userId, folderId);
+        const bookmarkFolder =
+          await BookmarkFolder.insertOrUpdateBookmarkedPage(
+            pageId,
+            userId,
+            folderId,
+          );
         logger.debug('bookmark added to folder', bookmarkFolder);
         logger.debug('bookmark added to folder', bookmarkFolder);
         return res.apiv3({ bookmarkFolder });
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -456,18 +511,26 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    *                      $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 { pageId, status } = req.body;
       const userId = req.user?._id;
       const userId = req.user?._id;
       try {
       try {
-        const bookmarkFolder = await BookmarkFolder.updateBookmark(pageId, status, userId);
+        const bookmarkFolder = await BookmarkFolder.updateBookmark(
+          pageId,
+          status,
+          userId,
+        );
         return res.apiv3({ bookmarkFolder });
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
-    });
+    },
+  );
   return router;
   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 { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { NextFunction, Request, Router } from 'express';
 import type { NextFunction, Request, Router } from 'express';
 import express from 'express';
 import express from 'express';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
+import { createReadStream } from 'fs';
 import multer from 'multer';
 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 { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
@@ -19,15 +19,13 @@ import { getImportService } from '~/server/service/import';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { TransferKey } from '~/utils/vo/transfer-key';
 import { TransferKey } from '~/utils/vo/transfer-key';
 
 
-
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { Attachment } from '../../models/attachment';
 import { Attachment } from '../../models/attachment';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
 interface AuthorizedRequest extends Request {
 interface AuthorizedRequest extends Request {
-  user?: any
+  user?: any;
 }
 }
 
 
 const logger = loggerFactory('growi:routes:apiv3:transfer');
 const logger = loggerFactory('growi:routes:apiv3:transfer');
@@ -76,20 +74,27 @@ const validator = {
  *                 type: string
  *                 type: string
  *               containerName:
  *               containerName:
  *                 type: string
  *                 type: string
-*/
+ */
 /*
 /*
  * Routes
  * Routes
  */
  */
 module.exports = (crowi: Crowi): Router => {
 module.exports = (crowi: Crowi): Router => {
   const {
   const {
-    g2gTransferPusherService, g2gTransferReceiverService,
+    g2gTransferPusherService,
+    g2gTransferReceiverService,
     growiBridgeService,
     growiBridgeService,
   } = crowi;
   } = crowi;
 
 
   const importService = getImportService();
   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');
     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 isInstalled = configManager.getConfig('app:installed');
 
 
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
 
 
   // Middleware
   // Middleware
-  const adminRequiredIfInstalled = (req: Request, res: ApiV3Response, next: NextFunction) => {
+  const adminRequiredIfInstalled = (
+    req: Request,
+    res: ApiV3Response,
+    next: NextFunction,
+  ) => {
     if (!isInstalled) {
     if (!isInstalled) {
       next();
       next();
       return;
       return;
@@ -139,29 +150,47 @@ module.exports = (crowi: Crowi): Router => {
   };
   };
 
 
   // Middleware
   // Middleware
-  const appSiteUrlRequiredIfNotInstalled = (req: Request, res: ApiV3Response, next: NextFunction) => {
+  const appSiteUrlRequiredIfNotInstalled = (
+    req: Request,
+    res: ApiV3Response,
+    next: NextFunction,
+  ) => {
     if (!isInstalled && req.body.appSiteUrl != null) {
     if (!isInstalled && req.body.appSiteUrl != null) {
       next();
       next();
       return;
       return;
     }
     }
 
 
-    if (configManager.getConfig('app:siteUrl') != null || req.body.appSiteUrl != null) {
+    if (
+      configManager.getConfig('app:siteUrl') != null ||
+      req.body.appSiteUrl != null
+    ) {
       next();
       next();
       return;
       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
   // 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;
     const transferKey = req.headers[X_GROWI_TRANSFER_KEY_HEADER_NAME] as string;
 
 
     try {
     try {
       await g2gTransferReceiverService.validateTransferKey(transferKey);
       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();
     next();
@@ -200,10 +229,14 @@ module.exports = (crowi: Crowi): Router => {
    *                          type: number
    *                          type: number
    *                          description: The size of the file
    *                          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
    * @swagger
@@ -251,88 +284,122 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    type: string
    *                    description: The message of the result
    *                    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
    * @swagger
@@ -370,54 +437,101 @@ module.exports = (crowi: Crowi): Router => {
    *                    description: The message of the result
    *                    description: The message of the result
    */
    */
   // This endpoint uses multer's MemoryStorage since the received data should be persisted directly on attachment storage.
   // 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 { file } = req;
       const { attachmentMetadata } = req.body;
       const { attachmentMetadata } = req.body;
 
 
-      let attachmentMap;
+      let attachmentMap: { fileName: any; fileSize: any };
       try {
       try {
         attachmentMap = JSON.parse(attachmentMetadata);
         attachmentMap = JSON.parse(attachmentMetadata);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(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 {
       try {
         const { fileName, fileSize } = attachmentMap;
         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 });
           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 });
           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 });
         const count = await Attachment.countDocuments({ fileName, fileSize });
         if (count === 0) {
         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);
         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, {
       const fileStream = createReadStream(file.path, {
-        flags: 'r', mode: 0o666, autoClose: true,
+        flags: 'r',
+        mode: 0o666,
+        autoClose: true,
       });
       });
       try {
       try {
-        await g2gTransferReceiverService.receiveAttachment(fileStream, attachmentMap);
-      }
-      catch (err) {
+        await g2gTransferReceiverService.receiveAttachment(
+          fileStream,
+          attachmentMap,
+        );
+      } catch (err) {
         logger.error(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.' });
       return res.apiv3({ message: 'Successfully imported attached file.' });
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -439,23 +553,32 @@ module.exports = (crowi: Crowi): Router => {
    *                  growiInfo:
    *                  growiInfo:
    *                    $ref: '#/components/schemas/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
    * @swagger
@@ -489,32 +612,46 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    type: string
    *                    description: The transfer key
    *                    description: The transfer key
    */
    */
-  receiveRouter.post('/generate-key',
+  receiveRouter.post(
+    '/generate-key',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
     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;
       let appSiteUrlOrigin: string;
       try {
       try {
         appSiteUrlOrigin = new URL(appSiteUrl).origin;
         appSiteUrlOrigin = new URL(appSiteUrl).origin;
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(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
       // Save TransferKey document
       let transferKeyString: string;
       let transferKeyString: string;
       try {
       try {
-        transferKeyString = await g2gTransferReceiverService.createTransferKey(appSiteUrlOrigin);
-      }
-      catch (err) {
+        transferKeyString =
+          await g2gTransferReceiverService.createTransferKey(appSiteUrlOrigin);
+      } catch (err) {
         logger.error(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 });
       return res.apiv3({ transferKey: transferKeyString });
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -556,44 +693,65 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    type: string
    *                    description: The message of the result
    *                    description: The message of the result
    */
    */
-  pushRouter.post('/transfer',
+  pushRouter.post(
+    '/transfer',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
     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;
       const { transferKey, collections, optionsMap } = req.body;
 
 
       // Parse transfer key
       // Parse transfer key
       let tk: TransferKey;
       let tk: TransferKey;
       try {
       try {
         tk = TransferKey.parse(transferKey);
         tk = TransferKey.parse(transferKey);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(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
       // get growi info
       let destGROWIInfo: IDataGROWIInfo;
       let destGROWIInfo: IDataGROWIInfo;
       try {
       try {
         destGROWIInfo = await g2gTransferPusherService.askGROWIInfo(tk);
         destGROWIInfo = await g2gTransferPusherService.askGROWIInfo(tk);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(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
       // Check if can transfer
-      const transferability = await g2gTransferPusherService.getTransferability(destGROWIInfo);
+      const transferability =
+        await g2gTransferPusherService.getTransferability(destGROWIInfo);
       if (!transferability.canTransfer) {
       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
       // Start transfer
       // DO NOT "await". Let it run in the background.
       // DO NOT "await". Let it run in the background.
       // Errors should be emitted through websocket.
       // 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.' });
       return res.apiv3({ message: 'Successfully requested auto transfer.' });
-    });
+    },
+  );
 
 
   // Merge receiveRouter and pushRouter
   // Merge receiveRouter and pushRouter
   router.use(receiveRouter, 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 loggerFactory from '~/utils/logger';
 
 
 import { Config } from '../../models/config';
 import { Config } from '../../models/config';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:healthcheck');
 const logger = loggerFactory('growi:routes:apiv3:healthcheck');
 
 
 const router = express.Router();
 const router = express.Router();
@@ -79,15 +77,19 @@ const router = express.Router();
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-
   async function checkMongo(errors, info) {
   async function checkMongo(errors, info) {
     try {
     try {
       await Config.findOne({});
       await Config.findOne({});
 
 
       info.mongo = 'OK';
       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 {
       try {
         info.searchInfo = await searchService.getInfoForHealth();
         info.searchInfo = await searchService.getInfoForHealth();
         searchService.resetErrorStatus();
         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:
    *                  info:
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    */
    */
-  router.get('/', nocache(), async(req, res: ApiV3Response) => {
+  router.get('/', nocache(), async (req, res: ApiV3Response) => {
     let checkServices = (() => {
     let checkServices = (() => {
       if (req.query.checkServices == null) return [];
       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;
     let isStrictly = req.query.strictly != null;
 
 
     // for backward compatibility
     // for backward compatibility
     if (req.query.connectToMiddlewares != null) {
     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'];
       checkServices = ['mongo', 'search'];
     }
     }
     if (req.query.checkMiddlewaresStrictly != null) {
     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'];
       checkServices = ['mongo', 'search'];
       isStrictly = true;
       isStrictly = true;
     }
     }

+ 199 - 141
apps/app/src/server/routes/apiv3/import.ts

@@ -1,5 +1,6 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
+import type { Router } from 'express';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
@@ -8,11 +9,11 @@ import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { ImportSettings } from '~/server/service/import';
 import type { ImportSettings } from '~/server/service/import';
 import { getImportService } from '~/server/service/import';
 import { getImportService } from '~/server/service/import';
 import { generateOverwriteParams } from '~/server/service/import/overwrite-params';
 import { generateOverwriteParams } from '~/server/service/import/overwrite-params';
+import type { ZipFileStat } from '~/server/service/interfaces/export';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 
 
 const path = require('path');
 const path = require('path');
@@ -20,7 +21,6 @@ const path = require('path');
 const express = require('express');
 const express = require('express');
 const multer = require('multer');
 const multer = require('multer');
 
 
-
 const router = express.Router();
 const router = express.Router();
 
 
 /**
 /**
@@ -126,7 +126,7 @@ const router = express.Router();
  *                  type: integer
  *                  type: integer
  *                  nullable: true
  *                  nullable: true
  */
  */
-export default function route(crowi: Crowi): void {
+export default function route(crowi: Crowi): Router {
   const { growiBridgeService, socketIoService } = crowi;
   const { growiBridgeService, socketIoService } = crowi;
   const importService = getImportService();
   const importService = getImportService();
 
 
@@ -201,22 +201,35 @@ export default function route(crowi: Crowi): void {
    *                        type: string
    *                        type: string
    *                        description: the access token of qiita.com
    *                        description: the access token of qiita.com
    */
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
-    try {
-      const importSettingsParams = {
-        esaTeamName: await crowi.configManager.getConfig('importer:esa:team_name'),
-        esaAccessToken: await crowi.configManager.getConfig('importer:esa:access_token'),
-        qiitaTeamName: await crowi.configManager.getConfig('importer:qiita:team_name'),
-        qiitaAccessToken: await crowi.configManager.getConfig('importer:qiita:access_token'),
-      };
-      return res.apiv3({
-        importSettingsParams,
-      });
-    }
-    catch (err) {
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const importSettingsParams = {
+          esaTeamName: await crowi.configManager.getConfig(
+            'importer:esa:team_name',
+          ),
+          esaAccessToken: await crowi.configManager.getConfig(
+            'importer:esa:access_token',
+          ),
+          qiitaTeamName: await crowi.configManager.getConfig(
+            'importer:qiita:team_name',
+          ),
+          qiitaAccessToken: await crowi.configManager.getConfig(
+            'importer:qiita:access_token',
+          ),
+        };
+        return res.apiv3({
+          importSettingsParams,
+        });
+      } catch (err) {
+        return res.apiv3Err(err, 500);
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -239,15 +252,20 @@ export default function route(crowi: Crowi): void {
    *                  status:
    *                  status:
    *                    $ref: '#/components/schemas/ImportStatus'
    *                    $ref: '#/components/schemas/ImportStatus'
    */
    */
-  router.get('/status', accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
-    try {
-      const status = await importService.getStatus();
-      return res.apiv3(status);
-    }
-    catch (err) {
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.get(
+    '/status',
+    accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const status = await importService.getStatus();
+        return res.apiv3(status);
+      } catch (err) {
+        return res.apiv3Err(err, 500);
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -286,103 +304,131 @@ export default function route(crowi: Crowi): void {
    *        200:
    *        200:
    *          description: Import process has requested
    *          description: Import process has requested
    */
    */
-  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, addActivity, async(req, res) => {
-    // TODO: add express validator
-    const { fileName, collections, options } = req.body;
-
-    // pages collection can only be imported by upsert if isV5Compatible is true
-    const isV5Compatible = crowi.configManager.getConfig('app:isV5Compatible');
-    const isImportPagesCollection = collections.includes('pages');
-    if (isV5Compatible && isImportPagesCollection) {
-      /** @type {ImportOptionForPages} */
-      const option = options.find(opt => opt.collectionName === 'pages');
-      if (option.mode !== 'upsert') {
-        return res.apiv3Err(new ErrorV3('Upsert is only available for importing pages collection.', 'only_upsert_available'));
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    addActivity,
+    async (req, res) => {
+      // TODO: add express validator
+      const { fileName, collections, options } = req.body;
+
+      // pages collection can only be imported by upsert if isV5Compatible is true
+      const isV5Compatible =
+        crowi.configManager.getConfig('app:isV5Compatible');
+      const isImportPagesCollection = collections.includes('pages');
+      if (isV5Compatible && isImportPagesCollection) {
+        /** @type {ImportOptionForPages} */
+        const option = options.find((opt) => opt.collectionName === 'pages');
+        if (option.mode !== 'upsert') {
+          return res.apiv3Err(
+            new ErrorV3(
+              'Upsert is only available for importing pages collection.',
+              'only_upsert_available',
+            ),
+          );
+        }
       }
       }
-    }
-
-    const isMaintenanceMode = crowi.appService.isMaintenanceMode();
-    if (!isMaintenanceMode) {
-      return res.apiv3Err(new ErrorV3('GROWI is not maintenance mode. To import data, please activate the maintenance mode first.', 'not_maintenance_mode'));
-    }
 
 
+      const isMaintenanceMode = crowi.appService.isMaintenanceMode();
+      if (!isMaintenanceMode) {
+        return res.apiv3Err(
+          new ErrorV3(
+            'GROWI is not maintenance mode. To import data, please activate the maintenance mode first.',
+            'not_maintenance_mode',
+          ),
+        );
+      }
 
 
-    const zipFile = importService.getFile(fileName);
-
-    // return response first
-    res.apiv3();
+      const zipFile = importService.getFile(fileName);
 
 
-    /*
-     * unzip, parse
-     */
-    let meta;
-    let fileStatsToImport;
-    try {
-      // unzip
-      await importService.unzip(zipFile);
+      // return response first
+      res.apiv3();
 
 
-      // eslint-disable-next-line no-unused-vars
-      const parseZipResult = await growiBridgeService.parseZipFile(zipFile);
-      if (parseZipResult == null) {
-        throw new Error('parseZipFile returns null');
+      /*
+       * unzip, parse
+       */
+      let meta: object;
+      let fileStatsToImport: {
+        fileName: string;
+        collectionName: string;
+        size: number;
+      }[];
+      try {
+        // unzip
+        await importService.unzip(zipFile);
+
+        // eslint-disable-next-line no-unused-vars
+        const parseZipResult = await growiBridgeService.parseZipFile(zipFile);
+        if (parseZipResult == null) {
+          throw new Error('parseZipFile returns null');
+        }
+
+        meta = parseZipResult.meta;
+
+        // filter innerFileStats
+        fileStatsToImport = parseZipResult.innerFileStats.filter(
+          ({ collectionName }) => {
+            return collections.includes(collectionName);
+          },
+        );
+      } catch (err) {
+        logger.error(err);
+        adminEvent.emit('onErrorForImport', { message: err.message });
+        return;
       }
       }
 
 
-      meta = parseZipResult.meta;
+      /*
+       * validate with meta.json
+       */
+      try {
+        importService.validate(meta);
+      } catch (err) {
+        logger.error(err);
+        adminEvent.emit('onErrorForImport', { message: err.message });
+        return;
+      }
 
 
-      // filter innerFileStats
-      fileStatsToImport = parseZipResult.innerFileStats.filter(({ collectionName }) => {
-        return collections.includes(collectionName);
+      // generate maps of ImportSettings to import
+      // Use the Map for a potential fix for the code scanning alert no. 895: Prototype-polluting assignment
+      const importSettingsMap = new Map<string, ImportSettings>();
+      fileStatsToImport.forEach(({ fileName, collectionName }) => {
+        // instanciate GrowiArchiveImportOption
+        const option: GrowiArchiveImportOption = options.find(
+          (opt) => opt.collectionName === collectionName,
+        );
+
+        // generate options
+        const importSettings = {
+          mode: option.mode,
+          jsonFileName: fileName,
+          overwriteParams: generateOverwriteParams(
+            collectionName,
+            req.user._id,
+            option,
+          ),
+        } satisfies ImportSettings;
+
+        importSettingsMap.set(collectionName, importSettings);
       });
       });
-    }
-    catch (err) {
-      logger.error(err);
-      adminEvent.emit('onErrorForImport', { message: err.message });
-      return;
-    }
-
-    /*
-     * validate with meta.json
-     */
-    try {
-      importService.validate(meta);
-    }
-    catch (err) {
-      logger.error(err);
-      adminEvent.emit('onErrorForImport', { message: err.message });
-      return;
-    }
-
-    // generate maps of ImportSettings to import
-    // Use the Map for a potential fix for the code scanning alert no. 895: Prototype-polluting assignment
-    const importSettingsMap = new Map<string, ImportSettings>();
-    fileStatsToImport.forEach(({ fileName, collectionName }) => {
-      // instanciate GrowiArchiveImportOption
-      const option: GrowiArchiveImportOption = options.find(opt => opt.collectionName === collectionName);
-
-      // generate options
-      const importSettings = {
-        mode: option.mode,
-        jsonFileName: fileName,
-        overwriteParams: generateOverwriteParams(collectionName, req.user._id, option),
-      } satisfies ImportSettings;
-
-      importSettingsMap.set(collectionName, importSettings);
-    });
-
-    /*
-     * import
-     */
-    try {
-      importService.import(collections, importSettingsMap);
-
-      const parameters = { action: SupportedAction.ACTION_ADMIN_GROWI_DATA_IMPORTED };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-    }
-    catch (err) {
-      logger.error(err);
-      adminEvent.emit('onErrorForImport', { message: err.message });
-    }
-  });
+
+      /*
+       * import
+       */
+      try {
+        importService.import(collections, importSettingsMap);
+
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_GROWI_DATA_IMPORTED,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+      } catch (err) {
+        logger.error(err);
+        adminEvent.emit('onErrorForImport', { message: err.message });
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -412,36 +458,43 @@ export default function route(crowi: Crowi): void {
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/FileImportResponse'
    *                $ref: '#/components/schemas/FileImportResponse'
    */
    */
-  router.post('/upload',
-    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, uploads.single('file'), addActivity,
-    async(req, res) => {
+  router.post(
+    '/upload',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    uploads.single('file'),
+    addActivity,
+    async (req, res) => {
       const { file } = req;
       const { file } = req;
       const zipFile = importService.getFile(file.filename);
       const zipFile = importService.getFile(file.filename);
-      let data;
+      let data: ZipFileStat | null;
 
 
       try {
       try {
         data = await growiBridgeService.parseZipFile(zipFile);
         data = await growiBridgeService.parseZipFile(zipFile);
-      }
-      catch (err) {
-      // TODO: use ApiV3Error
+      } catch (err) {
+        // TODO: use ApiV3Error
         logger.error(err);
         logger.error(err);
         return res.status(500).send({ status: 'ERROR' });
         return res.status(500).send({ status: 'ERROR' });
       }
       }
       try {
       try {
-      // validate with meta.json
-        importService.validate(data.meta);
+        // validate with meta.json
+        importService.validate(data?.meta);
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_UPLOAD };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3(data);
         return res.apiv3(data);
-      }
-      catch {
-        const msg = 'The version of this GROWI and the uploaded GROWI data are not the same';
+      } catch {
+        const msg =
+          'The version of this GROWI and the uploaded GROWI data are not the same';
         const validationErr = 'versions-are-not-met';
         const validationErr = 'versions-are-not-met';
         return res.apiv3Err(new ErrorV3(msg, validationErr), 500);
         return res.apiv3Err(new ErrorV3(msg, validationErr), 500);
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -458,17 +511,22 @@ export default function route(crowi: Crowi): void {
    *        200:
    *        200:
    *          description: all files are deleted
    *          description: all files are deleted
    */
    */
-  router.delete('/all', accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
-    try {
-      importService.deleteAllZipFiles();
-
-      return res.apiv3();
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.delete(
+    '/all',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    async (req, res) => {
+      try {
+        importService.deleteAllZipFiles();
+
+        return res.apiv3();
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    },
+  );
 
 
   return router;
   return router;
 }
 }

+ 88 - 59
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -1,17 +1,15 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import express from 'express';
 import express from 'express';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 
 
 import type { IInAppNotification } from '../../../interfaces/in-app-notification';
 import type { IInAppNotification } from '../../../interfaces/in-app-notification';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
-
 const router = express.Router();
 const router = express.Router();
 
 
 /**
 /**
@@ -88,7 +86,9 @@ const router = express.Router();
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const addActivity = generateAddActivityMiddleware();
   const addActivity = generateAddActivityMiddleware();
 
 
   const inAppNotificationService = crowi.inAppNotificationService;
   const inAppNotificationService = crowi.inAppNotificationService;
@@ -97,7 +97,6 @@ module.exports = (crowi) => {
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
-
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -133,15 +132,21 @@ module.exports = (crowi) => {
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/InAppNotificationListResponse'
    *                $ref: '#/components/schemas/InAppNotificationListResponse'
    */
    */
-  router.get('/list', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly,
-    async(req: CrowiRequest, res: ApiV3Response) => {
-    // user must be set by loginRequiredStrictly
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  router.get(
+    '/list',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: CrowiRequest, res: ApiV3Response) => {
+      // user must be set by loginRequiredStrictly
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       const user = req.user!;
       const user = req.user!;
 
 
-      const limit = req.query.limit != null
-        ? parseInt(req.query.limit.toString()) || 10
-        : 10;
+      const limit =
+        req.query.limit != null
+          ? parseInt(req.query.limit.toString()) || 10
+          : 10;
 
 
       let offset = 0;
       let offset = 0;
       if (req.query.offset != null) {
       if (req.query.offset != null) {
@@ -158,37 +163,42 @@ module.exports = (crowi) => {
         Object.assign(queryOptions, { status: req.query.status });
         Object.assign(queryOptions, { status: req.query.status });
       }
       }
 
 
-      const paginationResult = await inAppNotificationService.getLatestNotificationsByUser(user._id, queryOptions);
-
-
-      const getActionUsersFromActivities = function(activities) {
-        return activities.map(({ user }) => user).filter((user, i, self) => self.indexOf(user) === i);
-      };
-
-      const serializedDocs: Array<IInAppNotification> = paginationResult.docs.map((doc) => {
-        if (doc.user != null && doc.user instanceof User) {
-          doc.user = serializeUserSecurely(doc.user);
-        }
-        // To add a new property into mongoose doc, need to change the format of doc to an object
-        const docObj: IInAppNotification = doc.toObject();
-        const actionUsersNew = getActionUsersFromActivities(doc.activities);
-
-        const serializedActionUsers = actionUsersNew.map((actionUser) => {
-          return serializeUserSecurely(actionUser);
+      const paginationResult =
+        await inAppNotificationService.getLatestNotificationsByUser(
+          user._id,
+          queryOptions,
+        );
+
+      const getActionUsersFromActivities = (activities) =>
+        activities
+          .map(({ user }) => user)
+          .filter((user, i, self) => self.indexOf(user) === i);
+
+      const serializedDocs: Array<IInAppNotification> =
+        paginationResult.docs.map((doc) => {
+          if (doc.user != null && doc.user instanceof User) {
+            doc.user = serializeUserSecurely(doc.user);
+          }
+          // To add a new property into mongoose doc, need to change the format of doc to an object
+          const docObj: IInAppNotification = doc.toObject();
+          const actionUsersNew = getActionUsersFromActivities(doc.activities);
+
+          const serializedActionUsers = actionUsersNew.map((actionUser) => {
+            return serializeUserSecurely(actionUser);
+          });
+
+          docObj.actionUsers = serializedActionUsers;
+          return docObj;
         });
         });
 
 
-        docObj.actionUsers = serializedActionUsers;
-        return docObj;
-      });
-
       const serializedPaginationResult = {
       const serializedPaginationResult = {
         ...paginationResult,
         ...paginationResult,
         docs: serializedDocs,
         docs: serializedDocs,
       };
       };
 
 
       return res.apiv3(serializedPaginationResult);
       return res.apiv3(serializedPaginationResult);
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -212,20 +222,27 @@ module.exports = (crowi) => {
    *                    type: integer
    *                    type: integer
    *                    description: Count of unread notifications
    *                    description: Count of unread notifications
    */
    */
-  router.get('/status', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly,
-    async(req: CrowiRequest, res: ApiV3Response) => {
-    // user must be set by loginRequiredStrictly
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  router.get(
+    '/status',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: CrowiRequest, res: ApiV3Response) => {
+      // user must be set by loginRequiredStrictly
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       const user = req.user!;
       const user = req.user!;
 
 
       try {
       try {
-        const count = await inAppNotificationService.getUnreadCountByUser(user._id);
+        const count = await inAppNotificationService.getUnreadCountByUser(
+          user._id,
+        );
         return res.apiv3({ count });
         return res.apiv3({ count });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
         return res.apiv3Err(err);
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -256,10 +273,15 @@ module.exports = (crowi) => {
    *              schema:
    *              schema:
    *                type: object
    *                type: object
    */
    */
-  router.post('/open', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly,
-    async(req: CrowiRequest, res: ApiV3Response) => {
-    // user must be set by loginRequiredStrictly
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  router.post(
+    '/open',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: CrowiRequest, res: ApiV3Response) => {
+      // user must be set by loginRequiredStrictly
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       const user = req.user!;
       const user = req.user!;
 
 
       const id = req.body.id;
       const id = req.body.id;
@@ -268,11 +290,11 @@ module.exports = (crowi) => {
         const notification = await inAppNotificationService.open(user, id);
         const notification = await inAppNotificationService.open(user, id);
         const result = { notification };
         const result = { notification };
         return res.apiv3(result);
         return res.apiv3(result);
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
         return res.apiv3Err(err);
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -289,24 +311,31 @@ module.exports = (crowi) => {
    *        200:
    *        200:
    *          description: All notifications opened successfully
    *          description: All notifications opened successfully
    */
    */
-  router.put('/all-statuses-open',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly, addActivity,
-    async(req: CrowiRequest, res: ApiV3Response) => {
-    // user must be set by loginRequiredStrictly
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  router.put(
+    '/all-statuses-open',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    addActivity,
+    async (req: CrowiRequest, res: ApiV3Response) => {
+      // user must be set by loginRequiredStrictly
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       const user = req.user!;
       const user = req.user!;
 
 
       try {
       try {
         await inAppNotificationService.updateAllNotificationsAsOpened(user);
         await inAppNotificationService.updateAllNotificationsAsOpened(user);
 
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN,
+        });
 
 
         return res.apiv3();
         return res.apiv3();
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
         return res.apiv3Err(err);
       }
       }
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 68 - 49
apps/app/src/server/routes/apiv3/installer.ts

@@ -1,3 +1,4 @@
+import type { IUser } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, Router } from 'express';
 import type { Request, Router } from 'express';
 import express from 'express';
 import express from 'express';
@@ -9,17 +10,20 @@ import loggerFactory from '~/utils/logger';
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import * as applicationNotInstalled from '../../middlewares/application-not-installed';
 import * as applicationNotInstalled from '../../middlewares/application-not-installed';
-import { registerRules, registerValidation } from '../../middlewares/register-form-validator';
-import { InstallerService, FailedToCreateAdminUserError } from '../../service/installer';
-
+import {
+  registerRules,
+  registerValidation,
+} from '../../middlewares/register-form-validator';
+import {
+  FailedToCreateAdminUserError,
+  InstallerService,
+} from '../../service/installer';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
-
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:installer');
 const logger = loggerFactory('growi:routes:apiv3:installer');
 
 
-
-type FormRequest = Request & { form: any, logIn: any };
+type FormRequest = Request & { form: any; logIn: any };
 
 
 module.exports = (crowi: Crowi): Router => {
 module.exports = (crowi: Crowi): Router => {
   const addActivity = generateAddActivityMiddleware();
   const addActivity = generateAddActivityMiddleware();
@@ -78,53 +82,68 @@ module.exports = (crowi: Crowi): Router => {
    *                    example: Installation completed (Logged in as an admin user)
    *                    example: Installation completed (Logged in as an admin user)
    */
    */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.post('/', registerRules(minPasswordLength), registerValidation, addActivity, async(req: FormRequest, res: ApiV3Response) => {
-
-    if (!req.form.isValid) {
-      const errors = req.form.errors;
-      return res.apiv3Err(errors, 400);
-    }
-
-    const registerForm = req.body.registerForm || {};
-
-    const name = registerForm.name;
-    const username = registerForm.username;
-    const email = registerForm.email;
-    const password = registerForm.password;
-    const language = registerForm['app:globalLang'] || 'en_US';
-
-    const installerService = new InstallerService(crowi);
-
-    let adminUser;
-    try {
-      adminUser = await installerService.install({
-        name,
-        username,
-        email,
-        password,
-      }, language);
-    }
-    catch (err) {
-      if (err instanceof FailedToCreateAdminUserError) {
-        return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_admin_user'));
+  router.post(
+    '/',
+    registerRules(minPasswordLength),
+    registerValidation,
+    addActivity,
+    async (req: FormRequest, res: ApiV3Response) => {
+      if (!req.form.isValid) {
+        const errors = req.form.errors;
+        return res.apiv3Err(errors, 400);
       }
       }
-      return res.apiv3Err(new ErrorV3(err, 'failed_to_install'));
-    }
 
 
-    await crowi.appService.setupAfterInstall();
-
-    const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
-
-    // login with passport
-    req.logIn(adminUser, (err) => {
-      if (err != null) {
-        return res.apiv3Err(new ErrorV3(err, 'failed_to_login_after_install'));
+      const registerForm = req.body.registerForm || {};
+
+      const name = registerForm.name;
+      const username = registerForm.username;
+      const email = registerForm.email;
+      const password = registerForm.password;
+      const language = registerForm['app:globalLang'] || 'en_US';
+
+      const installerService = new InstallerService(crowi);
+
+      let adminUser: IUser;
+      try {
+        adminUser = await installerService.install(
+          {
+            name,
+            username,
+            email,
+            password,
+          },
+          language,
+        );
+      } catch (err) {
+        if (err instanceof FailedToCreateAdminUserError) {
+          return res.apiv3Err(
+            new ErrorV3(err.message, 'failed_to_create_admin_user'),
+          );
+        }
+        return res.apiv3Err(new ErrorV3(err, 'failed_to_install'));
       }
       }
 
 
-      return res.apiv3({ message: 'Installation completed (Logged in as an admin user)' });
-    });
-  });
+      await crowi.appService.setupAfterInstall();
+
+      const parameters = {
+        action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
+      // login with passport
+      req.logIn(adminUser, (err) => {
+        if (err != null) {
+          return res.apiv3Err(
+            new ErrorV3(err, 'failed_to_login_after_install'),
+          );
+        }
+
+        return res.apiv3({
+          message: 'Installation completed (Logged in as an admin user)',
+        });
+      });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 2 - 2
apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts

@@ -1,6 +1,6 @@
 import type { Response } from 'express';
 import type { Response } from 'express';
 
 
 export interface ApiV3Response extends Response {
 export interface ApiV3Response extends Response {
-  apiv3(obj?: any, status?: number): any
-  apiv3Err(_err: any, status?: number, info?: any): any
+  apiv3(obj?: any, status?: number): any;
+  apiv3Err(_err: any, status?: number, info?: any): any;
 }
 }

+ 48 - 36
apps/app/src/server/routes/apiv3/invited.ts

@@ -6,16 +6,19 @@ import mongoose from 'mongoose';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
-import { invitedRules, invitedValidation } from '../../middlewares/invited-form-validator';
-
+import {
+  invitedRules,
+  invitedValidation,
+} from '../../middlewares/invited-form-validator';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
 const logger = loggerFactory('growi:routes:login');
 const logger = loggerFactory('growi:routes:login');
 
 
-type InvitedFormRequest = Request & { form: any, user: any };
+type InvitedFormRequest = Request & { form: any; user: any };
 
 
 module.exports = (crowi: Crowi): Router => {
 module.exports = (crowi: Crowi): Router => {
-  const applicationInstalled = require('../../middlewares/application-installed')(crowi);
+  const applicationInstalled =
+    require('../../middlewares/application-installed')(crowi);
   const router = express.Router();
   const router = express.Router();
 
 
   /**
   /**
@@ -59,44 +62,53 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    type: string
    *                    description: URL to redirect after successful activation.
    *                    description: URL to redirect after successful activation.
    */
    */
-  router.post('/', applicationInstalled, invitedRules(), invitedValidation, async(req: InvitedFormRequest, res: ApiV3Response) => {
-    if (!req.user) {
-      return res.apiv3({ redirectTo: '/login' });
-    }
+  router.post(
+    '/',
+    applicationInstalled,
+    invitedRules(),
+    invitedValidation,
+    async (req: InvitedFormRequest, res: ApiV3Response) => {
+      if (!req.user) {
+        return res.apiv3({ redirectTo: '/login' });
+      }
 
 
-    if (!req.form.isValid) {
-      return res.apiv3Err(req.form.errors, 400);
-    }
+      if (!req.form.isValid) {
+        return res.apiv3Err(req.form.errors, 400);
+      }
 
 
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const User = mongoose.model<IUser, any>('User');
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const User = mongoose.model<IUser, any>('User');
 
 
-    const user = req.user;
-    const invitedForm = req.form.invitedForm || {};
-    const username = invitedForm.username;
-    const name = invitedForm.name;
-    const password = invitedForm.password;
+      const user = req.user;
+      const invitedForm = req.form.invitedForm || {};
+      const username = invitedForm.username;
+      const name = invitedForm.name;
+      const password = invitedForm.password;
 
 
-    // check user upper limit
-    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
-    if (isUserCountExceedsUpperLimit) {
-      return res.apiv3Err('message.can_not_activate_maximum_number_of_users', 403);
-    }
+      // check user upper limit
+      const isUserCountExceedsUpperLimit =
+        await User.isUserCountExceedsUpperLimit();
+      if (isUserCountExceedsUpperLimit) {
+        return res.apiv3Err(
+          'message.can_not_activate_maximum_number_of_users',
+          403,
+        );
+      }
 
 
-    const creatable = await User.isRegisterableUsername(username);
-    if (!creatable) {
-      logger.debug('username', username);
-      return res.apiv3Err('message.unable_to_use_this_user', 403);
-    }
+      const creatable = await User.isRegisterableUsername(username);
+      if (!creatable) {
+        logger.debug('username', username);
+        return res.apiv3Err('message.unable_to_use_this_user', 403);
+      }
 
 
-    try {
-      await user.activateInvitedUser(username, name, password);
-      return res.apiv3({ redirectTo: '/' });
-    }
-    catch (err) {
-      return res.apiv3Err('message.failed_to_activate', 403);
-    }
-  });
+      try {
+        await user.activateInvitedUser(username, name, password);
+        return res.apiv3({ redirectTo: '/' });
+      } catch (err) {
+        return res.apiv3Err('message.failed_to_activate', 403);
+      }
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 114 - 52
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,12 +1,10 @@
-import type {
-  IPageInfoForListing, IPageInfo, IUserHasId,
-} from '@growi/core';
+import type { IPageInfo, IPageInfoForListing, IUserHasId } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, Router } from 'express';
 import type { Request, Router } from 'express';
 import express from 'express';
 import express from 'express';
-import { query, oneOf } from 'express-validator';
+import { oneOf, query } from 'express-validator';
 import type { HydratedDocument } from 'mongoose';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
@@ -20,39 +18,37 @@ import loggerFactory from '~/utils/logger';
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import type { PageDocument, PageModel } from '../../models/page';
 import type { PageDocument, PageModel } from '../../models/page';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
 
 /*
 /*
  * Types & Interfaces
  * Types & Interfaces
  */
  */
 interface AuthorizedRequest extends Request {
 interface AuthorizedRequest extends Request {
-  user?: IUserHasId,
+  user?: IUserHasId;
 }
 }
 
 
 /*
 /*
  * Validators
  * Validators
  */
  */
 const validator = {
 const validator = {
-  pagePathRequired: [
-    query('path').isString().withMessage('path is required'),
-  ],
-  pageIdOrPathRequired: oneOf([
-    query('id').isMongoId(),
-    query('path').isString(),
-  ], 'id or path is required'),
+  pagePathRequired: [query('path').isString().withMessage('path is required')],
+  pageIdOrPathRequired: oneOf(
+    [query('id').isMongoId(), query('path').isString()],
+    'id or path is required',
+  ),
   pageIdsOrPathRequired: [
   pageIdsOrPathRequired: [
     // type check independent of existence check
     // type check independent of existence check
-    query('pageIds').isArray().optional(),
+    query('pageIds')
+      .isArray()
+      .optional(),
     query('path').isString().optional(),
     query('path').isString().optional(),
     // existence check
     // existence check
-    oneOf([
-      query('pageIds').exists(),
-      query('path').exists(),
-    ], 'pageIds or path is required'),
+    oneOf(
+      [query('pageIds').exists(), query('path').exists()],
+      'pageIds or path is required',
+    ),
   ],
   ],
   infoParams: [
   infoParams: [
     query('attachBookmarkCount').isBoolean().optional(),
     query('attachBookmarkCount').isBoolean().optional(),
@@ -64,11 +60,13 @@ const validator = {
  * Routes
  * Routes
  */
  */
 const routerFactory = (crowi: Crowi): Router => {
 const routerFactory = (crowi: Crowi): Router => {
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequired = require('../../middlewares/login-required')(
+    crowi,
+    true,
+  );
 
 
   const router = express.Router();
   const router = express.Router();
 
 
-
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -91,16 +89,20 @@ const routerFactory = (crowi: Crowi): Router => {
    *                 rootPage:
    *                 rootPage:
    *                   $ref: '#/components/schemas/PageForTreeItem'
    *                   $ref: '#/components/schemas/PageForTreeItem'
    */
    */
-  router.get('/root',
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get(
+    '/root',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       try {
       try {
-        const rootPage: IPageForTreeItem = await pageListingService.findRootByViewer(req.user);
+        const rootPage: IPageForTreeItem =
+          await pageListingService.findRootByViewer(req.user);
         return res.apiv3({ rootPage });
         return res.apiv3({ rootPage });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(new ErrorV3('rootPage not found'));
         return res.apiv3Err(new ErrorV3('rootPage not found'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -138,25 +140,39 @@ const routerFactory = (crowi: Crowi): Router => {
   /*
   /*
    * In most cases, using id should be prioritized
    * In most cases, using id should be prioritized
    */
    */
-  router.get('/children',
+  router.get(
+    '/children',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    loginRequired,
+    validator.pageIdOrPathRequired,
+    apiV3FormValidator,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const { id, path } = req.query;
       const { id, path } = req.query;
 
 
-      const hideRestrictedByOwner = await configManager.getConfig('security:list-policy:hideRestrictedByOwner');
-      const hideRestrictedByGroup = await configManager.getConfig('security:list-policy:hideRestrictedByGroup');
+      const hideRestrictedByOwner = await configManager.getConfig(
+        'security:list-policy:hideRestrictedByOwner',
+      );
+      const hideRestrictedByGroup = await configManager.getConfig(
+        'security:list-policy:hideRestrictedByGroup',
+      );
 
 
       try {
       try {
-        const pages = await pageListingService.findChildrenByParentPathOrIdAndViewer(
-          (id || path) as string, req.user, !hideRestrictedByOwner, !hideRestrictedByGroup,
-        );
+        const pages =
+          await pageListingService.findChildrenByParentPathOrIdAndViewer(
+            (id || path) as string,
+            req.user,
+            !hideRestrictedByOwner,
+            !hideRestrictedByGroup,
+          );
         return res.apiv3({ children: pages });
         return res.apiv3({ children: pages });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Error occurred while finding children.', err);
         logger.error('Error occurred while finding children.', err);
-        return res.apiv3Err(new ErrorV3('Error occurred while finding children.'));
+        return res.apiv3Err(
+          new ErrorV3('Error occurred while finding children.'),
+        );
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -186,21 +202,65 @@ const routerFactory = (crowi: Crowi): Router => {
    *                 item:
    *                 item:
    *                   $ref: '#/components/schemas/PageForTreeItem'
    *                   $ref: '#/components/schemas/PageForTreeItem'
    */
    */
-  router.get('/item',
+  router.get(
+    '/item',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
-      const { id } = req.query;
+    validator.pageIdsOrPathRequired,
+    validator.infoParams,
+    apiV3FormValidator,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
+      const {
+        pageIds,
+        path,
+        attachBookmarkCount: attachBookmarkCountParam,
+        attachShortBody: attachShortBodyParam,
+      } = req.query;
 
 
-      if (id == null) {
-        return res.apiv3Err(new ErrorV3('id parameter is required'));
-      }
+      const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
+      const attachShortBody: boolean = attachShortBodyParam === 'true';
+
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+        'Page',
+      );
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const Bookmark = mongoose.model<any, any>('Bookmark');
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const pageService = crowi.pageService;
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const pageGrantService: IPageGrantService = crowi.pageGrantService!;
 
 
       try {
       try {
-        const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
-        const page = await Page.findByIdAndViewer(id as string, req.user, null, true);
+        const pages =
+          pageIds != null
+            ? await Page.findByIdsAndViewer(
+                pageIds as string[],
+                req.user,
+                null,
+                true,
+              )
+            : await Page.findByPathAndViewer(
+                path as string,
+                req.user,
+                null,
+                false,
+                true,
+              );
+
+        const foundIds = pages.map((page) => page._id);
 
 
-        if (page == null) {
-          return res.apiv3Err(new ErrorV3('Page not found'), 404);
+        let shortBodiesMap;
+        if (attachShortBody) {
+          shortBodiesMap = await pageService.shortBodiesMapByPageIds(
+            foundIds,
+            req.user,
+          );
+        }
+
+        let bookmarkCountMap;
+        if (attachBookmarkCount) {
+          bookmarkCountMap = (await Bookmark.getPageIdToCountMap(
+            foundIds,
+          )) as Record<string, number>;
         }
         }
 
 
         const item: IPageForTreeItem = {
         const item: IPageForTreeItem = {
@@ -215,12 +275,14 @@ const routerFactory = (crowi: Crowi): Router => {
         };
         };
 
 
         return res.apiv3({ item });
         return res.apiv3({ item });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Error occurred while fetching page item.', err);
         logger.error('Error occurred while fetching page item.', err);
-        return res.apiv3Err(new ErrorV3('Error occurred while fetching page item.'));
+        return res.apiv3Err(
+          new ErrorV3('Error occurred while fetching page item.'),
+        );
       }
       }
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

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


+ 44 - 32
apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts

@@ -1,9 +1,9 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { Request, RequestHandler } from 'express';
 import { query } from 'express-validator';
 import { query } from 'express-validator';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -14,13 +14,20 @@ import loggerFactory from '~/utils/logger';
 
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
 
-const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+const logger = loggerFactory(
+  'growi:routes:apiv3:personal-setting:generate-access-tokens',
+);
 
 
 type ReqQuery = {
 type ReqQuery = {
-  tokenId: string,
-}
+  tokenId: string;
+};
 
 
-type DeleteAccessTokenRequest = Request<undefined, ApiV3Response, undefined, ReqQuery>;
+type DeleteAccessTokenRequest = Request<
+  undefined,
+  ApiV3Response,
+  undefined,
+  ReqQuery
+>;
 
 
 type DeleteAccessTokenHandlersFactory = (crowi: Crowi) => RequestHandler[];
 type DeleteAccessTokenHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
 
@@ -32,34 +39,39 @@ const validator = [
     .withMessage('tokenId must be a string'),
     .withMessage('tokenId must be a string'),
 ];
 ];
 
 
-export const deleteAccessTokenHandlersFactory: DeleteAccessTokenHandlersFactory = (crowi) => {
+export const deleteAccessTokenHandlersFactory: DeleteAccessTokenHandlersFactory =
+  (crowi) => {
+    const loginRequiredStrictly =
+      require('../../../middlewares/login-required')(crowi);
+    const addActivity = generateAddActivityMiddleware();
+    const activityEvent = crowi.event('activity');
 
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
-  const addActivity = generateAddActivityMiddleware();
-  const activityEvent = crowi.event('activity');
+    return [
+      accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+      loginRequiredStrictly,
+      excludeReadOnlyUser,
+      addActivity,
+      validator,
+      apiV3FormValidator,
+      async (req: DeleteAccessTokenRequest, res: ApiV3Response) => {
+        const { query } = req;
+        const { tokenId } = query;
 
 
-  return [
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
-    loginRequiredStrictly,
-    excludeReadOnlyUser,
-    addActivity,
-    validator,
-    apiV3FormValidator,
-    async(req: DeleteAccessTokenRequest, res: ApiV3Response) => {
-      const { query } = req;
-      const { tokenId } = query;
+        try {
+          await AccessToken.deleteTokenById(tokenId);
 
 
-      try {
-        await AccessToken.deleteTokenById(tokenId);
+          const parameters = {
+            action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE,
+          };
+          activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
-        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE };
-        activityEvent.emit('update', res.locals.activity._id, parameters);
-
-        return res.apiv3({});
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(new ErrorV3(err.toString(), 'delete-access-token-failed'));
-      }
-    }];
-};
+          return res.apiv3({});
+        } catch (err) {
+          logger.error(err);
+          return res.apiv3Err(
+            new ErrorV3(err.toString(), 'delete-access-token-failed'),
+          );
+        }
+      },
+    ];
+  };

+ 40 - 32
apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts

@@ -1,9 +1,9 @@
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { Request, RequestHandler } from 'express';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -13,39 +13,47 @@ import loggerFactory from '~/utils/logger';
 
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
 
-const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+const logger = loggerFactory(
+  'growi:routes:apiv3:personal-setting:generate-access-tokens',
+);
 
 
-interface DeleteAllAccessTokensRequest extends Request<undefined, ApiV3Response, undefined> {
-  user: IUserHasId,
+interface DeleteAllAccessTokensRequest
+  extends Request<undefined, ApiV3Response, undefined> {
+  user: IUserHasId;
 }
 }
 
 
 type DeleteAllAccessTokensHandlersFactory = (crowi: Crowi) => RequestHandler[];
 type DeleteAllAccessTokensHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
 
-export const deleteAllAccessTokensHandlersFactory: DeleteAllAccessTokensHandlersFactory = (crowi) => {
-
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
-  const addActivity = generateAddActivityMiddleware();
-  const activityEvent = crowi.event('activity');
-
-  return [
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
-    loginRequiredStrictly,
-    excludeReadOnlyUser,
-    addActivity,
-    async(req: DeleteAllAccessTokensRequest, res: ApiV3Response) => {
-      const { user } = req;
-
-      try {
-        await AccessToken.deleteAllTokensByUserId(user._id);
-
-        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE };
-        activityEvent.emit('update', res.locals.activity._id, parameters);
-
-        return res.apiv3({});
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(new ErrorV3(err.toString(), 'delete-all-access-token-failed'));
-      }
-    }];
-};
+export const deleteAllAccessTokensHandlersFactory: DeleteAllAccessTokensHandlersFactory =
+  (crowi) => {
+    const loginRequiredStrictly =
+      require('../../../middlewares/login-required')(crowi);
+    const addActivity = generateAddActivityMiddleware();
+    const activityEvent = crowi.event('activity');
+
+    return [
+      accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+      loginRequiredStrictly,
+      excludeReadOnlyUser,
+      addActivity,
+      async (req: DeleteAllAccessTokensRequest, res: ApiV3Response) => {
+        const { user } = req;
+
+        try {
+          await AccessToken.deleteAllTokensByUserId(user._id);
+
+          const parameters = {
+            action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE,
+          };
+          activityEvent.emit('update', res.locals.activity._id, parameters);
+
+          return res.apiv3({});
+        } catch (err) {
+          logger.error(err);
+          return res.apiv3Err(
+            new ErrorV3(err.toString(), 'delete-all-access-token-failed'),
+          );
+        }
+      },
+    ];
+  };

+ 51 - 42
apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts

@@ -1,6 +1,4 @@
-import type {
-  IUserHasId, Scope,
-} from '@growi/core/dist/interfaces';
+import type { IUserHasId, Scope } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { Request, RequestHandler } from 'express';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
@@ -16,16 +14,19 @@ import loggerFactory from '~/utils/logger';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
 
-const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+const logger = loggerFactory(
+  'growi:routes:apiv3:personal-setting:generate-access-tokens',
+);
 
 
 type ReqBody = {
 type ReqBody = {
-  expiredAt: Date,
-  description?: string,
-  scopes?: Scope[],
-}
+  expiredAt: Date;
+  description?: string;
+  scopes?: Scope[];
+};
 
 
-interface GenerateAccessTokenRequest extends Request<undefined, ApiV3Response, ReqBody> {
-  user: IUserHasId,
+interface GenerateAccessTokenRequest
+  extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId;
 }
 }
 
 
 type GenerateAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
 type GenerateAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
@@ -73,35 +74,43 @@ const validator = [
     .withMessage('Invalid scope'),
     .withMessage('Invalid scope'),
 ];
 ];
 
 
-export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactory = (crowi) => {
-
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
-  const activityEvent = crowi.event('activity');
-  const addActivity = generateAddActivityMiddleware();
-
-  return [
-    loginRequiredStrictly,
-    excludeReadOnlyUser,
-    addActivity,
-    validator,
-    apiV3FormValidator,
-    async(req: GenerateAccessTokenRequest, res: ApiV3Response) => {
-
-      const { user, body } = req;
-      const { expiredAt, description, scopes } = body;
-
-      try {
-        const tokenData = await AccessToken.generateToken(user._id, expiredAt, scopes, description);
-
-        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE };
-        activityEvent.emit('update', res.locals.activity._id, parameters);
-
-        return res.apiv3(tokenData);
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(new ErrorV3(err.toString(), 'generate-access-token-failed'));
-      }
-    },
-  ];
-};
+export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactory =
+  (crowi) => {
+    const loginRequiredStrictly =
+      require('../../../middlewares/login-required')(crowi);
+    const activityEvent = crowi.event('activity');
+    const addActivity = generateAddActivityMiddleware();
+
+    return [
+      loginRequiredStrictly,
+      excludeReadOnlyUser,
+      addActivity,
+      validator,
+      apiV3FormValidator,
+      async (req: GenerateAccessTokenRequest, res: ApiV3Response) => {
+        const { user, body } = req;
+        const { expiredAt, description, scopes } = body;
+
+        try {
+          const tokenData = await AccessToken.generateToken(
+            user._id,
+            expiredAt,
+            scopes,
+            description,
+          );
+
+          const parameters = {
+            action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE,
+          };
+          activityEvent.emit('update', res.locals.activity._id, parameters);
+
+          return res.apiv3(tokenData);
+        } catch (err) {
+          logger.error(err);
+          return res.apiv3Err(
+            new ErrorV3(err.toString(), 'generate-access-token-failed'),
+          );
+        }
+      },
+    ];
+  };

+ 18 - 11
apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts

@@ -1,8 +1,8 @@
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { Request, RequestHandler } from 'express';
 
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -12,17 +12,23 @@ import loggerFactory from '~/utils/logger';
 
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
 
-const logger = loggerFactory('growi:routes:apiv3:personal-setting:get-access-tokens');
+const logger = loggerFactory(
+  'growi:routes:apiv3:personal-setting:get-access-tokens',
+);
 
 
-interface GetAccessTokenRequest extends Request<undefined, ApiV3Response, undefined> {
-  user: IUserHasId,
+interface GetAccessTokenRequest
+  extends Request<undefined, ApiV3Response, undefined> {
+  user: IUserHasId;
 }
 }
 
 
 type GetAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
 type GetAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
 
 
-export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (crowi) => {
-
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (
+  crowi,
+) => {
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
   const addActivity = generateAddActivityMiddleware();
   const addActivity = generateAddActivityMiddleware();
 
 
   return [
   return [
@@ -30,16 +36,17 @@ export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (crowi
     loginRequiredStrictly,
     loginRequiredStrictly,
     excludeReadOnlyUser,
     excludeReadOnlyUser,
     addActivity,
     addActivity,
-    async(req: GetAccessTokenRequest, res: ApiV3Response) => {
+    async (req: GetAccessTokenRequest, res: ApiV3Response) => {
       const { user } = req;
       const { user } = req;
 
 
       try {
       try {
         const accessTokens = await AccessToken.findTokenByUserId(user._id);
         const accessTokens = await AccessToken.findTokenByUserId(user._id);
         return res.apiv3({ accessTokens });
         return res.apiv3({ accessTokens });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
-        return res.apiv3Err(new ErrorV3(err.toString(), 'colud_not_get_access_token'));
+        return res.apiv3Err(
+          new ErrorV3(err.toString(), 'colud_not_get_access_token'),
+        );
       }
       }
     },
     },
   ];
   ];

+ 277 - 157
apps/app/src/server/routes/apiv3/personal-setting/index.js

@@ -2,7 +2,6 @@ import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 
 
-
 import { i18n } from '^/config/next-i18next.config';
 import { i18n } from '^/config/next-i18next.config';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
@@ -14,13 +13,11 @@ import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import EditorSettings from '../../../models/editor-settings';
 import EditorSettings from '../../../models/editor-settings';
 import ExternalAccount from '../../../models/external-account';
 import ExternalAccount from '../../../models/external-account';
 import InAppNotificationSettings from '../../../models/in-app-notification-settings';
 import InAppNotificationSettings from '../../../models/in-app-notification-settings';
-
 import { deleteAccessTokenHandlersFactory } from './delete-access-token';
 import { deleteAccessTokenHandlersFactory } from './delete-access-token';
 import { deleteAllAccessTokensHandlersFactory } from './delete-all-access-tokens';
 import { deleteAllAccessTokensHandlersFactory } from './delete-all-access-tokens';
 import { generateAccessTokenHandlerFactory } from './generate-access-token';
 import { generateAccessTokenHandlerFactory } from './generate-access-token';
 import { getAccessTokenHandlerFactory } from './get-access-tokens';
 import { getAccessTokenHandlerFactory } from './get-access-tokens';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
 
 
 const express = require('express');
 const express = require('express');
@@ -76,14 +73,18 @@ const router = express.Router();
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const { User } = crowi.models;
   const { User } = crowi.models;
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
-  const minPasswordLength = crowi.configManager.getConfig('app:minPasswordLength');
+  const minPasswordLength = crowi.configManager.getConfig(
+    'app:minPasswordLength',
+  );
 
 
   const validator = {
   const validator = {
     personal: [
     personal: [
@@ -91,24 +92,31 @@ module.exports = (crowi) => {
       body('email')
       body('email')
         .isEmail()
         .isEmail()
         .custom((email) => {
         .custom((email) => {
-          if (!User.isEmailValid(email)) throw new Error('email is not included in whitelist');
+          if (!User.isEmailValid(email))
+            throw new Error('email is not included in whitelist');
           return true;
           return true;
         }),
         }),
       body('lang').isString().isIn(i18n.locales),
       body('lang').isString().isIn(i18n.locales),
       body('isEmailPublished').isBoolean(),
       body('isEmailPublished').isBoolean(),
       body('slackMemberId').optional().isString(),
       body('slackMemberId').optional().isString(),
     ],
     ],
-    imageType: [
-      body('isGravatarEnabled').isBoolean(),
-    ],
+    imageType: [body('isGravatarEnabled').isBoolean()],
     password: [
     password: [
       body('oldPassword').isString(),
       body('oldPassword').isString(),
-      body('newPassword').isString().not().isEmpty()
+      body('newPassword')
+        .isString()
+        .not()
+        .isEmpty()
         .isLength({ min: minPasswordLength })
         .isLength({ min: minPasswordLength })
-        .withMessage(`password must be at least ${minPasswordLength} characters long`),
-      body('newPasswordConfirm').isString().not().isEmpty()
+        .withMessage(
+          `password must be at least ${minPasswordLength} characters long`,
+        ),
+      body('newPasswordConfirm')
+        .isString()
+        .not()
+        .isEmpty()
         .custom((value, { req }) => {
         .custom((value, { req }) => {
-          return (value === req.body.newPassword);
+          return value === req.body.newPassword;
         }),
         }),
     ],
     ],
     associateLdap: [
     associateLdap: [
@@ -150,24 +158,28 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: personal params
    *                      description: personal params
    */
    */
-  router.get('/', accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
-    const { username } = req.user;
-    try {
-      const user = await User.findUserByUsername(username);
-
-      // return email and apiToken
-      const { email, apiToken } = user;
-      const currentUser = user.toObject();
-      currentUser.email = email;
-      currentUser.apiToken = apiToken;
-
-      return res.apiv3({ currentUser });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-personal-settings-failed');
-    }
-  });
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    async (req, res) => {
+      const { username } = req.user;
+      try {
+        const user = await User.findUserByUsername(username);
+
+        // return email and apiToken
+        const { email, apiToken } = user;
+        const currentUser = user.toObject();
+        currentUser.email = email;
+        currentUser.apiToken = apiToken;
+
+        return res.apiv3({ currentUser });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-personal-settings-failed');
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -191,21 +203,28 @@ module.exports = (crowi) => {
    *                      type: number
    *                      type: number
    *                      description: Minimum password length
    *                      description: Minimum password length
    */
    */
-  router.get('/is-password-set', accessTokenParser([SCOPE.READ.USER_SETTINGS.PASSWORD], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
-    const { username } = req.user;
-
-    try {
-      const user = await User.findUserByUsername(username);
-      const isPasswordSet = user.isPasswordSet();
-      const minPasswordLength = crowi.configManager.getConfig('app:minPasswordLength');
-      return res.apiv3({ isPasswordSet, minPasswordLength });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('fail-to-get-whether-password-is-set');
-    }
-
-  });
+  router.get(
+    '/is-password-set',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.PASSWORD], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req, res) => {
+      const { username } = req.user;
+
+      try {
+        const user = await User.findUserByUsername(username);
+        const isPasswordSet = user.isPasswordSet();
+        const minPasswordLength = crowi.configManager.getConfig(
+          'app:minPasswordLength',
+        );
+        return res.apiv3({ isPasswordSet, minPasswordLength });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('fail-to-get-whether-password-is-set');
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -232,10 +251,14 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: personal params
    *                      description: personal params
    */
    */
-  router.put('/',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO], { acceptLegacy: true }), loginRequiredStrictly, addActivity, validator.personal, apiV3FormValidator,
-    async(req, res) => {
-
+  router.put(
+    '/',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    addActivity,
+    validator.personal,
+    apiV3FormValidator,
+    async (req, res) => {
       try {
       try {
         const user = await User.findOne({ _id: req.user.id });
         const user = await User.findOne({ _id: req.user.id });
         user.name = req.body.name;
         user.name = req.body.name;
@@ -248,22 +271,28 @@ module.exports = (crowi) => {
 
 
         if (!isUniqueEmail) {
         if (!isUniqueEmail) {
           logger.error('email-is-not-unique');
           logger.error('email-is-not-unique');
-          return res.apiv3Err(new ErrorV3('The email is already in use', 'email-is-already-in-use'));
+          return res.apiv3Err(
+            new ErrorV3(
+              'The email is already in use',
+              'email-is-already-in-use',
+            ),
+          );
         }
         }
 
 
         const updatedUser = await user.save();
         const updatedUser = await user.save();
 
 
-        const parameters = { action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ updatedUser });
         return res.apiv3({ updatedUser });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err('update-personal-settings-failed');
         return res.apiv3Err('update-personal-settings-failed');
       }
       }
-
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -293,24 +322,32 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: user data
    *                      description: user data
    */
    */
-  router.put('/image-type', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO], { acceptLegacy: true }), loginRequiredStrictly, addActivity,
-    validator.imageType, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/image-type',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    addActivity,
+    validator.imageType,
+    apiV3FormValidator,
+    async (req, res) => {
       const { isGravatarEnabled } = req.body;
       const { isGravatarEnabled } = req.body;
 
 
       try {
       try {
-        const userData = await req.user.updateIsGravatarEnabled(isGravatarEnabled);
+        const userData =
+          await req.user.updateIsGravatarEnabled(isGravatarEnabled);
 
 
-        const parameters = { action: SupportedAction.ACTION_USER_IMAGE_TYPE_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_USER_IMAGE_TYPE_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ userData });
         return res.apiv3({ userData });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err('update-personal-settings-failed');
         return res.apiv3Err('update-personal-settings-failed');
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -331,20 +368,24 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: array of external accounts
    *                      description: array of external accounts
    */
    */
-  router.get('/external-accounts',
-    accessTokenParser([SCOPE.READ.USER_SETTINGS.EXTERNAL_ACCOUNT], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
+  router.get(
+    '/external-accounts',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.EXTERNAL_ACCOUNT], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req, res) => {
       const userData = req.user;
       const userData = req.user;
 
 
       try {
       try {
         const externalAccounts = await ExternalAccount.find({ user: userData });
         const externalAccounts = await ExternalAccount.find({ user: userData });
         return res.apiv3({ externalAccounts });
         return res.apiv3({ externalAccounts });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err('get-external-accounts-failed');
         return res.apiv3Err('get-external-accounts-failed');
       }
       }
-
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -376,9 +417,16 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: user data updated
    *                      description: user data updated
    */
    */
-  router.put('/password',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.PASSWORD], { acceptLegacy: true }), loginRequiredStrictly, addActivity, validator.password, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/password',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.PASSWORD], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    addActivity,
+    validator.password,
+    apiV3FormValidator,
+    async (req, res) => {
       const { body, user } = req;
       const { body, user } = req;
       const { oldPassword, newPassword } = body;
       const { oldPassword, newPassword } = body;
 
 
@@ -388,17 +436,18 @@ module.exports = (crowi) => {
       try {
       try {
         const userData = await user.updatePassword(newPassword);
         const userData = await user.updatePassword(newPassword);
 
 
-        const parameters = { action: SupportedAction.ACTION_USER_PASSWORD_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_USER_PASSWORD_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ userData });
         return res.apiv3({ userData });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err('update-password-failed');
         return res.apiv3Err('update-password-failed');
       }
       }
-
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -421,23 +470,29 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: user data
    *                      description: user data
    */
    */
-  router.put('/api-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.API_TOKEN]), loginRequiredStrictly, addActivity, async(req, res) => {
-    const { user } = req;
-
-    try {
-      const userData = await user.updateApiToken();
+  router.put(
+    '/api-token',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.API_TOKEN]),
+    loginRequiredStrictly,
+    addActivity,
+    async (req, res) => {
+      const { user } = req;
 
 
-      const parameters = { action: SupportedAction.ACTION_USER_API_TOKEN_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      try {
+        const userData = await user.updateApiToken();
 
 
-      return res.apiv3({ userData });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-api-token-failed');
-    }
+        const parameters = {
+          action: SupportedAction.ACTION_USER_API_TOKEN_UPDATE,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
-  });
+        return res.apiv3({ userData });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-api-token-failed');
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -458,7 +513,11 @@ module.exports = (crowi) => {
    *                     type: object
    *                     type: object
    *                     description: array of access tokens
    *                     description: array of access tokens
    */
    */
-  router.get('/access-token', accessTokenParser([SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN]), getAccessTokenHandlerFactory(crowi));
+  router.get(
+    '/access-token',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN]),
+    getAccessTokenHandlerFactory(crowi),
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -493,7 +552,11 @@ module.exports = (crowi) => {
    *                     items:
    *                     items:
    *                      type: string
    *                      type: string
    */
    */
-  router.post('/access-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), generateAccessTokenHandlerFactory(crowi));
+  router.post(
+    '/access-token',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+    generateAccessTokenHandlerFactory(crowi),
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -508,7 +571,11 @@ module.exports = (crowi) => {
    *           description: succeded to delete access token
    *           description: succeded to delete access token
    *
    *
    */
    */
-  router.delete('/access-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), deleteAccessTokenHandlersFactory(crowi));
+  router.delete(
+    '/access-token',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+    deleteAccessTokenHandlersFactory(crowi),
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -522,7 +589,11 @@ module.exports = (crowi) => {
    *         200:
    *         200:
    *           description: succeded to delete all access tokens
    *           description: succeded to delete all access tokens
    */
    */
-  router.delete('/access-token/all', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), deleteAllAccessTokensHandlersFactory(crowi));
+  router.delete(
+    '/access-token/all',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+    deleteAllAccessTokensHandlersFactory(crowi),
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -552,9 +623,14 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: Ldap account associate to me
    *                      description: Ldap account associate to me
    */
    */
-  router.put('/associate-ldap', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]), loginRequiredStrictly, addActivity,
-    validator.associateLdap, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/associate-ldap',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]),
+    loginRequiredStrictly,
+    addActivity,
+    validator.associateLdap,
+    apiV3FormValidator,
+    async (req, res) => {
       const { passportService } = crowi;
       const { passportService } = crowi;
       const { user, body } = req;
       const { user, body } = req;
       const { username } = body;
       const { username } = body;
@@ -566,19 +642,24 @@ module.exports = (crowi) => {
 
 
       try {
       try {
         await passport.authenticate('ldapauth');
         await passport.authenticate('ldapauth');
-        const associateUser = await ExternalAccount.associate('ldap', username, user);
+        const associateUser = await ExternalAccount.associate(
+          'ldap',
+          username,
+          user,
+        );
 
 
-        const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_ASSOCIATE };
+        const parameters = {
+          action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ associateUser });
         return res.apiv3({ associateUser });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err('associate-ldap-account-failed');
         return res.apiv3Err('associate-ldap-account-failed');
       }
       }
-
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -605,9 +686,14 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: Ldap account disassociate to me
    *                      description: Ldap account disassociate to me
    */
    */
-  router.put('/disassociate-ldap',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]), loginRequiredStrictly, addActivity, validator.disassociateLdap, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/disassociate-ldap',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]),
+    loginRequiredStrictly,
+    addActivity,
+    validator.disassociateLdap,
+    apiV3FormValidator,
+    async (req, res) => {
       const { user, body } = req;
       const { user, body } = req;
       const { providerType, accountId } = body;
       const { providerType, accountId } = body;
 
 
@@ -623,17 +709,18 @@ module.exports = (crowi) => {
           user,
           user,
         });
         });
 
 
-        const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_DISCONNECT };
+        const parameters = {
+          action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ disassociateUser });
         return res.apiv3({ disassociateUser });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err('disassociate-ldap-account-failed');
         return res.apiv3Err('disassociate-ldap-account-failed');
       }
       }
-
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -666,37 +753,49 @@ module.exports = (crowi) => {
    *                  type: object
    *                  type: object
    *                  description: editor settings
    *                  description: editor settings
    */
    */
-  router.put('/editor-settings', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.OTHER]), loginRequiredStrictly,
-    addActivity, validator.editorSettings, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/editor-settings',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.OTHER]),
+    loginRequiredStrictly,
+    addActivity,
+    validator.editorSettings,
+    apiV3FormValidator,
+    async (req, res) => {
       const query = { userId: req.user.id };
       const query = { userId: req.user.id };
       const { body } = req;
       const { body } = req;
 
 
-      const {
-        theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
-      } = body;
+      const { theme, keymapMode, styleActiveLine, autoFormatMarkdownTable } =
+        body;
 
 
       const document = {
       const document = {
-        theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
+        theme,
+        keymapMode,
+        styleActiveLine,
+        autoFormatMarkdownTable,
       };
       };
 
 
       // Insert if document does not exist, and return new values
       // Insert if document does not exist, and return new values
       // See: https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
       // See: https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
       const options = { upsert: true, new: true };
       const options = { upsert: true, new: true };
       try {
       try {
-        const response = await EditorSettings.findOneAndUpdate(query, { $set: document }, options);
-
-        const parameters = { action: SupportedAction.ACTION_USER_EDITOR_SETTINGS_UPDATE };
+        const response = await EditorSettings.findOneAndUpdate(
+          query,
+          { $set: document },
+          options,
+        );
+
+        const parameters = {
+          action: SupportedAction.ACTION_USER_EDITOR_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3(response);
         return res.apiv3(response);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err('updating-editor-settings-failed');
         return res.apiv3Err('updating-editor-settings-failed');
       }
       }
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -715,17 +814,22 @@ module.exports = (crowi) => {
    *                  type: object
    *                  type: object
    *                  description: editor settings
    *                  description: editor settings
    */
    */
-  router.get('/editor-settings', accessTokenParser([SCOPE.READ.USER_SETTINGS.OTHER]), loginRequiredStrictly, async(req, res) => {
-    try {
-      const query = { userId: req.user.id };
-      const editorSettings = await EditorSettings.findOne(query) ?? new EditorSettings();
-      return res.apiv3(editorSettings);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('getting-editor-settings-failed');
-    }
-  });
+  router.get(
+    '/editor-settings',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.OTHER]),
+    loginRequiredStrictly,
+    async (req, res) => {
+      try {
+        const query = { userId: req.user.id };
+        const editorSettings =
+          (await EditorSettings.findOne(query)) ?? new EditorSettings();
+        return res.apiv3(editorSettings);
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('getting-editor-settings-failed');
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -758,9 +862,14 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                 type: object
    *                 type: object
    */
    */
-  router.put('/in-app-notification-settings',
+  router.put(
+    '/in-app-notification-settings',
     accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION]),
     accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION]),
-    loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
+    loginRequiredStrictly,
+    addActivity,
+    validator.inAppNotificationSettings,
+    apiV3FormValidator,
+    async (req, res) => {
       const query = { userId: req.user.id };
       const query = { userId: req.user.id };
       const subscribeRules = req.body.subscribeRules;
       const subscribeRules = req.body.subscribeRules;
 
 
@@ -770,18 +879,25 @@ module.exports = (crowi) => {
 
 
       const options = { upsert: true, new: true, runValidators: true };
       const options = { upsert: true, new: true, runValidators: true };
       try {
       try {
-        const response = await InAppNotificationSettings.findOneAndUpdate(query, { $set: { subscribeRules } }, options);
-
-        const parameters = { action: SupportedAction.ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE };
+        const response = await InAppNotificationSettings.findOneAndUpdate(
+          query,
+          { $set: { subscribeRules } },
+          options,
+        );
+
+        const parameters = {
+          action:
+            SupportedAction.ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3(response);
         return res.apiv3(response);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err('updating-in-app-notification-settings-failed');
         return res.apiv3Err('updating-in-app-notification-settings-failed');
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -802,17 +918,21 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: InAppNotificationSettings
    *                      description: InAppNotificationSettings
    */
    */
-  router.get('/in-app-notification-settings', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION]), loginRequiredStrictly, async(req, res) => {
-    const query = { userId: req.user.id };
-    try {
-      const response = await InAppNotificationSettings.findOne(query);
-      return res.apiv3(response);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('getting-in-app-notification-settings-failed');
-    }
-  });
+  router.get(
+    '/in-app-notification-settings',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION]),
+    loginRequiredStrictly,
+    async (req, res) => {
+      const query = { userId: req.user.id };
+      try {
+        const response = await InAppNotificationSettings.findOne(query);
+        return res.apiv3(response);
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('getting-in-app-notification-settings-failed');
+      }
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 13 - 5
apps/app/src/server/routes/apiv3/security-settings/checkSetupStrategiesHasAdmin.ts

@@ -6,7 +6,7 @@ interface AggregateResult {
   count: number;
   count: number;
 }
 }
 
 
-const checkLocalStrategyHasAdmin = async(): Promise<boolean> => {
+const checkLocalStrategyHasAdmin = async (): Promise<boolean> => {
   const User = mongoose.model('User') as any;
   const User = mongoose.model('User') as any;
 
 
   const localAdmins: AggregateResult[] = await User.aggregate([
   const localAdmins: AggregateResult[] = await User.aggregate([
@@ -23,7 +23,9 @@ const checkLocalStrategyHasAdmin = async(): Promise<boolean> => {
   return localAdmins.length > 0 && localAdmins[0].count > 0;
   return localAdmins.length > 0 && localAdmins[0].count > 0;
 };
 };
 
 
-const checkExternalStrategiesHasAdmin = async(setupExternalStrategies: IExternalAuthProviderType[]): Promise<boolean> => {
+const checkExternalStrategiesHasAdmin = async (
+  setupExternalStrategies: IExternalAuthProviderType[],
+): Promise<boolean> => {
   const User = mongoose.model('User') as any;
   const User = mongoose.model('User') as any;
 
 
   const externalAdmins: AggregateResult[] = await User.aggregate([
   const externalAdmins: AggregateResult[] = await User.aggregate([
@@ -47,7 +49,9 @@ const checkExternalStrategiesHasAdmin = async(setupExternalStrategies: IExternal
   return externalAdmins.length > 0 && externalAdmins[0].count > 0;
   return externalAdmins.length > 0 && externalAdmins[0].count > 0;
 };
 };
 
 
-export const checkSetupStrategiesHasAdmin = async(setupStrategies: (IExternalAuthProviderType | 'local')[]): Promise<boolean> => {
+export const checkSetupStrategiesHasAdmin = async (
+  setupStrategies: (IExternalAuthProviderType | 'local')[],
+): Promise<boolean> => {
   if (setupStrategies.includes('local')) {
   if (setupStrategies.includes('local')) {
     const isLocalStrategyHasAdmin = await checkLocalStrategyHasAdmin();
     const isLocalStrategyHasAdmin = await checkLocalStrategyHasAdmin();
     if (isLocalStrategyHasAdmin) {
     if (isLocalStrategyHasAdmin) {
@@ -55,12 +59,16 @@ export const checkSetupStrategiesHasAdmin = async(setupStrategies: (IExternalAut
     }
     }
   }
   }
 
 
-  const setupExternalStrategies = setupStrategies.filter(strategy => strategy !== 'local') as IExternalAuthProviderType[];
+  const setupExternalStrategies = setupStrategies.filter(
+    (strategy) => strategy !== 'local',
+  ) as IExternalAuthProviderType[];
   if (setupExternalStrategies.length === 0) {
   if (setupExternalStrategies.length === 0) {
     return false;
     return false;
   }
   }
 
 
-  const isExternalStrategiesHasAdmin = await checkExternalStrategiesHasAdmin(setupExternalStrategies);
+  const isExternalStrategiesHasAdmin = await checkExternalStrategiesHasAdmin(
+    setupExternalStrategies,
+  );
 
 
   return isExternalStrategiesHasAdmin;
   return isExternalStrategiesHasAdmin;
 };
 };

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


+ 145 - 84
apps/app/src/server/routes/apiv3/user-activation.ts

@@ -1,10 +1,9 @@
-import path from 'path';
-
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { format, subSeconds } from 'date-fns';
 import { format, subSeconds } from 'date-fns';
 import { body, validationResult } from 'express-validator';
 import { body, validationResult } from 'express-validator';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
+import path from 'path';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { RegistrationMode } from '~/interfaces/registration-mode';
@@ -34,7 +33,9 @@ export const completeRegistrationRules = () => {
       .matches(/^[\x20-\x7F]*$/)
       .matches(/^[\x20-\x7F]*$/)
       .withMessage('Password has invalid character')
       .withMessage('Password has invalid character')
       .isLength({ min: PASSOWRD_MINIMUM_NUMBER })
       .isLength({ min: PASSOWRD_MINIMUM_NUMBER })
-      .withMessage('Password minimum character should be more than 8 characters')
+      .withMessage(
+        'Password minimum character should be more than 8 characters',
+      )
       .not()
       .not()
       .isEmpty()
       .isEmpty()
       .withMessage('Password field is required'),
       .withMessage('Password field is required'),
@@ -49,12 +50,19 @@ export const validateCompleteRegistration = (req, res, next) => {
   }
   }
 
 
   const extractedErrors: string[] = [];
   const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
+  errors.array().map((err) => extractedErrors.push(err.msg));
 
 
   return res.apiv3Err(extractedErrors);
   return res.apiv3Err(extractedErrors);
 };
 };
 
 
-async function sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url) {
+async function sendEmailToAllAdmins(
+  userData,
+  admins,
+  appTitle,
+  mailService,
+  template,
+  url,
+) {
   admins.map((admin) => {
   admins.map((admin) => {
     return mailService.send({
     return mailService.send({
       to: admin.email,
       to: admin.email,
@@ -110,29 +118,47 @@ async function sendEmailToAllAdmins(userData, admins, appTitle, mailService, tem
  *                   type: string
  *                   type: string
  */
  */
 export const completeRegistrationAction = (crowi: Crowi) => {
 export const completeRegistrationAction = (crowi: Crowi) => {
-  const User = mongoose.model<IUser, { isEmailValid, isRegisterable, createUserByEmailAndPassword, findAdmins }>('User');
+  const User = mongoose.model<
+    IUser,
+    { isEmailValid; isRegisterable; createUserByEmailAndPassword; findAdmins }
+  >('User');
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
-  const {
-    aclService,
-    appService,
-    mailService,
-  } = crowi;
+  const { aclService, appService, mailService } = crowi;
 
 
-  return async function(req, res) {
+  return async (req, res) => {
     const { t } = await getTranslation();
     const { t } = await getTranslation();
 
 
     if (req.user != null) {
     if (req.user != null) {
-      return res.apiv3Err(new ErrorV3('You have been logged in', 'registration-failed'), 403);
+      return res.apiv3Err(
+        new ErrorV3('You have been logged in', 'registration-failed'),
+        403,
+      );
     }
     }
 
 
     // error when registration is not allowed
     // error when registration is not allowed
-    if (configManager.getConfig('security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED) {
-      return res.apiv3Err(new ErrorV3('Registration closed', 'registration-failed'), 403);
+    if (
+      configManager.getConfig('security:registrationMode') ===
+      aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED
+    ) {
+      return res.apiv3Err(
+        new ErrorV3('Registration closed', 'registration-failed'),
+        403,
+      );
     }
     }
 
 
     // error when email authentication is disabled
     // error when email authentication is disabled
-    if (configManager.getConfig('security:passport-local:isEmailAuthenticationEnabled') !== true) {
-      return res.apiv3Err(new ErrorV3('Email authentication configuration is disabled', 'registration-failed'), 403);
+    if (
+      configManager.getConfig(
+        'security:passport-local:isEmailAuthenticationEnabled',
+      ) !== true
+    ) {
+      return res.apiv3Err(
+        new ErrorV3(
+          'Email authentication configuration is disabled',
+          'registration-failed',
+        ),
+        403,
+      );
     }
     }
 
 
     const { userRegistrationOrder } = req;
     const { userRegistrationOrder } = req;
@@ -162,65 +188,94 @@ export const completeRegistrationAction = (crowi: Crowi) => {
         }
         }
       }
       }
       if (isError) {
       if (isError) {
-        return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
+        return res.apiv3Err(
+          new ErrorV3(errorMessage, 'registration-failed'),
+          403,
+        );
       }
       }
 
 
-      User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
-        if (err) {
-          if (err.name === 'UserUpperLimitException') {
-            errorMessage = t('message.can_not_register_maximum_number_of_users');
-          }
-          else {
-            errorMessage = t('message.failed_to_register');
-          }
-          return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
-        }
-
-        const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
-        activityEvent.emit('update', res.locals.activity._id, parameters);
-
-        userRegistrationOrder.revokeOneTimeToken();
-
-        if (configManager.getConfig('security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-          const isMailerSetup = mailService.isMailerSetup ?? false;
-
-          if (isMailerSetup) {
-            const admins = await User.findAdmins();
-            const appTitle = appService.getAppTitle();
-            const locale = configManager.getConfig('app:globalLang');
-            const template = path.join(crowi.localeDir, `${locale}/admin/userWaitingActivation.ejs`);
-            const url = growiInfoService.getSiteUrl();
-
-            sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url);
-          }
-          // This 'completeRegistrationAction' should not be able to be called if the email settings is not set up in the first place.
-          // So this method dows not stop processing as an error, but only displays a warning. -- 2022.11.01 Yuki Takei
-          else {
-            logger.warn('E-mail Settings must be set up.');
-          }
-
-          return res.apiv3({});
-        }
-
-        req.login(userData, (err) => {
+      User.createUserByEmailAndPassword(
+        name,
+        username,
+        email,
+        password,
+        undefined,
+        async (err, userData) => {
           if (err) {
           if (err) {
-            logger.debug(err);
+            if (err.name === 'UserUpperLimitException') {
+              errorMessage = t(
+                'message.can_not_register_maximum_number_of_users',
+              );
+            } else {
+              errorMessage = t('message.failed_to_register');
+            }
+            return res.apiv3Err(
+              new ErrorV3(errorMessage, 'registration-failed'),
+              403,
+            );
           }
           }
-          else {
-            // update lastLoginAt
-            userData.updateLastLoginAt(new Date(), (err) => {
-              if (err) {
-                logger.error(`updateLastLoginAt dumps error: ${err}`);
-              }
-            });
+
+          const parameters = {
+            action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS,
+          };
+          activityEvent.emit('update', res.locals.activity._id, parameters);
+
+          userRegistrationOrder.revokeOneTimeToken();
+
+          if (
+            configManager.getConfig('security:registrationMode') ===
+            aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED
+          ) {
+            const isMailerSetup = mailService.isMailerSetup ?? false;
+
+            if (isMailerSetup) {
+              const admins = await User.findAdmins();
+              const appTitle = appService.getAppTitle();
+              const locale = configManager.getConfig('app:globalLang');
+              const template = path.join(
+                crowi.localeDir,
+                `${locale}/admin/userWaitingActivation.ejs`,
+              );
+              const url = growiInfoService.getSiteUrl();
+
+              sendEmailToAllAdmins(
+                userData,
+                admins,
+                appTitle,
+                mailService,
+                template,
+                url,
+              );
+            }
+            // This 'completeRegistrationAction' should not be able to be called if the email settings is not set up in the first place.
+            // So this method dows not stop processing as an error, but only displays a warning. -- 2022.11.01 Yuki Takei
+            else {
+              logger.warn('E-mail Settings must be set up.');
+            }
+
+            return res.apiv3({});
           }
           }
 
 
-          // userData.password can't be empty but, prepare redirect because password property in User Model is optional
-          // https://github.com/growilabs/growi/pull/6670
-          const redirectTo = userData.password != null ? '/' : '/me#password_settings';
-          return res.apiv3({ redirectTo });
-        });
-      });
+          req.login(userData, (err) => {
+            if (err) {
+              logger.debug(err);
+            } else {
+              // update lastLoginAt
+              userData.updateLastLoginAt(new Date(), (err) => {
+                if (err) {
+                  logger.error(`updateLastLoginAt dumps error: ${err}`);
+                }
+              });
+            }
+
+            // userData.password can't be empty but, prepare redirect because password property in User Model is optional
+            // https://github.com/growilabs/growi/pull/6670
+            const redirectTo =
+              userData.password != null ? '/' : '/me#password_settings';
+            return res.apiv3({ redirectTo });
+          });
+        },
+      );
     });
     });
   };
   };
 };
 };
@@ -244,17 +299,13 @@ export const validateRegisterForm = (req, res, next) => {
   }
   }
 
 
   const extractedErrors: string[] = [];
   const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
+  errors.array().map((err) => extractedErrors.push(err.msg));
 
 
   return res.apiv3Err(extractedErrors, 400);
   return res.apiv3Err(extractedErrors, 400);
 };
 };
 
 
 async function makeRegistrationEmailToken(email, crowi: Crowi) {
 async function makeRegistrationEmailToken(email, crowi: Crowi) {
-  const {
-    mailService,
-    localeDir,
-    appService,
-  } = crowi;
+  const { mailService, localeDir, appService } = crowi;
 
 
   const isMailerSetup = mailService.isMailerSetup ?? false;
   const isMailerSetup = mailService.isMailerSetup ?? false;
   if (!isMailerSetup) {
   if (!isMailerSetup) {
@@ -264,17 +315,24 @@ async function makeRegistrationEmailToken(email, crowi: Crowi) {
   const locale = configManager.getConfig('app:globalLang');
   const locale = configManager.getConfig('app:globalLang');
   const appUrl = growiInfoService.getSiteUrl();
   const appUrl = growiInfoService.getSiteUrl();
 
 
-  const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
+  const userRegistrationOrder =
+    await UserRegistrationOrder.createUserRegistrationOrder(email);
   const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
   const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
   const expiredAt = subSeconds(userRegistrationOrder.expiredAt, grwTzoffsetSec);
   const expiredAt = subSeconds(userRegistrationOrder.expiredAt, grwTzoffsetSec);
   const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
   const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
-  const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
+  const url = new URL(
+    `/user-activation/${userRegistrationOrder.token}`,
+    appUrl,
+  );
   const oneTimeUrl = url.href;
   const oneTimeUrl = url.href;
 
 
   return mailService.send({
   return mailService.send({
     to: email,
     to: email,
     subject: '[GROWI] User Activation',
     subject: '[GROWI] User Activation',
-    template: path.join(localeDir, `${locale}/notifications/userActivation.ejs`),
+    template: path.join(
+      localeDir,
+      `${locale}/notifications/userActivation.ejs`,
+    ),
     vars: {
     vars: {
       appTitle: appService.getAppTitle(),
       appTitle: appService.getAppTitle(),
       email,
       email,
@@ -285,13 +343,17 @@ async function makeRegistrationEmailToken(email, crowi: Crowi) {
 }
 }
 
 
 export const registerAction = (crowi: Crowi) => {
 export const registerAction = (crowi: Crowi) => {
-  const User = mongoose.model<IUser, { isRegisterableEmail, isEmailValid }>('User');
+  const User = mongoose.model<IUser, { isRegisterableEmail; isEmailValid }>(
+    'User',
+  );
 
 
-  return async function(req, res) {
+  return async (req, res) => {
     const registerForm = req.body.registerForm || {};
     const registerForm = req.body.registerForm || {};
     const email = registerForm.email;
     const email = registerForm.email;
     const isRegisterableEmail = await User.isRegisterableEmail(email);
     const isRegisterableEmail = await User.isRegisterableEmail(email);
-    const registrationMode = configManager.getConfig('security:registrationMode');
+    const registrationMode = configManager.getConfig(
+      'security:registrationMode',
+    );
     const isEmailValid = await User.isEmailValid(email);
     const isEmailValid = await User.isEmailValid(email);
 
 
     if (registrationMode === RegistrationMode.CLOSED) {
     if (registrationMode === RegistrationMode.CLOSED) {
@@ -309,8 +371,7 @@ export const registerAction = (crowi: Crowi) => {
 
 
     try {
     try {
       await makeRegistrationEmailToken(email, crowi);
       await makeRegistrationEmailToken(email, crowi);
-    }
-    catch (err) {
+    } catch (err) {
       return res.apiv3Err(err);
       return res.apiv3Err(err);
     }
     }
 
 

+ 46 - 26
apps/app/src/server/routes/apiv3/user-activities.ts

@@ -3,7 +3,7 @@ import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Request, Router } from 'express';
 import type { Request, Router } from 'express';
 import express from 'express';
 import express from 'express';
 import { query } from 'express-validator';
 import { query } from 'express-validator';
-import type { PipelineStage, PaginateResult } from 'mongoose';
+import type { PaginateResult, PipelineStage } from 'mongoose';
 import { Types } from 'mongoose';
 import { Types } from 'mongoose';
 
 
 import type { IActivity } from '~/interfaces/activity';
 import type { IActivity } from '~/interfaces/activity';
@@ -12,22 +12,32 @@ import Activity from '~/server/models/activity';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
 const logger = loggerFactory('growi:routes:apiv3:activity');
 const logger = loggerFactory('growi:routes:apiv3:activity');
 
 
 const validator = {
 const validator = {
   list: [
   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')
       .toInt(),
       .toInt(),
-    query('offset').optional().isInt().withMessage('page must be a number')
+    query('offset')
+      .optional()
+      .isInt()
+      .withMessage('page must be a number')
       .toInt(),
       .toInt(),
-    query('searchFilter').optional().isString().withMessage('query must be a string'),
-    query('targetUserId').optional().isMongoId().withMessage('user ID must be a MongoDB ID'),
+    query('searchFilter')
+      .optional()
+      .isString()
+      .withMessage('query must be a string'),
+    query('targetUserId')
+      .optional()
+      .isMongoId()
+      .withMessage('user ID must be a MongoDB ID'),
   ],
   ],
 };
 };
 
 
@@ -41,17 +51,16 @@ interface StrictActivityQuery {
 type CustomRequest<
 type CustomRequest<
   TQuery = Request['query'],
   TQuery = Request['query'],
   TBody = any,
   TBody = any,
-  TParams = any
+  TParams = any,
 > = Omit<Request<TParams, any, TBody, TQuery>, 'query'> & {
 > = Omit<Request<TParams, any, TBody, TQuery>, 'query'> & {
-    query: TQuery & Request['query'];
-    user?: IUserHasId;
+  query: TQuery & Request['query'];
+  user?: IUserHasId;
 };
 };
 
 
 type AuthorizedRequest = CustomRequest<StrictActivityQuery>;
 type AuthorizedRequest = CustomRequest<StrictActivityQuery>;
 
 
 type ActivityPaginationResult = PaginateResult<IActivity>;
 type ActivityPaginationResult = PaginateResult<IActivity>;
 
 
-
 /**
 /**
  * @swagger
  * @swagger
  *
  *
@@ -134,7 +143,9 @@ type ActivityPaginationResult = PaginateResult<IActivity>;
  */
  */
 
 
 module.exports = (crowi: Crowi): Router => {
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
 
 
   const router = express.Router();
   const router = express.Router();
 
 
@@ -173,10 +184,15 @@ module.exports = (crowi: Crowi): Router => {
    *             schema:
    *             schema:
    *               $ref: '#/components/schemas/ActivityResponse'
    *               $ref: '#/components/schemas/ActivityResponse'
    */
    */
-  router.get('/',
-    loginRequiredStrictly, validator.list, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
-
-      const defaultLimit = configManager.getConfig('customize:showPageLimitationS');
+  router.get(
+    '/',
+    loginRequiredStrictly,
+    validator.list,
+    apiV3FormValidator,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
+      const defaultLimit = configManager.getConfig(
+        'customize:showPageLimitationS',
+      );
 
 
       const limit = req.query.limit || defaultLimit || 10;
       const limit = req.query.limit || defaultLimit || 10;
       const offset = req.query.offset || 0;
       const offset = req.query.offset || 0;
@@ -187,10 +203,12 @@ module.exports = (crowi: Crowi): Router => {
       }
       }
 
 
       if (!targetUserId) {
       if (!targetUserId) {
-        return res.apiv3Err('Target user ID is missing and authenticated user ID is unavailable.', 400);
+        return res.apiv3Err(
+          'Target user ID is missing and authenticated user ID is unavailable.',
+          400,
+        );
       }
       }
 
 
-
       try {
       try {
         const userObjectId = new Types.ObjectId(targetUserId);
         const userObjectId = new Types.ObjectId(targetUserId);
 
 
@@ -203,9 +221,7 @@ module.exports = (crowi: Crowi): Router => {
           },
           },
           {
           {
             $facet: {
             $facet: {
-              totalCount: [
-                { $count: 'count' },
-              ],
+              totalCount: [{ $count: 'count' }],
               docs: [
               docs: [
                 { $sort: { createdAt: -1 } },
                 { $sort: { createdAt: -1 } },
                 { $skip: offset },
                 { $skip: offset },
@@ -256,7 +272,8 @@ module.exports = (crowi: Crowi): Router => {
           },
           },
         ];
         ];
 
 
-        const [activityResults] = await Activity.aggregate(userActivityPipeline);
+        const [activityResults] =
+          await Activity.aggregate(userActivityPipeline);
 
 
         const serializedResults = activityResults.docs.map((doc: IActivity) => {
         const serializedResults = activityResults.docs.map((doc: IActivity) => {
           const { user, ...rest } = doc;
           const { user, ...rest } = doc;
@@ -266,7 +283,10 @@ module.exports = (crowi: Crowi): Router => {
           };
           };
         });
         });
 
 
-        const totalDocs = activityResults.totalCount.length > 0 ? activityResults.totalCount[0].count : 0;
+        const totalDocs =
+          activityResults.totalCount.length > 0
+            ? activityResults.totalCount[0].count
+            : 0;
         const totalPages = Math.ceil(totalDocs / limit);
         const totalPages = Math.ceil(totalDocs / limit);
         const page = Math.floor(offset / limit) + 1;
         const page = Math.floor(offset / limit) + 1;
 
 
@@ -289,12 +309,12 @@ module.exports = (crowi: Crowi): Router => {
         };
         };
 
 
         return res.apiv3({ serializedPaginationResult });
         return res.apiv3({ serializedPaginationResult });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Failed to get paginated activity', err);
         logger.error('Failed to get paginated activity', err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 51 - 45
apps/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -13,10 +13,13 @@ const logger = loggerFactory('growi:routes:apiv3:user-ui-settings');
 const router = express.Router();
 const router = express.Router();
 
 
 module.exports = () => {
 module.exports = () => {
-
   const validatorForPut = [
   const validatorForPut = [
-    body('settings').exists().withMessage('The body param \'settings\' is required'),
-    body('settings.currentSidebarContents').optional().isIn(AllSidebarContentsType),
+    body('settings')
+      .exists()
+      .withMessage("The body param 'settings' is required"),
+    body('settings.currentSidebarContents')
+      .optional()
+      .isIn(AllSidebarContentsType),
     body('settings.currentProductNavWidth').optional().isNumeric(),
     body('settings.currentProductNavWidth').optional().isNumeric(),
     body('settings.preferCollapsedModeByUser').optional().isBoolean(),
     body('settings.preferCollapsedModeByUser').optional().isBoolean(),
   ];
   ];
@@ -67,55 +70,58 @@ module.exports = () => {
    *                   type: boolean
    *                   type: boolean
    */
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  router.put('/', validatorForPut, apiV3FormValidator, async(req: any, res: any) => {
-    const { user } = req;
-    const { settings } = req.body;
+  router.put(
+    '/',
+    validatorForPut,
+    apiV3FormValidator,
+    async (req: any, res: any) => {
+      const { user } = req;
+      const { settings } = req.body;
 
 
-    // extract only necessary params
-    const updateData = {
-      currentSidebarContents: settings.currentSidebarContents,
-      currentProductNavWidth: settings.currentProductNavWidth,
-      preferCollapsedModeByUser: settings.preferCollapsedModeByUser,
-    };
+      // extract only necessary params
+      const updateData = {
+        currentSidebarContents: settings.currentSidebarContents,
+        currentProductNavWidth: settings.currentProductNavWidth,
+        preferCollapsedModeByUser: settings.preferCollapsedModeByUser,
+      };
 
 
-    if (user == null) {
-      if (req.session.uiSettings == null) {
-        req.session.uiSettings = {};
+      if (user == null) {
+        if (req.session.uiSettings == null) {
+          req.session.uiSettings = {};
+        }
+        Object.keys(updateData).forEach((setting) => {
+          if (updateData[setting] != null) {
+            req.session.uiSettings[setting] = updateData[setting];
+          }
+        });
+        return res.apiv3(updateData);
       }
       }
-      Object.keys(updateData).forEach((setting) => {
-        if (updateData[setting] != null) {
-          req.session.uiSettings[setting] = updateData[setting];
+
+      // remove the keys that have null value
+      Object.keys(updateData).forEach((key) => {
+        if (updateData[key] == null) {
+          delete updateData[key];
         }
         }
       });
       });
-      return res.apiv3(updateData);
-    }
-
 
 
-    // remove the keys that have null value
-    Object.keys(updateData).forEach((key) => {
-      if (updateData[key] == null) {
-        delete updateData[key];
-      }
-    });
-
-    try {
-      const updatedSettings = await UserUISettings.findOneAndUpdate(
-        { user: user._id },
-        {
-          $set: {
-            user: user._id,
-            ...updateData,
+      try {
+        const updatedSettings = await UserUISettings.findOneAndUpdate(
+          { user: user._id },
+          {
+            $set: {
+              user: user._id,
+              ...updateData,
+            },
           },
           },
-        },
-        { upsert: true, new: true },
-      );
-      return res.apiv3(updatedSettings);
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
+          { upsert: true, new: true },
+        );
+        return res.apiv3(updatedSettings);
+      } catch (err) {
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(err));
+      }
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 17 - 10
apps/app/src/server/routes/apiv3/user/get-related-groups.ts

@@ -1,8 +1,8 @@
 import type { IUserHasId } from '@growi/core';
 import type { IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { Request, RequestHandler } from 'express';
 
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -14,22 +14,29 @@ const logger = loggerFactory('growi:routes:apiv3:user:get-related-groups');
 type GetRelatedGroupsHandlerFactory = (crowi: Crowi) => RequestHandler[];
 type GetRelatedGroupsHandlerFactory = (crowi: Crowi) => RequestHandler[];
 
 
 interface Req extends Request {
 interface Req extends Request {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 }
 
 
-export const getRelatedGroupsHandlerFactory: GetRelatedGroupsHandlerFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+export const getRelatedGroupsHandlerFactory: GetRelatedGroupsHandlerFactory = (
+  crowi,
+) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
+    crowi,
+  );
 
 
   return [
   return [
-    accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO], { acceptLegacy: true }), loginRequiredStrictly,
-    async(req: Req, res: ApiV3Response) => {
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    async (req: Req, res: ApiV3Response) => {
       try {
       try {
-        const relatedGroups = await crowi.pageGrantService?.getUserRelatedGroups(req.user);
+        const relatedGroups =
+          await crowi.pageGrantService?.getUserRelatedGroups(req.user);
         return res.apiv3({ relatedGroups });
         return res.apiv3({ relatedGroups });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Error occurred while getting user related groups'));
+        return res.apiv3Err(
+          new ErrorV3('Error occurred while getting user related groups'),
+        );
       }
       }
     },
     },
   ];
   ];

+ 39 - 18
apps/app/src/server/service/attachment.js → apps/app/src/server/service/attachment.ts

@@ -1,6 +1,11 @@
+import type { IAttachment, Ref } from '@growi/core/dist/interfaces';
+import type { HydratedDocument } from 'mongoose';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import type Crowi from '../crowi';
 import { AttachmentType } from '../interfaces/attachment';
 import { AttachmentType } from '../interfaces/attachment';
+import type { IAttachmentDocument } from '../models/attachment';
 import { Attachment } from '../models/attachment';
 import { Attachment } from '../models/attachment';
 
 
 const fs = require('fs');
 const fs = require('fs');
@@ -16,26 +21,40 @@ const createReadStream = (filePath) => {
   });
   });
 };
 };
 
 
+type AttachHandler = (pageId: string | null, attachment: IAttachmentDocument, file: Express.Multer.File) => Promise<void>;
+
+type DetachHandler = (attachmentId: string) => Promise<void>;
+
+
+type IAttachmentService = {
+  createAttachment(
+    file: Express.Multer.File, user: any, pageId: string | null, attachmentType: AttachmentType,
+    disposeTmpFileCallback?: (file: Express.Multer.File) => void,
+  ): Promise<IAttachmentDocument>;
+  removeAllAttachments(attachments: IAttachmentDocument[]): Promise<void>;
+  removeAttachment(attachmentId: Ref<IAttachment> | undefined): Promise<void>;
+  isBrandLogoExist(): Promise<boolean>;
+  addAttachHandler(handler: AttachHandler): void;
+  addDetachHandler(handler: DetachHandler): void;
+};
+
+
 /**
 /**
  * the service class for Attachment and file-uploader
  * the service class for Attachment and file-uploader
  */
  */
-class AttachmentService {
+export class AttachmentService implements IAttachmentService {
 
 
-  /** @type {Array<(pageId: string, attachment: Attachment, file: Express.Multer.File) => Promise<void>>} */
-  attachHandlers = [];
+  attachHandlers: AttachHandler[] = [];
 
 
-  /** @type {Array<(attachmentId: string) => Promise<void>>} */
-  detachHandlers = [];
+  detachHandlers: DetachHandler[] = [];
 
 
-  /** @type {import('~/server/crowi').default} Crowi instance */
-  crowi;
+  crowi: Crowi;
 
 
-  /** @param {import('~/server/crowi').default} crowi Crowi instance */
-  constructor(crowi) {
+  constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  async createAttachment(file, user, pageId = null, attachmentType, disposeTmpFileCallback) {
+  async createAttachment(file, user, pageId: string | null | undefined = null, attachmentType, disposeTmpFileCallback): Promise<IAttachmentDocument> {
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
 
 
     // check limit
     // check limit
@@ -78,7 +97,7 @@ class AttachmentService {
     return attachment;
     return attachment;
   }
   }
 
 
-  async removeAllAttachments(attachments) {
+  async removeAllAttachments(attachments: HydratedDocument<IAttachmentDocument>[]): Promise<void> {
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
     const attachmentsCollection = mongoose.connection.collection('attachments');
     const attachmentsCollection = mongoose.connection.collection('attachments');
     const unorderAttachmentsBulkOp = attachmentsCollection.initializeUnorderedBulkOp();
     const unorderAttachmentsBulkOp = attachmentsCollection.initializeUnorderedBulkOp();
@@ -92,15 +111,19 @@ class AttachmentService {
     });
     });
     await unorderAttachmentsBulkOp.execute();
     await unorderAttachmentsBulkOp.execute();
 
 
-    await fileUploadService.deleteFiles(attachments);
+    fileUploadService.deleteFiles(attachments);
 
 
     return;
     return;
   }
   }
 
 
-  async removeAttachment(attachmentId) {
+  async removeAttachment(attachmentId: Ref<IAttachment> | undefined): Promise<void> {
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
     const attachment = await Attachment.findById(attachmentId);
     const attachment = await Attachment.findById(attachmentId);
 
 
+    if (attachment == null) {
+      throw new Error(`Attachment not found: ${attachmentId}`);
+    }
+
     await fileUploadService.deleteFile(attachment);
     await fileUploadService.deleteFile(attachment);
     await attachment.remove();
     await attachment.remove();
 
 
@@ -117,7 +140,7 @@ class AttachmentService {
     return;
     return;
   }
   }
 
 
-  async isBrandLogoExist() {
+  async isBrandLogoExist(): Promise<boolean> {
     const query = { attachmentType: AttachmentType.BRAND_LOGO };
     const query = { attachmentType: AttachmentType.BRAND_LOGO };
     const count = await Attachment.countDocuments(query);
     const count = await Attachment.countDocuments(query);
 
 
@@ -128,7 +151,7 @@ class AttachmentService {
    * Register a handler that will be called after attachment creation
    * Register a handler that will be called after attachment creation
    * @param {(pageId: string, attachment: Attachment, file: Express.Multer.File) => Promise<void>} handler
    * @param {(pageId: string, attachment: Attachment, file: Express.Multer.File) => Promise<void>} handler
    */
    */
-  addAttachHandler(handler) {
+  addAttachHandler(handler: AttachHandler): void {
     this.attachHandlers.push(handler);
     this.attachHandlers.push(handler);
   }
   }
 
 
@@ -136,10 +159,8 @@ class AttachmentService {
    * Register a handler that will be called before attachment deletion
    * Register a handler that will be called before attachment deletion
    * @param {(attachmentId: string) => Promise<void>} handler
    * @param {(attachmentId: string) => Promise<void>} handler
    */
    */
-  addDetachHandler(handler) {
+  addDetachHandler(handler: DetachHandler): void {
     this.detachHandlers.push(handler);
     this.detachHandlers.push(handler);
   }
   }
 
 
 }
 }
-
-module.exports = AttachmentService;

+ 1 - 3
apps/app/src/server/service/customize.ts

@@ -22,7 +22,7 @@ const logger = loggerFactory('growi:service:CustomizeService');
 /**
 /**
  * the service class of CustomizeService
  * the service class of CustomizeService
  */
  */
-class CustomizeService implements S2sMessageHandlable {
+export class CustomizeService implements S2sMessageHandlable {
 
 
   s2sMessagingService: any;
   s2sMessagingService: any;
 
 
@@ -148,5 +148,3 @@ class CustomizeService implements S2sMessageHandlable {
   }
   }
 
 
 }
 }
-
-module.exports = CustomizeService;

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

@@ -166,8 +166,50 @@ class AwsFileUploader extends AbstractFileUploader {
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
-  override deleteFiles() {
-    throw new Error('Method not implemented.');
+  override async deleteFile(attachment: IAttachmentDocument): Promise<void> {
+    const filePath = getFilePathOnStorage(attachment);
+    return this.deleteFileByFilePath(filePath);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override async deleteFiles(attachments: IAttachmentDocument[]): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('AWS is not configured.');
+    }
+    const s3 = S3Factory();
+
+    const filePaths = attachments.map((attachment) => {
+      return { Key: getFilePathOnStorage(attachment) };
+    });
+
+    const totalParams = {
+      Bucket: getS3Bucket(),
+      Delete: { Objects: filePaths },
+    };
+    await s3.send(new DeleteObjectsCommand(totalParams));
+  }
+
+  private async deleteFileByFilePath(filePath: string): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('AWS is not configured.');
+    }
+    const s3 = S3Factory();
+
+    const params = {
+      Bucket: getS3Bucket(),
+      Key: filePath,
+    };
+
+    // check file exists
+    const isExists = await isFileExists(s3, params);
+    if (!isExists) {
+      logger.warn(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
+      return;
+    }
+
+    await s3.send(new DeleteObjectCommand(params));
   }
   }
 
 
   /**
   /**
@@ -345,49 +387,6 @@ module.exports = (crowi: Crowi) => {
       && configManager.getConfig('aws:s3Bucket') != null;
       && configManager.getConfig('aws:s3Bucket') != null;
   };
   };
 
 
-  (lib as any).deleteFile = async function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
-    return (lib as any).deleteFileByFilePath(filePath);
-  };
-
-  (lib as any).deleteFiles = async function(attachments) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('AWS is not configured.');
-    }
-    const s3 = S3Factory();
-
-    const filePaths = attachments.map((attachment) => {
-      return { Key: getFilePathOnStorage(attachment) };
-    });
-
-    const totalParams = {
-      Bucket: getS3Bucket(),
-      Delete: { Objects: filePaths },
-    };
-    return s3.send(new DeleteObjectsCommand(totalParams));
-  };
-
-  (lib as any).deleteFileByFilePath = async function(filePath) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('AWS is not configured.');
-    }
-    const s3 = S3Factory();
-
-    const params = {
-      Bucket: getS3Bucket(),
-      Key: filePath,
-    };
-
-    // check file exists
-    const isExists = await isFileExists(s3, params);
-    if (!isExists) {
-      logger.warn(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
-      return;
-    }
-
-    return s3.send(new DeleteObjectCommand(params));
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
   lib.saveFile = async function({ filePath, contentType, data }) {
     const s3 = S3Factory();
     const s3 = S3Factory();
 
 
@@ -400,12 +399,6 @@ module.exports = (crowi: Crowi) => {
     }));
     }));
   };
   };
 
 
-  (lib as any).checkLimit = async function(uploadFileSize) {
-    const maxFileSize = configManager.getConfig('app:maxFileSize');
-    const totalLimit = configManager.getConfig('app:fileUploadTotalLimit');
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
-  };
-
   /**
   /**
    * List files in storage
    * List files in storage
    */
    */

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

@@ -161,8 +161,27 @@ class AzureFileUploader extends AbstractFileUploader {
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
-  override deleteFiles() {
-    throw new Error('Method not implemented.');
+  override async deleteFile(attachment: IAttachmentDocument): Promise<void> {
+    const filePath = getFilePathOnStorage(attachment);
+    const containerClient = await getContainerClient();
+    const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
+    const options: BlobDeleteOptions = { deleteSnapshots: 'include' };
+    const blobDeleteIfExistsResponse: BlobDeleteIfExistsResponse = await blockBlobClient.deleteIfExists(options);
+    if (!blobDeleteIfExistsResponse.errorCode) {
+      logger.info(`deleted blob ${filePath}`);
+    }
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override async deleteFiles(attachments: IAttachmentDocument[]): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('Azure is not configured.');
+    }
+    for await (const attachment of attachments) {
+      await this.deleteFile(attachment);
+    }
   }
   }
 
 
   /**
   /**
@@ -312,26 +331,6 @@ module.exports = (crowi: Crowi) => {
       && configManager.getConfig('azure:storageContainerName') != null;
       && configManager.getConfig('azure:storageContainerName') != null;
   };
   };
 
 
-  (lib as any).deleteFile = async function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
-    const containerClient = await getContainerClient();
-    const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
-    const options: BlobDeleteOptions = { deleteSnapshots: 'include' };
-    const blobDeleteIfExistsResponse: BlobDeleteIfExistsResponse = await blockBlobClient.deleteIfExists(options);
-    if (!blobDeleteIfExistsResponse.errorCode) {
-      logger.info(`deleted blob ${filePath}`);
-    }
-  };
-
-  (lib as any).deleteFiles = async function(attachments) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('Azure is not configured.');
-    }
-    for await (const attachment of attachments) {
-      (lib as any).deleteFile(attachment);
-    }
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
   lib.saveFile = async function({ filePath, contentType, data }) {
     const containerClient = await getContainerClient();
     const containerClient = await getContainerClient();
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
@@ -345,12 +344,6 @@ module.exports = (crowi: Crowi) => {
     return;
     return;
   };
   };
 
 
-  (lib as any).checkLimit = async function(uploadFileSize) {
-    const maxFileSize = configManager.getConfig('app:maxFileSize');
-    const gcsTotalLimit = configManager.getConfig('app:fileUploadTotalLimit');
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, gcsTotalLimit);
-  };
-
   (lib as any).listFiles = async function() {
   (lib as any).listFiles = async function() {
     if (!lib.getIsReadable()) {
     if (!lib.getIsReadable()) {
       throw new Error('Azure is not configured.');
       throw new Error('Azure is not configured.');

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

@@ -1,6 +1,7 @@
 import type { Readable } from 'stream';
 import type { Readable } from 'stream';
 
 
 import type { Response } from 'express';
 import type { Response } from 'express';
+import type { HydratedDocument } from 'mongoose';
 import { v4 as uuidv4 } from 'uuid';
 import { v4 as uuidv4 } from 'uuid';
 
 
 import type { ICheckLimitResult } from '~/interfaces/attachment';
 import type { ICheckLimitResult } from '~/interfaces/attachment';
@@ -35,10 +36,11 @@ export interface FileUploader {
   getFileUploadEnabled(): boolean,
   getFileUploadEnabled(): boolean,
   listFiles(): any,
   listFiles(): any,
   saveFile(param: SaveFileParam): Promise<any>,
   saveFile(param: SaveFileParam): Promise<any>,
-  deleteFiles(): void,
+  deleteFile(attachment: HydratedDocument<IAttachmentDocument>): void,
+  deleteFiles(attachments: HydratedDocument<IAttachmentDocument>[]): void,
   getFileUploadTotalLimit(): number,
   getFileUploadTotalLimit(): number,
   getTotalFileSize(): Promise<number>,
   getTotalFileSize(): Promise<number>,
-  doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult>,
+  checkLimit(uploadFileSize: number): Promise<ICheckLimitResult>,
   determineResponseMode(): ResponseMode,
   determineResponseMode(): ResponseMode,
   uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void>,
   uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void>,
   respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
   respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
@@ -103,20 +105,16 @@ export abstract class AbstractFileUploader implements FileUploader {
 
 
   abstract saveFile(param: SaveFileParam);
   abstract saveFile(param: SaveFileParam);
 
 
-  abstract deleteFiles();
+  abstract deleteFile(attachment: HydratedDocument<IAttachmentDocument>): void;
+
+  abstract deleteFiles(attachments: HydratedDocument<IAttachmentDocument>[]): void;
 
 
   /**
   /**
    * Returns file upload total limit in bytes.
    * Returns file upload total limit in bytes.
-   * Reference to previous implementation is
-   * {@link https://github.com/growilabs/growi/blob/798e44f14ad01544c1d75ba83d4dfb321a94aa0b/src/server/service/file-uploader/gridfs.js#L86-L88}
    * @returns file upload total limit in bytes
    * @returns file upload total limit in bytes
    */
    */
-  getFileUploadTotalLimit() {
-    const fileUploadTotalLimit = configManager.getConfig('app:fileUploadType') === 'mongodb'
-      // Use app:fileUploadTotalLimit if gridfs:totalLimit is null (default for gridfs:totalLimit is null)
-      ? configManager.getConfig('app:fileUploadTotalLimit')
-      : configManager.getConfig('app:fileUploadTotalLimit');
-    return fileUploadTotalLimit;
+  getFileUploadTotalLimit(): number {
+    return configManager.getConfig('app:fileUploadTotalLimit');
   }
   }
 
 
   /**
   /**
@@ -134,11 +132,20 @@ export abstract class AbstractFileUploader implements FileUploader {
     return res.length === 0 ? 0 : res[0].total;
     return res.length === 0 ? 0 : res[0].total;
   }
   }
 
 
+  /**
+   * check the file size limit
+   */
+  checkLimit(uploadFileSize: number): Promise<ICheckLimitResult> {
+    const maxFileSize = configManager.getConfig('app:maxFileSize');
+    const totalLimit = this.getFileUploadTotalLimit();
+    return this.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
+  }
+
   /**
   /**
    * Check files size limits for all uploaders
    * Check files size limits for all uploaders
    *
    *
    */
    */
-  async doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult> {
+  protected async doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult> {
     if (uploadFileSize > maxFileSize) {
     if (uploadFileSize > maxFileSize) {
       return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
       return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
     }
     }

+ 30 - 43
apps/app/src/server/service/file-uploader/gcs/index.ts

@@ -105,8 +105,36 @@ class GcsFileUploader extends AbstractFileUploader {
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
-  override deleteFiles() {
-    throw new Error('Method not implemented.');
+  override async deleteFile(attachment: IAttachmentDocument): Promise<void> {
+    const filePath = getFilePathOnStorage(attachment);
+    return this.deleteFilesByFilePaths([filePath]);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override async deleteFiles(attachments: IAttachmentDocument[]): Promise<void> {
+    const filePaths = attachments.map((attachment) => {
+      return getFilePathOnStorage(attachment);
+    });
+    return this.deleteFilesByFilePaths(filePaths);
+  }
+
+  private async deleteFilesByFilePaths(filePaths: string[]): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('GCS is not configured.');
+    }
+
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+
+    const files = filePaths.map((filePath) => {
+      return myBucket.file(filePath);
+    });
+
+    files.forEach((file) => {
+      file.delete({ ignoreNotFound: true });
+    });
   }
   }
 
 
   /**
   /**
@@ -263,35 +291,6 @@ module.exports = function(crowi: Crowi) {
       && configManager.getConfig('gcs:bucket') != null;
       && configManager.getConfig('gcs:bucket') != null;
   };
   };
 
 
-  (lib as any).deleteFile = function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
-    return (lib as any).deleteFilesByFilePaths([filePath]);
-  };
-
-  (lib as any).deleteFiles = function(attachments) {
-    const filePaths = attachments.map((attachment) => {
-      return getFilePathOnStorage(attachment);
-    });
-    return (lib as any).deleteFilesByFilePaths(filePaths);
-  };
-
-  (lib as any).deleteFilesByFilePaths = function(filePaths) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('GCS is not configured.');
-    }
-
-    const gcs = getGcsInstance();
-    const myBucket = gcs.bucket(getGcsBucket());
-
-    const files = filePaths.map((filePath) => {
-      return myBucket.file(filePath);
-    });
-
-    files.forEach((file) => {
-      file.delete({ ignoreNotFound: true });
-    });
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
   lib.saveFile = async function({ filePath, contentType, data }) {
     const gcs = getGcsInstance();
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());
     const myBucket = gcs.bucket(getGcsBucket());
@@ -299,18 +298,6 @@ module.exports = function(crowi: Crowi) {
     return myBucket.file(filePath).save(data, { resumable: false });
     return myBucket.file(filePath).save(data, { resumable: false });
   };
   };
 
 
-  /**
-   * check the file size limit
-   *
-   * In detail, the followings are checked.
-   * - per-file size limit (specified by MAX_FILE_SIZE)
-   */
-  (lib as any).checkLimit = async function(uploadFileSize) {
-    const maxFileSize = configManager.getConfig('app:maxFileSize');
-    const gcsTotalLimit = configManager.getConfig('app:fileUploadTotalLimit');
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, gcsTotalLimit);
-  };
-
   /**
   /**
    * List files in storage
    * List files in storage
    */
    */

+ 42 - 47
apps/app/src/server/service/file-uploader/gridfs.ts

@@ -104,8 +104,48 @@ class GridfsFileUploader extends AbstractFileUploader {
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
-  override deleteFiles() {
-    throw new Error('Method not implemented.');
+  override async deleteFile(attachment: IAttachmentDocument): Promise<void> {
+    const { attachmentFileModel } = initializeGridFSModels();
+    const filenameValue = attachment.fileName;
+
+    const attachmentFile = await attachmentFileModel.findOne({ filename: filenameValue });
+
+    if (attachmentFile == null) {
+      logger.warn(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in GridFS`);
+      return;
+    }
+
+    return attachmentFileModel.promisifiedUnlink({ _id: attachmentFile._id });
+  }
+
+  /**
+   * @inheritdoc
+   *
+   * Bulk delete files since unlink method of mongoose-gridfs does not support bulk operation
+   */
+  override async deleteFiles(attachments: IAttachmentDocument[]): Promise<void> {
+    const { attachmentFileModel, chunkCollection } = initializeGridFSModels();
+
+    const filenameValues = attachments.map((attachment) => {
+      return attachment.fileName;
+    });
+    const fileIdObjects = await attachmentFileModel.find({ filename: { $in: filenameValues } }, { _id: 1 });
+    const idsRelatedFiles = fileIdObjects.map((obj) => { return obj._id });
+
+    await Promise.all([
+      attachmentFileModel.deleteMany({ filename: { $in: filenameValues } }),
+      chunkCollection.deleteMany({ files_id: { $in: idsRelatedFiles } }),
+    ]);
+  }
+
+  /**
+   * @inheritdoc
+   *
+   * Reference to previous implementation is
+   * {@link https://github.com/growilabs/growi/blob/798e44f14ad01544c1d75ba83d4dfb321a94aa0b/src/server/service/file-uploader/gridfs.js#L86-L88}
+   */
+  override getFileUploadTotalLimit() {
+    return configManager.getConfig('gridfs:totalLimit') ?? configManager.getConfig('app:fileUploadTotalLimit');
   }
   }
 
 
   /**
   /**
@@ -158,51 +198,6 @@ module.exports = function(crowi: Crowi) {
     return true;
     return true;
   };
   };
 
 
-  (lib as any).deleteFile = async function(attachment) {
-    const { attachmentFileModel } = initializeGridFSModels();
-    const filenameValue = attachment.fileName;
-
-    const attachmentFile = await attachmentFileModel.findOne({ filename: filenameValue });
-
-    if (attachmentFile == null) {
-      logger.warn(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in GridFS`);
-      return;
-    }
-
-    return attachmentFileModel.promisifiedUnlink({ _id: attachmentFile._id });
-  };
-
-  /**
-   * Bulk delete files since unlink method of mongoose-gridfs does not support bulk operation
-   */
-  (lib as any).deleteFiles = async function(attachments) {
-    const { attachmentFileModel, chunkCollection } = initializeGridFSModels();
-
-    const filenameValues = attachments.map((attachment) => {
-      return attachment.fileName;
-    });
-    const fileIdObjects = await attachmentFileModel.find({ filename: { $in: filenameValues } }, { _id: 1 });
-    const idsRelatedFiles = fileIdObjects.map((obj) => { return obj._id });
-
-    return Promise.all([
-      attachmentFileModel.deleteMany({ filename: { $in: filenameValues } }),
-      chunkCollection.deleteMany({ files_id: { $in: idsRelatedFiles } }),
-    ]);
-  };
-
-  /**
-   * check the file size limit
-   *
-   * In detail, the followings are checked.
-   * - per-file size limit (specified by MAX_FILE_SIZE)
-   * - mongodb(gridfs) size limit (specified by MONGO_GRIDFS_TOTAL_LIMIT)
-   */
-  (lib as any).checkLimit = async function(uploadFileSize) {
-    const maxFileSize = configManager.getConfig('app:maxFileSize');
-    const totalLimit = lib.getFileUploadTotalLimit();
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
   lib.saveFile = async function({ filePath, contentType, data }) {
     const { attachmentFileModel } = initializeGridFSModels();
     const { attachmentFileModel } = initializeGridFSModels();
 
 

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

@@ -56,11 +56,34 @@ class LocalFileUploader extends AbstractFileUploader {
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
-  override deleteFiles() {
-    throw new Error('Method not implemented.');
+  override async deleteFile(attachment: IAttachmentDocument): Promise<void> {
+    const filePath = this.getFilePathOnStorage(attachment);
+    return this.deleteFileByFilePath(filePath);
   }
   }
 
 
-  deleteFileByFilePath(filePath: string): void {
+  /**
+   * @inheritdoc
+   */
+  override async deleteFiles(attachments: IAttachmentDocument[]): Promise<void> {
+    await Promise.all(attachments.map((attachment) => {
+      return this.deleteFile(attachment);
+    }));
+  }
+
+  private async deleteFileByFilePath(filePath: string): Promise<void> {
+    // check file exists
+    try {
+      fs.statSync(filePath);
+    }
+    catch (err) {
+      logger.warn(`Any AttachmentFile which path is '${filePath}' does not exist in local fs`);
+      return;
+    }
+
+    return fs.unlinkSync(filePath);
+  }
+
+  getFilePathOnStorage(_attachment: IAttachmentDocument): string {
     throw new Error('Method not implemented.');
     throw new Error('Method not implemented.');
   }
   }
 
 
@@ -108,14 +131,14 @@ module.exports = function(crowi: Crowi) {
 
 
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
 
-  function getFilePathOnStorage(attachment: IAttachmentDocument) {
+  lib.getFilePathOnStorage = function(attachment: IAttachmentDocument) {
     const dirName = (attachment.page != null)
     const dirName = (attachment.page != null)
       ? FilePathOnStoragePrefix.attachment
       ? FilePathOnStoragePrefix.attachment
       : FilePathOnStoragePrefix.user;
       : FilePathOnStoragePrefix.user;
     const filePath = path.posix.join(basePath, dirName, attachment.fileName);
     const filePath = path.posix.join(basePath, dirName, attachment.fileName);
 
 
     return filePath;
     return filePath;
-  }
+  };
 
 
   async function readdirRecursively(dirPath) {
   async function readdirRecursively(dirPath) {
     const directories = await fsPromises.readdir(dirPath, { withFileTypes: true });
     const directories = await fsPromises.readdir(dirPath, { withFileTypes: true });
@@ -131,34 +154,10 @@ module.exports = function(crowi: Crowi) {
     return true;
     return true;
   };
   };
 
 
-  (lib as any).deleteFile = async function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
-    return lib.deleteFileByFilePath(filePath);
-  };
-
-  (lib as any).deleteFiles = async function(attachments) {
-    attachments.map((attachment) => {
-      return (lib as any).deleteFile(attachment);
-    });
-  };
-
-  lib.deleteFileByFilePath = async function(filePath) {
-    // check file exists
-    try {
-      fs.statSync(filePath);
-    }
-    catch (err) {
-      logger.warn(`Any AttachmentFile which path is '${filePath}' does not exist in local fs`);
-      return;
-    }
-
-    return fs.unlinkSync(filePath);
-  };
-
   lib.uploadAttachment = async function(fileStream, attachment) {
   lib.uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
 
-    const filePath = getFilePathOnStorage(attachment);
+    const filePath = lib.getFilePathOnStorage(attachment);
     const dirpath = path.posix.dirname(filePath);
     const dirpath = path.posix.dirname(filePath);
 
 
     // mkdir -p
     // mkdir -p
@@ -211,7 +210,7 @@ module.exports = function(crowi: Crowi) {
    * @return {stream.Readable} readable stream
    * @return {stream.Readable} readable stream
    */
    */
   lib.findDeliveryFile = async function(attachment) {
   lib.findDeliveryFile = async function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
+    const filePath = lib.getFilePathOnStorage(attachment);
 
 
     // check file exists
     // check file exists
     try {
     try {
@@ -225,18 +224,6 @@ module.exports = function(crowi: Crowi) {
     return fs.createReadStream(filePath);
     return fs.createReadStream(filePath);
   };
   };
 
 
-  /**
-   * check the file size limit
-   *
-   * In detail, the followings are checked.
-   * - per-file size limit (specified by MAX_FILE_SIZE)
-   */
-  (lib as any).checkLimit = async function(uploadFileSize) {
-    const maxFileSize = configManager.getConfig('app:maxFileSize');
-    const totalLimit = configManager.getConfig('app:fileUploadTotalLimit');
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
-  };
-
   /**
   /**
    * Respond to the HTTP request.
    * Respond to the HTTP request.
    * @param {Response} res
    * @param {Response} res
@@ -244,7 +231,7 @@ module.exports = function(crowi: Crowi) {
    */
    */
   lib.respond = function(res, attachment, opts) {
   lib.respond = function(res, attachment, opts) {
     // Responce using internal redirect of nginx or Apache.
     // Responce using internal redirect of nginx or Apache.
-    const storagePath = getFilePathOnStorage(attachment);
+    const storagePath = lib.getFilePathOnStorage(attachment);
     const relativePath = path.relative(crowi.publicDir, storagePath);
     const relativePath = path.relative(crowi.publicDir, storagePath);
     const internalPathRoot = configManager.getConfig('fileUpload:local:internalRedirectPath');
     const internalPathRoot = configManager.getConfig('fileUpload:local:internalRedirectPath');
     const internalPath = urljoin(internalPathRoot, relativePath);
     const internalPath = urljoin(internalPathRoot, relativePath);

+ 1 - 0
apps/pdf-converter/.env

@@ -1 +1,2 @@
 PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
 PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
+PUPPETEER_CLUSTER_CONFIG={"maxConcurrency":1, "concurrency": 2}

+ 2 - 2
apps/pdf-converter/docker/README.md

@@ -5,10 +5,10 @@ GROWI PDF Converter Official docker image
 [![Node CI for pdf-converter](https://github.com/growilabs/growi/actions/workflows/ci-pdf-converter.yml/badge.svg)](https://github.com/growilabs/growi/actions/workflows/ci-pdf-converter.yml) [![docker-pulls](https://img.shields.io/docker/pulls/growilabs/pdf-converter.svg)](https://hub.docker.com/r/growilabs/pdf-converter/)
 [![Node CI for pdf-converter](https://github.com/growilabs/growi/actions/workflows/ci-pdf-converter.yml/badge.svg)](https://github.com/growilabs/growi/actions/workflows/ci-pdf-converter.yml) [![docker-pulls](https://img.shields.io/docker/pulls/growilabs/pdf-converter.svg)](https://hub.docker.com/r/growilabs/pdf-converter/)
 
 
 
 
-Supported tags and respective Dockerfile links
+Dockerfile link
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`1.0.0`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/master/apps/pdf-converter/docker/Dockerfile)
+https://github.com/growilabs/growi/blob/master/apps/pdf-converter/docker/Dockerfile
 
 
 
 
 What is GROWI PDF Converter used for?
 What is GROWI PDF Converter used for?

+ 1 - 1
apps/pdf-converter/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/pdf-converter",
   "name": "@growi/pdf-converter",
-  "version": "1.1.3-RC.0",
+  "version": "1.2.1-RC.0",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
   "types": "dist/index.d.ts",
   "license": "MIT",
   "license": "MIT",

+ 48 - 11
apps/pdf-converter/src/service/pdf-convert.ts

@@ -5,6 +5,7 @@ import { pipeline as pipelinePromise } from 'node:stream/promises';
 import { OnInit } from '@tsed/common';
 import { OnInit } from '@tsed/common';
 import { Service } from '@tsed/di';
 import { Service } from '@tsed/di';
 import { Logger } from '@tsed/logger';
 import { Logger } from '@tsed/logger';
+import type { PuppeteerNodeLaunchOptions } from 'puppeteer';
 import { Cluster } from 'puppeteer-cluster';
 import { Cluster } from 'puppeteer-cluster';
 
 
 interface PageInfo {
 interface PageInfo {
@@ -37,8 +38,6 @@ interface JobInfo {
 class PdfConvertService implements OnInit {
 class PdfConvertService implements OnInit {
   private puppeteerCluster: Cluster | undefined;
   private puppeteerCluster: Cluster | undefined;
 
 
-  private maxConcurrency = 1;
-
   private convertRetryLimit = 5;
   private convertRetryLimit = 5;
 
 
   private tmpOutputRootDir = '/tmp/page-bulk-export';
   private tmpOutputRootDir = '/tmp/page-bulk-export';
@@ -292,15 +291,8 @@ class PdfConvertService implements OnInit {
   private async initPuppeteerCluster(): Promise<void> {
   private async initPuppeteerCluster(): Promise<void> {
     if (process.env.SKIP_PUPPETEER_INIT === 'true') return;
     if (process.env.SKIP_PUPPETEER_INIT === 'true') return;
 
 
-    this.puppeteerCluster = await Cluster.launch({
-      concurrency: Cluster.CONCURRENCY_PAGE,
-      maxConcurrency: this.maxConcurrency,
-      workerCreationDelay: 10000,
-      puppeteerOptions: {
-        // ref) https://github.com/growilabs/growi/pull/10192
-        args: ['--no-sandbox'],
-      },
-    });
+    const config = this.getPuppeteerClusterConfig();
+    this.puppeteerCluster = await Cluster.launch(config);
 
 
     await this.puppeteerCluster.task(async ({ page, data: htmlString }) => {
     await this.puppeteerCluster.task(async ({ page, data: htmlString }) => {
       await page.setContent(htmlString, { waitUntil: 'domcontentloaded' });
       await page.setContent(htmlString, { waitUntil: 'domcontentloaded' });
@@ -326,6 +318,51 @@ class PdfConvertService implements OnInit {
     });
     });
   }
   }
 
 
+  /**
+   * Get puppeteer cluster configuration from environment variable
+   * @returns merged cluster configuration
+   */
+  private getPuppeteerClusterConfig(): Record<string, any> {
+    // Default cluster configuration
+    const defaultConfig = {
+      concurrency: Cluster.CONCURRENCY_CONTEXT,
+      maxConcurrency: 1,
+      workerCreationDelay: 10000,
+      // Puppeteer options (not configurable for security reasons)
+      // ref) https://github.com/growilabs/growi/pull/10192
+      puppeteerOptions: {
+        args: ['--no-sandbox'],
+      },
+    };
+
+    // Parse configuration from environment variable
+    let customConfig: Record<string, any> = {};
+    if (process.env.PUPPETEER_CLUSTER_CONFIG) {
+      try {
+        customConfig = JSON.parse(process.env.PUPPETEER_CLUSTER_CONFIG);
+      } catch (err) {
+        this.logger.warn(
+          'Failed to parse PUPPETEER_CLUSTER_CONFIG, using default values',
+          err,
+        );
+      }
+    }
+
+    // Remove puppeteerOptions from custom config if present (not allowed for security)
+    if (customConfig.puppeteerOptions) {
+      this.logger.warn(
+        'puppeteerOptions configuration is not allowed for security reasons and will be ignored',
+      );
+      delete customConfig.puppeteerOptions;
+    }
+
+    // Merge configurations (customConfig overrides defaultConfig, except puppeteerOptions)
+    return {
+      ...defaultConfig,
+      ...customConfig,
+    };
+  }
+
   /**
   /**
    * Get parent path from given path
    * Get parent path from given path
    * @param path target path
    * @param path target path

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "7.3.6-slackbot-proxy.0",
+  "version": "7.3.8-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {

+ 3 - 1
biome.json

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

+ 22 - 11
packages/editor/index.html

@@ -1,14 +1,25 @@
 <!DOCTYPE html>
 <!DOCTYPE html>
 <html lang="en">
 <html lang="en">
-  <head>
-    <meta charset="UTF-8" />
-    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
-    <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL@20..48,300,0..1" rel="stylesheet" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Vite + React + TS</title>
-  </head>
-  <body>
-    <div id="root"></div>
-    <script type="module" src="/src/main.tsx"></script>
-  </body>
+
+<head>
+  <meta charset="UTF-8" />
+  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+  <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL@20..48,300,0..1"
+    rel="stylesheet" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>Vite + React + TS</title>
+  <style>
+    @font-face {
+      font-family: 'growi-custom-icons';
+      src: url('../custom-icons/dist/growi-custom-icons.woff2') format('woff2');
+      font-display: block;
+    }
+  </style>
+</head>
+
+<body>
+  <div id="root"></div>
+  <script type="module" src="/src/main.tsx"></script>
+</body>
+
 </html>
 </html>

+ 9 - 0
packages/editor/src/main.scss

@@ -8,4 +8,13 @@
   --font-family-sans-serif: -apple-system, blinkmacsystemfont, 'Hiragino Kaku Gothic ProN', meiryo, sans-serif;
   --font-family-sans-serif: -apple-system, blinkmacsystemfont, 'Hiragino Kaku Gothic ProN', meiryo, sans-serif;
   --font-family-serif: georgia, 'Times New Roman', times, serif;
   --font-family-serif: georgia, 'Times New Roman', times, serif;
   --font-family-monospace: Menlo, Consolas, DejaVu Sans Mono, monospace;
   --font-family-monospace: Menlo, Consolas, DejaVu Sans Mono, monospace;
+  --grw-font-family-custom-icon: 'growi-custom-icons';
+}
+
+.growi-custom-icons {
+  font-family: var(--grw-font-family-custom-icon);
+  font-size: 0.8em;
+  font-style: normal;
+  -webkit-font-smoothing: auto;
+  -moz-osx-font-smoothing: auto;
 }
 }

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