ソースを参照

Merge branch 'dev/7.4.x' into support/156162-174668-app-apiv3-routes-biome-4

Futa Arai 4 ヶ月 前
コミット
39129f4df1
74 ファイル変更1077 行追加1013 行削除
  1. 22 1
      CHANGELOG.md
  2. 1 1
      apps/app/package.json
  3. 5 1
      apps/app/public/static/locales/en_US/admin.json
  4. 5 1
      apps/app/public/static/locales/fr_FR/admin.json
  5. 6 1
      apps/app/public/static/locales/ja_JP/admin.json
  6. 5 1
      apps/app/public/static/locales/ko_KR/admin.json
  7. 5 1
      apps/app/public/static/locales/zh_CN/admin.json
  8. 3 2
      apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx
  9. 9 2
      apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx
  10. 3 1
      apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx
  11. 5 8
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  12. 5 3
      apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx
  13. 5 3
      apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx
  14. 14 11
      apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx
  15. 6 2
      apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx
  16. 20 17
      apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx
  17. 13 11
      apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx
  18. 15 4
      apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx
  19. 6 1
      apps/app/src/client/components/Admin/UserManagement.tsx
  20. 39 0
      apps/app/src/client/components/Admin/Users/UserStatisticsTable.tsx
  21. 27 22
      apps/app/src/client/components/InvitedForm.tsx
  22. 4 4
      apps/app/src/client/components/Page/DisplaySwitcher.tsx
  23. 133 74
      apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx
  24. 0 6
      apps/app/src/client/services/AdminAppContainer.js
  25. 16 3
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  26. 10 18
      apps/app/src/client/services/AdminGitHubSecurityContainer.js
  27. 9 19
      apps/app/src/client/services/AdminGoogleSecurityContainer.js
  28. 27 89
      apps/app/src/client/services/AdminLdapSecurityContainer.js
  29. 12 14
      apps/app/src/client/services/AdminLocalSecurityContainer.js
  30. 39 153
      apps/app/src/client/services/AdminOidcSecurityContainer.js
  31. 15 66
      apps/app/src/client/services/AdminSamlSecurityContainer.js
  32. 10 0
      apps/app/src/client/services/AdminUsersContainer.js
  33. 1 1
      apps/app/src/client/services/side-effects/page-updated.ts
  34. 1 10
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  35. 0 0
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.module.scss
  36. 1 1
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx
  37. 16 2
      apps/app/src/components/Layout/RawLayout.tsx
  38. 8 5
      apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx
  39. 20 13
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  40. 3 4
      apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts
  41. 2 1
      apps/app/src/pages/[[...path]]/index.page.tsx
  42. 12 4
      apps/app/src/pages/[[...path]]/server-side-props.ts
  43. 7 2
      apps/app/src/pages/[[...path]]/use-same-route-navigation.ts
  44. 11 10
      apps/app/src/pages/_document.page.tsx
  45. 1 10
      apps/app/src/pages/admin/customize.page.tsx
  46. 22 1
      apps/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  47. 6 1
      apps/app/src/pages/common-props/commons.ts
  48. 3 4
      apps/app/src/pages/general-page/configuration-props.ts
  49. 0 0
      apps/app/src/pages/me/[[...path]].page.tsx
  50. 3 14
      apps/app/src/pages/share/[[...path]]/index.page.tsx
  51. 47 32
      apps/app/src/pages/share/[[...path]]/page-data-props.ts
  52. 14 5
      apps/app/src/pages/share/[[...path]]/types.ts
  53. 8 2
      apps/app/src/server/crowi/index.js
  54. 0 3
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  55. 72 66
      apps/app/src/server/routes/apiv3/page/index.ts
  56. 39 18
      apps/app/src/server/service/attachment.ts
  57. 1 3
      apps/app/src/server/service/customize.ts
  58. 44 51
      apps/app/src/server/service/file-uploader/aws/index.ts
  59. 21 28
      apps/app/src/server/service/file-uploader/azure.ts
  60. 19 12
      apps/app/src/server/service/file-uploader/file-uploader.ts
  61. 30 43
      apps/app/src/server/service/file-uploader/gcs/index.ts
  62. 42 47
      apps/app/src/server/service/file-uploader/gridfs.ts
  63. 31 44
      apps/app/src/server/service/file-uploader/local.ts
  64. 1 0
      apps/app/src/services/renderer/recommended-whitelist.ts
  65. 3 0
      apps/app/src/states/page/hydrate.ts
  66. 1 0
      apps/pdf-converter/.env
  67. 2 2
      apps/pdf-converter/docker/README.md
  68. 1 1
      apps/pdf-converter/package.json
  69. 48 11
      apps/pdf-converter/src/service/pdf-convert.ts
  70. 1 1
      apps/slackbot-proxy/package.json
  71. 22 11
      packages/editor/index.html
  72. 1 1
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/TableButton.tsx
  73. 9 0
      packages/editor/src/main.scss
  74. 9 9
      pnpm-lock.yaml

+ 22 - 1
CHANGELOG.md

@@ -1,9 +1,30 @@
 # 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.*
 
+## [v7.3.7](https://github.com/growilabs/compare/v7.3.6...v7.3.7) - 2025-11-25
+
+### 💎 Features
+
+* feat(pdf-converter): Enable puppeteer-cluster config of pdf-converter from env var (#10516) @arafubeatbox
+
+### 🐛 Bug Fixes
+
+* fix: Admin form degradation (#10540) @yuki-takei
+
+## [v7.3.6](https://github.com/growilabs/compare/v7.3.5...v7.3.6) - 2025-11-18
+
+### 🐛 Bug Fixes
+
+* fix: Printing styles (#10505) @yuki-takei
+
+### 🧰 Maintenance
+
+* ci(deps): bump js-yaml from 4.1.0 to 4.1.1 (#10511) @[dependabot[bot]](https://github.com/apps/dependabot)
+* support: Configure biome for app routes excluding apiv3 (#10496) @arafubeatbox
+
 ## [v7.3.5](https://github.com/growilabs/compare/v7.3.4...v7.3.5) - 2025-11-10
 
 ### 💎 Features

+ 1 - 1
apps/app/package.json

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

+ 0 - 3
apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -516,9 +516,6 @@ module.exports = (crowi) => {
         bulkExportDownloadExpirationSeconds: configManager.getConfig(
           'app:bulkExportDownloadExpirationSeconds',
         ),
-        // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
-        isBulkExportDisabledForCloud:
-          configManager.getConfig('app:growiCloudUri') != null,
       };
       return res.apiv3({ appSettingsParams });
     },

+ 72 - 66
apps/app/src/server/routes/apiv3/page/index.ts

@@ -192,6 +192,55 @@ module.exports = (crowi: Crowi) => {
       const { pageId, path, findAll, revisionId, shareLinkId, includeEmpty } =
         req.query;
 
+      const respondWithSinglePage = async (
+        pageWithMeta:
+          | IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt>
+          | IDataWithMeta<null, IPageNotFoundInfo>,
+      ) => {
+        let { data: page } = pageWithMeta;
+        const { meta } = pageWithMeta;
+
+        if (isIPageNotFoundInfo(meta)) {
+          if (meta.isForbidden) {
+            return res.apiv3Err(
+              new ErrorV3(
+                'Page is forbidden',
+                'page-is-forbidden',
+                undefined,
+                meta,
+              ),
+              403,
+            );
+          }
+          return res.apiv3Err(
+            new ErrorV3('Page is not found', 'page-not-found', undefined, meta),
+            404,
+          );
+        }
+
+        if (page != null) {
+          try {
+            page.initLatestRevisionField(revisionId);
+
+            // populate
+            page = await page.populateDataToShowRevision();
+          } catch (err) {
+            logger.error('populate-page-failed', err);
+            return res.apiv3Err(
+              new ErrorV3(
+                'Failed to populate page',
+                'populate-page-failed',
+                undefined,
+                { err, meta },
+              ),
+              500,
+            );
+          }
+        }
+
+        return res.apiv3({ page, pages: undefined, meta });
+      };
+
       const isValid =
         (shareLinkId != null && pageId != null && path == null) ||
         (shareLinkId == null && (pageId != null || path != null));
@@ -204,12 +253,6 @@ module.exports = (crowi: Crowi) => {
         );
       }
 
-      let pageWithMeta:
-        | IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt>
-        | IDataWithMeta<null, IPageNotFoundInfo> = {
-        data: null,
-      };
-      let pages: HydratedDocument<PageDocument>[] = [];
       try {
         if (isSharedPage) {
           const shareLink = await ShareLink.findOne({
@@ -218,78 +261,41 @@ module.exports = (crowi: Crowi) => {
           if (shareLink == null) {
             return res.apiv3Err('ShareLink is not found', 404);
           }
-          pageWithMeta = await pageService.findPageAndMetaDataByViewer(
-            getIdStringForRef(shareLink.relatedPage),
-            path,
-            user,
-            true,
-          );
-        } else if (!findAll) {
-          pageWithMeta = await pageService.findPageAndMetaDataByViewer(
-            pageId,
-            path,
-            user,
+          return respondWithSinglePage(
+            await pageService.findPageAndMetaDataByViewer(
+              getIdStringForRef(shareLink.relatedPage),
+              path,
+              user,
+              true,
+            ),
           );
-        } else {
-          pages = await Page.findByPathAndViewer(
+        }
+
+        if (findAll != null) {
+          const pages = await Page.findByPathAndViewer(
             path,
             user,
             null,
             false,
             includeEmpty,
           );
+
+          if (pages.length === 0) {
+            return res.apiv3Err(
+              new ErrorV3('Page is not found', 'page-not-found'),
+              404,
+            );
+          }
+          return res.apiv3({ page: undefined, pages, meta: undefined });
         }
+
+        return respondWithSinglePage(
+          await pageService.findPageAndMetaDataByViewer(pageId, path, user),
+        );
       } catch (err) {
         logger.error('get-page-failed', err);
         return res.apiv3Err(err, 500);
       }
-
-      let { data: page } = pageWithMeta;
-      const { meta } = pageWithMeta;
-
-      // not found or forbidden
-      if (
-        isIPageNotFoundInfo(meta) ||
-        (Array.isArray(pages) && pages.length === 0)
-      ) {
-        if (isIPageNotFoundInfo(meta) && meta.isForbidden) {
-          return res.apiv3Err(
-            new ErrorV3(
-              'Page is forbidden',
-              'page-is-forbidden',
-              undefined,
-              meta,
-            ),
-            403,
-          );
-        }
-        return res.apiv3Err(
-          new ErrorV3('Page is not found', 'page-not-found', undefined, meta),
-          404,
-        );
-      }
-
-      if (page != null) {
-        try {
-          page.initLatestRevisionField(revisionId);
-
-          // populate
-          page = await page.populateDataToShowRevision();
-        } catch (err) {
-          logger.error('populate-page-failed', err);
-          return res.apiv3Err(
-            new ErrorV3(
-              'Failed to populate page',
-              'populate-page-failed',
-              undefined,
-              { err, meta },
-            ),
-            500,
-          );
-        }
-      }
-
-      return res.apiv3({ page, pages, meta });
     },
   );
 

+ 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 type Crowi from '../crowi';
 import { AttachmentType } from '../interfaces/attachment';
+import type { IAttachmentDocument } from '../models/attachment';
 import { Attachment } from '../models/attachment';
 
 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
  */
-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;
   }
 
-  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;
 
     // check limit
@@ -78,7 +97,7 @@ class AttachmentService {
     return attachment;
   }
 
-  async removeAllAttachments(attachments) {
+  async removeAllAttachments(attachments: HydratedDocument<IAttachmentDocument>[]): Promise<void> {
     const { fileUploadService } = this.crowi;
     const attachmentsCollection = mongoose.connection.collection('attachments');
     const unorderAttachmentsBulkOp = attachmentsCollection.initializeUnorderedBulkOp();
@@ -92,15 +111,19 @@ class AttachmentService {
     });
     await unorderAttachmentsBulkOp.execute();
 
-    await fileUploadService.deleteFiles(attachments);
+    fileUploadService.deleteFiles(attachments);
 
     return;
   }
 
-  async removeAttachment(attachmentId) {
+  async removeAttachment(attachmentId: Ref<IAttachment> | undefined): Promise<void> {
     const { fileUploadService } = this.crowi;
     const attachment = await Attachment.findById(attachmentId);
 
+    if (attachment == null) {
+      throw new Error(`Attachment not found: ${attachmentId}`);
+    }
+
     await fileUploadService.deleteFile(attachment);
     await attachment.remove();
 
@@ -117,7 +140,7 @@ class AttachmentService {
     return;
   }
 
-  async isBrandLogoExist() {
+  async isBrandLogoExist(): Promise<boolean> {
     const query = { attachmentType: AttachmentType.BRAND_LOGO };
     const count = await Attachment.countDocuments(query);
 
@@ -128,7 +151,7 @@ class AttachmentService {
    * Register a handler that will be called after attachment creation
    * @param {(pageId: string, attachment: Attachment, file: Express.Multer.File) => Promise<void>} handler
    */
-  addAttachHandler(handler) {
+  addAttachHandler(handler: AttachHandler): void {
     this.attachHandlers.push(handler);
   }
 
@@ -136,10 +159,8 @@ class AttachmentService {
    * Register a handler that will be called before attachment deletion
    * @param {(attachmentId: string) => Promise<void>} handler
    */
-  addDetachHandler(handler) {
+  addDetachHandler(handler: DetachHandler): void {
     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
  */
-class CustomizeService implements S2sMessageHandlable {
+export class CustomizeService implements S2sMessageHandlable {
 
   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
    */
-  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;
   };
 
-  (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 }) {
     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
    */

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

@@ -161,8 +161,27 @@ class AzureFileUploader extends AbstractFileUploader {
   /**
    * @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;
   };
 
-  (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 }) {
     const containerClient = await getContainerClient();
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
@@ -345,12 +344,6 @@ module.exports = (crowi: Crowi) => {
     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() {
     if (!lib.getIsReadable()) {
       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 { Response } from 'express';
+import type { HydratedDocument } from 'mongoose';
 import { v4 as uuidv4 } from 'uuid';
 
 import type { ICheckLimitResult } from '~/interfaces/attachment';
@@ -35,10 +36,11 @@ export interface FileUploader {
   getFileUploadEnabled(): boolean,
   listFiles(): any,
   saveFile(param: SaveFileParam): Promise<any>,
-  deleteFiles(): void,
+  deleteFile(attachment: HydratedDocument<IAttachmentDocument>): void,
+  deleteFiles(attachments: HydratedDocument<IAttachmentDocument>[]): void,
   getFileUploadTotalLimit(): number,
   getTotalFileSize(): Promise<number>,
-  doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult>,
+  checkLimit(uploadFileSize: number): Promise<ICheckLimitResult>,
   determineResponseMode(): ResponseMode,
   uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void>,
   respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
@@ -103,20 +105,16 @@ export abstract class AbstractFileUploader implements FileUploader {
 
   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.
-   * 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
    */
-  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;
   }
 
+  /**
+   * 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
    *
    */
-  async doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult> {
+  protected async doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult> {
     if (uploadFileSize > maxFileSize) {
       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
    */
-  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;
   };
 
-  (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 }) {
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());
@@ -299,18 +298,6 @@ module.exports = function(crowi: Crowi) {
     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
    */

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

@@ -104,8 +104,48 @@ class GridfsFileUploader extends AbstractFileUploader {
   /**
    * @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;
   };
 
-  (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 }) {
     const { attachmentFileModel } = initializeGridFSModels();
 

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

@@ -56,11 +56,34 @@ class LocalFileUploader extends AbstractFileUploader {
   /**
    * @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.');
   }
 
@@ -108,14 +131,14 @@ module.exports = function(crowi: Crowi) {
 
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
-  function getFilePathOnStorage(attachment: IAttachmentDocument) {
+  lib.getFilePathOnStorage = function(attachment: IAttachmentDocument) {
     const dirName = (attachment.page != null)
       ? FilePathOnStoragePrefix.attachment
       : FilePathOnStoragePrefix.user;
     const filePath = path.posix.join(basePath, dirName, attachment.fileName);
 
     return filePath;
-  }
+  };
 
   async function readdirRecursively(dirPath) {
     const directories = await fsPromises.readdir(dirPath, { withFileTypes: true });
@@ -131,34 +154,10 @@ module.exports = function(crowi: Crowi) {
     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) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
-    const filePath = getFilePathOnStorage(attachment);
+    const filePath = lib.getFilePathOnStorage(attachment);
     const dirpath = path.posix.dirname(filePath);
 
     // mkdir -p
@@ -211,7 +210,7 @@ module.exports = function(crowi: Crowi) {
    * @return {stream.Readable} readable stream
    */
   lib.findDeliveryFile = async function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
+    const filePath = lib.getFilePathOnStorage(attachment);
 
     // check file exists
     try {
@@ -225,18 +224,6 @@ module.exports = function(crowi: Crowi) {
     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.
    * @param {Response} res
@@ -244,7 +231,7 @@ module.exports = function(crowi: Crowi) {
    */
   lib.respond = function(res, attachment, opts) {
     // 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 internalPathRoot = configManager.getConfig('fileUpload:local:internalRedirectPath');
     const internalPath = urljoin(internalPathRoot, relativePath);

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

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

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

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

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

@@ -1 +1,2 @@
 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/)
 
 
-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?

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/pdf-converter",
-  "version": "1.1.3-RC.0",
+  "version": "1.2.1-RC.0",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
   "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 { Service } from '@tsed/di';
 import { Logger } from '@tsed/logger';
+import type { PuppeteerNodeLaunchOptions } from 'puppeteer';
 import { Cluster } from 'puppeteer-cluster';
 
 interface PageInfo {
@@ -37,8 +38,6 @@ interface JobInfo {
 class PdfConvertService implements OnInit {
   private puppeteerCluster: Cluster | undefined;
 
-  private maxConcurrency = 1;
-
   private convertRetryLimit = 5;
 
   private tmpOutputRootDir = '/tmp/page-bulk-export';
@@ -292,15 +291,8 @@ class PdfConvertService implements OnInit {
   private async initPuppeteerCluster(): Promise<void> {
     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 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
    * @param path target path

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

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

+ 22 - 11
packages/editor/index.html

@@ -1,14 +1,25 @@
 <!DOCTYPE html>
 <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>

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

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

+ 9 - 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-serif: georgia, 'Times New Roman', times, serif;
   --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;
 }

+ 9 - 9
pnpm-lock.yaml

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