2
0
Эх сурвалжийг харах

Merge branch 'master' into fix/doc-v3-share-links

Yuki Takei 1 жил өмнө
parent
commit
f81a406f43
100 өөрчлөгдсөн 1272 нэмэгдсэн , 610 устгасан
  1. 1 0
      .devcontainer/.gitignore
  2. 8 0
      .devcontainer/compose.yml
  3. 1 1
      .github/workflows/draft-release.yml
  4. 1 1
      .github/workflows/release-rc-scheduled.yml
  5. 1 1
      .github/workflows/release-rc.yml
  6. 3 3
      .github/workflows/release-slackbot-proxy.yml
  7. 3 3
      .github/workflows/release.yml
  8. 1 1
      .github/workflows/reusable-app-create-manifests.yml
  9. 24 1
      CHANGELOG.md
  10. 4 0
      apps/app/.env.development
  11. 4 0
      apps/app/.env.production
  12. 10 2
      apps/app/package.json
  13. 0 3
      apps/app/public/static/locales/en_US/admin.json
  14. 2 1
      apps/app/public/static/locales/en_US/translation.json
  15. 0 3
      apps/app/public/static/locales/fr_FR/admin.json
  16. 2 1
      apps/app/public/static/locales/fr_FR/translation.json
  17. 0 3
      apps/app/public/static/locales/ja_JP/admin.json
  18. 2 1
      apps/app/public/static/locales/ja_JP/translation.json
  19. 0 3
      apps/app/public/static/locales/zh_CN/admin.json
  20. 2 1
      apps/app/public/static/locales/zh_CN/translation.json
  21. 1 1
      apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx
  22. 0 36
      apps/app/src/client/components/Admin/Security/FacebookSecuritySetting.jsx
  23. 0 8
      apps/app/src/client/components/Admin/Security/SecurityManagementContents.jsx
  24. 2 1
      apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx
  25. 10 90
      apps/app/src/client/components/ItemsTree/ItemsTree.tsx
  26. 2 3
      apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx
  27. 0 6
      apps/app/src/client/components/LoginForm/LoginForm.module.scss
  28. 1 1
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  29. 0 6
      apps/app/src/client/components/Me/AssociateModal.tsx
  30. 3 2
      apps/app/src/client/components/Me/DisassociateModal.tsx
  31. 1 3
      apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx
  32. 4 7
      apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx
  33. 2 1
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  34. 8 6
      apps/app/src/client/components/TreeItem/TreeItemLayout.tsx
  35. 2 2
      apps/app/src/client/components/TreeItem/interfaces/index.ts
  36. 1 1
      apps/app/src/client/util/bookmark-utils.ts
  37. 1 1
      apps/app/src/components/ShareLinkPageView/ShareLinkAlert.tsx
  38. 28 28
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  39. 7 6
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  40. 2 1
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  41. 14 13
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  42. 12 10
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts
  43. 2 2
      apps/app/src/features/openai/server/routes/middlewares/certify-ai-service.ts
  44. 16 5
      apps/app/src/features/openai/server/services/assistant/assistant.ts
  45. 1 1
      apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts
  46. 1 1
      apps/app/src/features/openai/server/services/client.ts
  47. 4 4
      apps/app/src/features/openai/server/services/cron/thread-deletion-cron.ts
  48. 4 4
      apps/app/src/features/openai/server/services/cron/vector-store-file-deletion-cron.ts
  49. 1 1
      apps/app/src/features/openai/server/services/is-ai-enabled.ts
  50. 3 3
      apps/app/src/features/openai/server/services/openai.ts
  51. 1 0
      apps/app/src/features/opentelemetry/server/index.ts
  52. 76 0
      apps/app/src/features/opentelemetry/server/logger.ts
  53. 67 0
      apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts
  54. 103 0
      apps/app/src/features/opentelemetry/server/node-sdk.ts
  55. 2 2
      apps/app/src/features/questionnaire/interfaces/condition.ts
  56. 18 0
      apps/app/src/features/questionnaire/interfaces/growi-app-info.ts
  57. 0 58
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  58. 16 3
      apps/app/src/features/questionnaire/interfaces/proactive-questionnaire-answer.ts
  59. 14 4
      apps/app/src/features/questionnaire/interfaces/questionnaire-answer.ts
  60. 3 2
      apps/app/src/features/questionnaire/server/models/questionnaire-order.ts
  61. 2 2
      apps/app/src/features/questionnaire/server/models/schema/condition.ts
  62. 26 10
      apps/app/src/features/questionnaire/server/models/schema/growi-info.ts
  63. 20 11
      apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts
  64. 22 11
      apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts
  65. 301 0
      apps/app/src/features/questionnaire/server/service/questionnaire.integ.ts
  66. 8 70
      apps/app/src/features/questionnaire/server/service/questionnaire.ts
  67. 9 6
      apps/app/src/features/questionnaire/server/util/condition.ts
  68. 128 0
      apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.spec.ts
  69. 40 0
      apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.ts
  70. 2 1
      apps/app/src/features/templates/server/routes/apiv3/index.ts
  71. 1 0
      apps/app/src/interfaces/activity.ts
  72. 13 1
      apps/app/src/interfaces/attachment.ts
  73. 3 2
      apps/app/src/interfaces/crowi-request.ts
  74. 9 0
      apps/app/src/interfaces/external-auth-provider.ts
  75. 0 7
      apps/app/src/interfaces/page-listing-results.ts
  76. 38 0
      apps/app/src/migrations/19700101000000-foremost-1000-20241123211930-remove-index-for-ns-from-configs.js
  77. 24 0
      apps/app/src/migrations/19700101000000-foremost-1010-20250109000000-generate-service-instance-id.js
  78. 2 6
      apps/app/src/migrations/20180927102719-init-serverurl.js
  79. 6 6
      apps/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js
  80. 3 6
      apps/app/src/migrations/20190618104011-add-config-app-installed.js
  81. 0 1
      apps/app/src/migrations/20200512005851-remove-behavior-type.js
  82. 0 1
      apps/app/src/migrations/20200827045151-remove-layout-setting.js
  83. 0 2
      apps/app/src/migrations/20200828024025-copy-aws-setting.js
  84. 0 3
      apps/app/src/migrations/20200901034313-update-mail-transmission.js
  85. 0 1
      apps/app/src/migrations/20200903080025-remove-timeline-type.js.js
  86. 0 5
      apps/app/src/migrations/20220311011114-convert-page-delete-config.js
  87. 0 1
      apps/app/src/migrations/20221014130200-remove-customize-is-saved-states-of-tab-changes.js
  88. 0 1
      apps/app/src/migrations/20230213090921-remove-presentation-configurations.js
  89. 48 43
      apps/app/src/pages/[[...path]].page.tsx
  90. 18 16
      apps/app/src/pages/_private-legacy-pages.page.tsx
  91. 19 17
      apps/app/src/pages/_search.page.tsx
  92. 1 1
      apps/app/src/pages/admin/ai-integration.page.tsx
  93. 2 2
      apps/app/src/pages/admin/audit-log.page.tsx
  94. 3 2
      apps/app/src/pages/admin/customize.page.tsx
  95. 1 1
      apps/app/src/pages/admin/data-transfer.page.tsx
  96. 5 5
      apps/app/src/pages/admin/index.page.tsx
  97. 2 2
      apps/app/src/pages/admin/security.page.tsx
  98. 2 2
      apps/app/src/pages/admin/slack-integration.page.tsx
  99. 1 1
      apps/app/src/pages/installer.page.tsx
  100. 11 12
      apps/app/src/pages/login/index.page.tsx

+ 1 - 0
.devcontainer/.gitignore

@@ -0,0 +1 @@
+.env

+ 8 - 0
.devcontainer/compose.yml

@@ -9,6 +9,9 @@ services:
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../share:/workspace/share:delegated
     tty: true
+    networks:
+    - default
+    - opentelemetry-collector-dev-setup_default
 
   mongo:
     image: mongo:6.0
@@ -45,3 +48,8 @@ volumes:
   pnpm-store:
   node_modules:
   buildcache_app:
+
+networks:
+  default:
+  opentelemetry-collector-dev-setup_default:
+    external: ${OPENTELEMETRY_COLLECTOR_DEV_ENABLED:-false}

+ 1 - 1
.github/workflows/draft-release.yml

@@ -26,7 +26,7 @@ jobs:
       - uses: actions/checkout@v4
 
       - name: Retrieve information from package.json
-        uses: myrotvorets/info-from-package-json-action@2.0.1
+        uses: myrotvorets/info-from-package-json-action@v2.0.2
         id: package-json
 
       - uses: release-drafter/release-drafter@v5

+ 1 - 1
.github/workflows/release-rc-scheduled.yml

@@ -23,7 +23,7 @@ jobs:
     - uses: actions/checkout@v4
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
 
     - name: Docker meta for docker.io

+ 1 - 1
.github/workflows/release-rc.yml

@@ -23,7 +23,7 @@ jobs:
     - uses: actions/checkout@v4
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
 
     - name: Docker meta for docker.io

+ 3 - 3
.github/workflows/release-slackbot-proxy.yml

@@ -17,7 +17,7 @@ jobs:
         ref: ${{ github.event.pull_request.base.ref }}
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       with:
         workingDir: apps/slackbot-proxy
@@ -36,7 +36,7 @@ jobs:
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
     - name: Authenticate to Google Cloud for GROWI.cloud
-      uses: google-github-actions/auth@v1
+      uses: google-github-actions/auth@v2
       with:
         credentials_json: '${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}'
 
@@ -110,7 +110,7 @@ jobs:
         turbo run version:prerelease --filter=@growi/slackbot-proxy
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       with:
         workingDir: apps/slackbot-proxy

+ 3 - 3
.github/workflows/release.yml

@@ -40,7 +40,7 @@ jobs:
         sh ./apps/app/bin/github-actions/update-readme.sh
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
 
     - name: Update Changelog
@@ -86,7 +86,7 @@ jobs:
     - uses: actions/checkout@v4
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
 
     - name: Docker meta for docker.io
@@ -179,7 +179,7 @@ jobs:
         turbo run version:prepatch --filter=@growi/slackbot-proxy
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
 
     - name: Commit

+ 1 - 1
.github/workflows/reusable-app-create-manifests.yml

@@ -38,7 +38,7 @@ jobs:
           type=raw,value=${{ inputs.tag-temporary }}-arm64
 
     - name: Login to Container Registry
-      uses: docker/login-action@v2
+      uses: docker/login-action@v3
       with:
         registry: ${{ inputs.registry }}
         username: wsmoogle

+ 24 - 1
CHANGELOG.md

@@ -1,9 +1,32 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.1.8...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.1.9...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.1.9](https://github.com/weseek/growi/compare/v7.1.8...v7.1.9) - 2025-02-03
+
+### 💎 Features
+
+* feat: Add error handling for data migration (#9582) @miya
+
+### 🚀 Improvement
+
+* imprv: Data migration script performance (#9599) @miya
+* imprv: Initialization for Passport strategies (#9353) @yuki-takei
+* imprv: Make data migration type safe (#9590) @miya
+* imprv: Printing styles (#9576) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Serializing page data for share link (#9602) @yuki-takei
+
+### 🧰 Maintenance
+
+* ci(deps-dev): bump vite from 5.4.6 to 5.4.12 (#9574) @dependabot
+* ci(deps): bump mongoose from 6.13.0 to 6.13.6 (#9570) @dependabot
+* ci(deps): bump katex from 0.16.11 to 0.16.21 (#9564) @dependabot
+
 ## [v7.1.8](https://github.com/weseek/growi/compare/v7.1.7...v7.1.8) - 2025-01-21
 
 ### 🐛 Bug Fixes

+ 4 - 0
apps/app/.env.development

@@ -30,3 +30,7 @@ QUESTIONNAIRE_SERVER_ORIGIN="http://host.docker.internal:3003"
 # AUDIT_LOG_ACTION_GROUP_SIZE=SMALL
 # AUDIT_LOG_ADDITIONAL_ACTIONS=
 # AUDIT_LOG_EXCLUDE_ACTIONS=
+
+# OpenTelemetry Configuration
+OPENTELEMETRY_ENABLED=false
+OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317

+ 4 - 0
apps/app/.env.production

@@ -4,3 +4,7 @@
 ##
 FORMAT_NODE_LOG=false
 MIGRATIONS_DIR=dist/migrations/
+
+# OpenTelemetry Configuration
+OTEL_TRACES_SAMPLER_ARG=0.1
+

+ 10 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.1.9-RC.0",
+  "version": "7.2.0-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -21,7 +21,6 @@
     "dev:pre:styles": "pnpm run pre:styles --mode dev",
     "dev:migrate-mongo": "cross-env NODE_ENV=development pnpm run ts-node node_modules/migrate-mongo/bin/migrate-mongo",
     "dev:migrate": "pnpm run dev:migrate:status > tmp/cache/migration-status.out && pnpm run dev:migrate:up",
-    "dev:migrate:create": "pnpm run dev:migrate-mongo create -f config/migrate-mongo-config.js",
     "dev:migrate:status": "pnpm run dev:migrate-mongo status -f config/migrate-mongo-config.js",
     "dev:migrate:up": "pnpm run dev:migrate-mongo up -f config/migrate-mongo-config.js",
     "dev:migrate:down": "pnpm run dev:migrate-mongo down -f config/migrate-mongo-config.js",
@@ -84,6 +83,15 @@
     "@growi/remark-lsx": "workspace:^",
     "@growi/slack": "workspace:^",
     "@keycloak/keycloak-admin-client": "^18.0.0",
+    "@opentelemetry/api": "^1.9.0",
+    "@opentelemetry/auto-instrumentations-node": "^0.55.1",
+    "@opentelemetry/exporter-metrics-otlp-grpc": "^0.57.0",
+    "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0",
+    "@opentelemetry/resources": "^1.28.0",
+    "@opentelemetry/semantic-conventions": "^1.28.0",
+    "@opentelemetry/sdk-metrics": "^1.28.0",
+    "@opentelemetry/sdk-node": "^0.57.0",
+    "@opentelemetry/sdk-trace-node": "^1.28.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "JSONStream": "^1.3.5",

+ 0 - 3
apps/app/public/static/locales/en_US/admin.json

@@ -184,9 +184,6 @@
         "register_5": "Copy and paste your ClientID and Client Secret above",
         "updated_google": "Succeeded to update Google OAuth setting"
       },
-      "Facebook": {
-        "name": "Facebook OAuth"
-      },
       "GitHub": {
         "enable_github": "Enable GitHub OAuth",
         "name": "GitHub OAuth",

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

@@ -743,7 +743,8 @@
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",
     "you_cannot_move_this_page_now": "You cannot move this page now",
-    "something_went_wrong_with_moving_page": "Something went wrong with moving page"
+    "something_went_wrong_with_moving_page": "Something went wrong with moving page",
+    "error_retrieving_the_pagetree": "Error occurred while retrieving the PageTree"
   },
   "duplicated_page_alert": {
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",

+ 0 - 3
apps/app/public/static/locales/fr_FR/admin.json

@@ -184,9 +184,6 @@
         "register_5": "Copier l'ID client et Secret client ci-dessus",
         "updated_google": "Paramètres mis à jour"
       },
-      "Facebook": {
-        "name": "Facebook OAuth"
-      },
       "GitHub": {
         "enable_github": "Activer GitHub OAuth",
         "name": "GitHub OAuth",

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

@@ -737,7 +737,8 @@
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "Renommage impossible lorsque le titre contient '/'",
     "you_cannot_move_this_page_now": "Déplacement de la page impossible",
-    "something_went_wrong_with_moving_page": "Échec de déplacement de la page"
+    "something_went_wrong_with_moving_page": "Échec de déplacement de la page",
+    "error_retrieving_the_pagetree": "Une erreur s'est produite lors de la récupération de l'arbre des pages"
   },
   "duplicated_page_alert": {
     "same_page_name_exists": "Une page avec ce nom 「{{pageName}}」 existe déjà",

+ 0 - 3
apps/app/public/static/locales/ja_JP/admin.json

@@ -193,9 +193,6 @@
         "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力",
         "updated_google": "Google OAuth を更新しました"
       },
-      "Facebook": {
-        "name": "Facebook OAuth"
-      },
       "GitHub": {
         "enable_github": "GitHub OAuth を有効にする",
         "name": "GitHub OAuth",

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

@@ -775,7 +775,8 @@
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
     "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
-    "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました"
+    "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました",
+    "error_retrieving_the_pagetree": "ページツリーの取得中にエラーが発生しました"
   },
   "duplicated_page_alert": {
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",

+ 0 - 3
apps/app/public/static/locales/zh_CN/admin.json

@@ -193,9 +193,6 @@
 				"register_5": "Copy and paste your ClientID and Client Secret above",
 				"updated_google": "Succeeded to update Google OAuth setting"
 			},
-			"Facebook": {
-				"name": "Facebook OAuth"
-			},
 			"GitHub": {
 				"enable_github": "Enable GitHub OAuth",
 				"name": "GitHub OAuth",

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

@@ -745,7 +745,8 @@
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",
     "you_cannot_move_this_page_now": "你现在不能移动这个页面",
-    "something_went_wrong_with_moving_page": "移动页面时出了问题"
+    "something_went_wrong_with_moving_page": "移动页面时出了问题",
+    "error_retrieving_the_pagetree": "检索页面树时发生错误"
   },
   "duplicated_page_alert": {
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",

+ 1 - 1
apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx

@@ -16,7 +16,7 @@ export const CustomizeTitle: FC = () => {
 
   const { data: customizeTitle } = useCustomizeTitle();
 
-  const [currentCustomizeTitle, setCrrentCustomizeTitle] = useState(customizeTitle);
+  const [currentCustomizeTitle, setCrrentCustomizeTitle] = useState(customizeTitle ?? '');
 
   const onClickSubmit = async() => {
     try {

+ 0 - 36
apps/app/src/client/components/Admin/Security/FacebookSecuritySetting.jsx

@@ -1,36 +0,0 @@
-import React from 'react';
-
-import { withTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-
-class FacebookSecurityManagement extends React.Component {
-
-  render() {
-    const { t } = this.props;
-    return (
-      <>
-        <h2 className="alert-anchor border-bottom">
-          Facebook OAuth { t('admin:security_settings.configuration') }
-        </h2>
-
-        <p className="card custom-card">(TBD)</p>
-      </>
-    );
-  }
-
-}
-
-
-FacebookSecurityManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-};
-
-const FacebookSecurityManagementWrapper = withUnstatedContainers(FacebookSecurityManagement, [AdminGeneralSecurityContainer]);
-
-export default withTranslation()(FacebookSecurityManagementWrapper);

+ 0 - 8
apps/app/src/client/components/Admin/Security/SecurityManagementContents.jsx

@@ -6,7 +6,6 @@ import { TabContent, TabPane } from 'reactstrap';
 
 import CustomNav from '../../CustomNavigation/CustomNav';
 
-// import FacebookSecuritySetting from './FacebookSecuritySetting';
 import GitHubSecuritySetting from './GitHubSecuritySetting';
 import GoogleSecuritySetting from './GoogleSecuritySetting';
 import LdapSecuritySetting from './LdapSecuritySetting';
@@ -53,10 +52,6 @@ const SecurityManagementContents = () => {
         Icon: () => <span className="growi-custom-icons align-bottom">github</span>,
         i18n: 'GitHub',
       },
-      // passport_facebook: {
-      //   Icon: () => <span className="growi-custom-icons align-bottom">facebook</span>,
-      //   i18n: '(TBD) Facebook',
-      // },
     };
   }, []);
 
@@ -114,9 +109,6 @@ const SecurityManagementContents = () => {
           <TabPane tabId="passport_github">
             {activeComponents.has('passport_github') && <GitHubSecuritySetting />}
           </TabPane>
-          {/* <TabPane tabId="passport_facebook">
-            {activeComponents.has('passport_facebook') && <FacebookSecuritySetting />}
-          </TabPane> */}
         </TabContent>
       </div>
     </div>

+ 2 - 1
apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx

@@ -6,6 +6,7 @@ import { useTranslation } from 'next-i18next';
 
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -76,7 +77,7 @@ const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element =>
           </tr>
         </thead>
         <tbody>
-          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount) => {
+          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount<IExternalAuthProviderType>) => {
             return (
               <tr key={ea._id}>
                 <td><span>{ea.providerType}</span></td>

+ 10 - 90
apps/app/src/client/components/ItemsTree/ItemsTree.tsx

@@ -1,17 +1,16 @@
 import React, {
-  useEffect, useMemo, useCallback,
+  useEffect, useCallback,
 } from 'react';
 
 import path from 'path';
 
-import type { Nullable, IPageHasId, IPageToDeleteWithMeta } from '@growi/core';
+import type { IPageToDeleteWithMeta } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IPageForItem } from '~/interfaces/page';
-import type { AncestorsChildrenResult, RootPageResult, TargetAndAncestors } from '~/interfaces/page-listing-results';
 import type { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
@@ -19,7 +18,7 @@ import type { IPageForPageDuplicateModal } from '~/stores/modal';
 import { usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
 import { mutateAllPageInfo, useCurrentPagePath, useSWRMUTxCurrentPage } from '~/stores/page';
 import {
-  useSWRxPageAncestorsChildren, useSWRxRootPage, mutatePageTree, mutatePageList,
+  useSWRxRootPage, mutatePageTree, mutatePageList,
 } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
 import { usePageTreeDescCountMap } from '~/stores/ui';
@@ -35,66 +34,12 @@ const moduleClass = styles['items-tree'] ?? '';
 
 const logger = loggerFactory('growi:cli:ItemsTree');
 
-/*
- * Utility to generate initial node
- */
-const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPageHasId>[]): ItemNode => {
-  const nodes = targetAndAncestors.map((page): ItemNode => {
-    return new ItemNode(page, []);
-  });
-
-  // update children for each node
-  const rootNode = nodes.reduce((child, parent) => {
-    parent.children = [child];
-    return parent;
-  });
-
-  return rootNode;
-};
-
-const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPageHasId>[]>, rootNode: ItemNode): ItemNode => {
-  const paths = Object.keys(ancestorsChildren);
-
-  let currentNode = rootNode;
-  paths.every((path) => {
-    // stop rendering when non-migrated pages found
-    if (currentNode == null) {
-      return false;
-    }
-
-    const childPages = ancestorsChildren[path];
-    currentNode.children = ItemNode.generateNodesFromPages(childPages);
-    const nextNode = currentNode.children.filter((node) => {
-      return paths.includes(node.page.path as string);
-    })[0];
-    currentNode = nextNode;
-    return true;
-  });
-
-  return rootNode;
-};
-
-// user defined typeguard to assert the arg is not null
-type RenderingCondition = {
-  ancestorsChildrenResult: AncestorsChildrenResult | undefined,
-  rootPageResult: RootPageResult | undefined,
-}
-type SecondStageRenderingCondition = {
-  ancestorsChildrenResult: AncestorsChildrenResult,
-  rootPageResult: RootPageResult,
-}
-const isSecondStageRenderingCondition = (condition: RenderingCondition|SecondStageRenderingCondition): condition is SecondStageRenderingCondition => {
-  return condition.ancestorsChildrenResult != null && condition.rootPageResult != null;
-};
-
-
 type ItemsTreeProps = {
   isEnableActions: boolean
   isReadOnlyUser: boolean
   isWipPageShown?: boolean
   targetPath: string
-  targetPathOrId?: Nullable<string>
-  targetAndAncestorsData?: TargetAndAncestors
+  targetPathOrId?: string,
   CustomTreeItem: React.FunctionComponent<TreeItemProps>
   onClickTreeItem?: (page: IPageForItem) => void;
 }
@@ -104,14 +49,13 @@ type ItemsTreeProps = {
  */
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
+    targetPath, targetPathOrId, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
   } = props;
 
   const { t } = useTranslation();
   const router = useRouter();
 
-  const { data: ancestorsChildrenResult, error: error1 } = useSWRxPageAncestorsChildren(targetPath, { suspense: true });
-  const { data: rootPageResult, error: error2 } = useSWRxRootPage({ suspense: true });
+  const { data: rootPageResult, error } = useSWRxRootPage({ suspense: true });
   const { data: currentPagePath } = useCurrentPagePath();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -122,14 +66,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   // for mutation
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
-
-  const renderingCondition = useMemo(() => {
-    return {
-      ancestorsChildrenResult,
-      rootPageResult,
-    };
-  }, [ancestorsChildrenResult, rootPageResult]);
-
   useEffect(() => {
     if (socket == null) {
       return;
@@ -197,34 +133,18 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   }, [currentPagePath, mutateCurrentPage, openDeleteModal, router, t]);
 
 
-  if (error1 != null || error2 != null) {
-    // TODO: improve message
-    toastError('Error occurred while fetching pages to render PageTree');
+  if (error != null) {
+    toastError(t('pagetree.error_retrieving_the_pagetree'));
     return <></>;
   }
 
-  let initialItemNode;
-  /*
-   * Render second stage
-   */
-  if (isSecondStageRenderingCondition(renderingCondition)) {
-    initialItemNode = generateInitialNodeAfterResponse(
-      renderingCondition.ancestorsChildrenResult.ancestorsChildren,
-      new ItemNode(renderingCondition.rootPageResult.rootPage),
-    );
-  }
-  /*
-   * Before swr response comes back
-   */
-  else if (targetAndAncestorsData != null) {
-    initialItemNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-  }
-
+  const initialItemNode = rootPageResult ? new ItemNode(rootPageResult.rootPage) : null;
   if (initialItemNode != null) {
     return (
       <ul className={`${moduleClass} list-group`}>
         <CustomTreeItem
           key={initialItemNode.page.path}
+          targetPath={targetPath}
           targetPathOrId={targetPathOrId}
           itemNode={initialItemNode}
           isOpen

+ 2 - 3
apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx

@@ -1,12 +1,12 @@
 import { useCallback } from 'react';
 
-import { IExternalAuthProviderType } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+
 const authIcon = {
   [IExternalAuthProviderType.google]: <span className="growi-custom-icons align-bottom">google</span>,
   [IExternalAuthProviderType.github]: <span className="growi-custom-icons align-bottom">github</span>,
-  [IExternalAuthProviderType.facebook]: <span className="growi-custom-icons align-bottom">facebook</span>,
   [IExternalAuthProviderType.oidc]: <span className="growi-custom-icons align-bottom">openid</span>,
   [IExternalAuthProviderType.saml]: <span className="material-symbols-outlined align-bottom">key</span>,
 };
@@ -14,7 +14,6 @@ const authIcon = {
 const authLabel = {
   [IExternalAuthProviderType.google]: 'Google',
   [IExternalAuthProviderType.github]: 'GitHub',
-  [IExternalAuthProviderType.facebook]: 'Facebook',
   [IExternalAuthProviderType.oidc]: 'OIDC',
   [IExternalAuthProviderType.saml]: 'SAML',
 };

+ 0 - 6
apps/app/src/client/components/LoginForm/LoginForm.module.scss

@@ -82,12 +82,6 @@
       --bs-btn-active-bg: #{rgba(#403D3E, 0.7)};
     }
 
-    .btn-auth-facebook {
-      --bs-btn-bg: #{rgba(#29487d, 0.4)};
-      --bs-btn-hover-bg: #{rgba(#29487d, 0.9)};
-      --bs-btn-active-bg: #{rgba(#29487d, 0.9)};
-    }
-
     .btn-auth-oidc {
       --bs-btn-bg: #{rgba(#835B1A, 0.4)};
       --bs-btn-hover-bg: #{rgba(#835B1A, 0.8)};

+ 1 - 1
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -2,7 +2,6 @@ import React, {
   useState, useEffect, useCallback,
 } from 'react';
 
-import type { IExternalAuthProviderType } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
@@ -13,6 +12,7 @@ import { useTWithOpt } from '~/client/util/t-with-opt';
 import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import type { IErrorV3 } from '~/interfaces/errors/v3-error';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { toArrayIfNot } from '~/utils/array-utils';
 

+ 0 - 6
apps/app/src/client/components/Me/AssociateModal.tsx

@@ -80,12 +80,6 @@ const AssociateModal = (props: Props): JSX.Element => {
             >
               <span className="growi-custom-icons">google</span> (TBD) Google OAuth
             </NavLink>
-            {/* <NavLink
-              className={`${activeTab === 4 ? 'active' : ''} d-flex gap-1 align-items-center`}
-              onClick={() => setActiveTab(4)}
-            >
-              <span className="growi-custom-icons">facebook</span> (TBD) Facebook
-            </NavLink> */}
           </Nav>
           <TabContent activeTab={activeTab}>
             <TabPane tabId={1}>

+ 3 - 2
apps/app/src/client/components/Me/DisassociateModal.tsx

@@ -1,6 +1,6 @@
 import React, { useCallback } from 'react';
 
-import type { IExternalAccountHasId } from '@growi/core';
+import type { HasObjectId, IExternalAccount } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
@@ -10,12 +10,13 @@ import {
 } from 'reactstrap';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 type Props = {
   isOpen: boolean,
   onClose: () => void,
-  accountForDisassociate: IExternalAccountHasId,
+  accountForDisassociate: IExternalAccount<IExternalAuthProviderType> & HasObjectId,
 }
 
 

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

@@ -13,7 +13,7 @@ import {
 import SimpleBar from 'simplebar-react';
 
 import type { IPageForItem } from '~/interfaces/page';
-import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
 import { usePageSelectModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 
@@ -38,7 +38,6 @@ export const PageSelectModal: FC = () => {
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
-  const { data: targetAndAncestorsData } = useTargetAndAncestors();
   const { data: currentPage } = useSWRxCurrentPage();
 
   const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
@@ -96,7 +95,6 @@ export const PageSelectModal: FC = () => {
                 isReadOnlyUser={!!isReadOnlyUser}
                 targetPath={targetPath}
                 targetPathOrId={targetPathOrId}
-                targetAndAncestorsData={targetAndAncestorsData}
                 onClickTreeItem={onClickTreeItem}
               />
             </div>

+ 4 - 7
apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -5,10 +5,10 @@ import React, {
 import { useTranslation } from 'next-i18next';
 import { debounce } from 'throttle-debounce';
 
-import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import {
-  mutatePageTree, mutateRecentlyUpdated, useSWRxPageAncestorsChildren, useSWRxRootPage, useSWRxV5MigrationStatus,
+  mutatePageTree, mutateRecentlyUpdated, useSWRxRootPage, useSWRxV5MigrationStatus,
 } from '~/stores/page-listing';
 import { useSidebarScrollerRef } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
@@ -99,14 +99,12 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
-  const { data: targetAndAncestorsData } = useTargetAndAncestors();
 
   const { data: migrationStatus } = useSWRxV5MigrationStatus({ suspense: true });
 
   const targetPathOrId = targetId || currentPath;
   const path = currentPath || '/';
 
-  const { data: ancestorsChildrenResult } = useSWRxPageAncestorsChildren(path, { suspense: true });
   const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
   const { data: sidebarScrollerRef } = useSidebarScrollerRef();
   const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
@@ -144,7 +142,7 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
 
   useEffect(() => {
-    if (isInitialScrollCompleted || ancestorsChildrenResult == null || rootPageResult == null) {
+    if (isInitialScrollCompleted || rootPageResult == null) {
       return;
     }
 
@@ -166,7 +164,7 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
     return () => {
       observer.disconnect();
     };
-  }, [isInitialScrollCompleted, scrollOnInitDebounced, ancestorsChildrenResult, rootPageResult]);
+  }, [isInitialScrollCompleted, scrollOnInitDebounced, rootPageResult]);
   // *******************************  end  *******************************
 
 
@@ -189,7 +187,6 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
         isWipPageShown={isWipPageShown}
         targetPath={path}
         targetPathOrId={targetPathOrId}
-        targetAndAncestorsData={targetAndAncestorsData}
         CustomTreeItem={PageTreeItem}
       />
 

+ 2 - 1
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -34,7 +34,7 @@ const moduleClass = styles['page-tree-item'] ?? '';
 
 const logger = loggerFactory('growi:cli:Item');
 
-export const PageTreeItem: FC<TreeItemProps> = (props) => {
+export const PageTreeItem = (props:TreeItemProps): JSX.Element => {
   const router = useRouter();
 
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
@@ -186,6 +186,7 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
   return (
     <TreeItemLayout
       className={moduleClass}
+      targetPath={props.targetPath}
       targetPathOrId={props.targetPathOrId}
       itemLevel={props.itemLevel}
       itemNode={props.itemNode}

+ 8 - 6
apps/app/src/client/components/TreeItem/TreeItemLayout.tsx

@@ -22,21 +22,21 @@ type TreeItemLayoutProps = TreeItemProps & {
   indentSize?: number,
 }
 
-export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
+export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
   const {
     className, itemClassName,
     indentSize = 10,
     itemLevel: baseItemLevel = 1,
-    itemNode, targetPathOrId, isOpen: _isOpen = false,
+    itemNode, targetPath, targetPathOrId, isOpen: _isOpen = false,
     onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, onWheelClick,
     isEnableActions, isReadOnlyUser, isWipPageShown = true,
     itemRef, itemClass,
     showAlternativeContent,
   } = props;
 
-  const { page, children } = itemNode;
+  const { page } = itemNode;
 
-  const [currentChildren, setCurrentChildren] = useState<ItemNode[]>(children);
+  const [currentChildren, setCurrentChildren] = useState<ItemNode[]>([]);
   const [isOpen, setIsOpen] = useState(_isOpen);
 
   const { data } = useSWRxPageChildren(isOpen ? page._id : null);
@@ -84,8 +84,9 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
 
   // didMount
   useEffect(() => {
-    if (hasChildren()) setIsOpen(true);
-  }, [hasChildren]);
+    const isPathToTarget = page.path != null && targetPath.startsWith(page.path) && targetPath !== page.path; // Target Page does not need to be opened
+    if (isPathToTarget) setIsOpen(true);
+  }, [targetPath, page.path]);
 
   /*
    * When swr fetch succeeded
@@ -108,6 +109,7 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
     isReadOnlyUser,
     isOpen: false,
     isWipPageShown,
+    targetPath,
     targetPathOrId,
     onRenamed,
     onClickDuplicateMenuItem,

+ 2 - 2
apps/app/src/client/components/TreeItem/interfaces/index.ts

@@ -1,5 +1,4 @@
 import type { IPageToDeleteWithMeta } from '@growi/core';
-import type { Nullable } from 'vitest';
 
 import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForPageDuplicateModal } from '~/stores/modal';
@@ -23,7 +22,8 @@ export type TreeItemToolProps = TreeItemBaseProps & {
 };
 
 export type TreeItemProps = TreeItemBaseProps & {
-  targetPathOrId?: Nullable<string>,
+  targetPath: string,
+  targetPathOrId?:string,
   isOpen?: boolean,
   isWipPageShown?: boolean,
   itemClass?: React.FunctionComponent<TreeItemProps>,

+ 1 - 1
apps/app/src/client/util/bookmark-utils.ts

@@ -16,7 +16,7 @@ export const addNewFolder = async(name: string, parent: string | null): Promise<
 
 // Put bookmark to a folder
 export const addBookmarkToFolder = async(pageId: string, folderId: string | null): Promise<void> => {
-  await apiv3Post('/bookmark-folder/add-boookmark-to-folder', { pageId, folderId });
+  await apiv3Post('/bookmark-folder/add-bookmark-to-folder', { pageId, folderId });
 };
 
 // Delete bookmark folder

+ 1 - 1
apps/app/src/components/ShareLinkPageView/ShareLinkAlert.tsx

@@ -42,7 +42,7 @@ const ShareLinkAlert: FC<Props> = (props: Props) => {
   return (
     <p className={`alert alert-${alertColor} px-4 d-edit-none`}>
       <span className="material-symbols-outlined me-1">link</span>
-      {(expiredAt === null ? <span>{t('page_page.notice.no_deadline')}</span>
+      {(expiredAt == null ? <span>{t('page_page.notice.no_deadline')}</span>
       // eslint-disable-next-line react/no-danger
         : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
       )}

+ 28 - 28
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -30,7 +30,7 @@ interface AuthorizedRequest extends Request {
 module.exports = (crowi: Crowi): Router => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
-  const addActivity = generateAddActivityMiddleware(crowi);
+  const addActivity = generateAddActivityMiddleware();
 
   const activityEvent = crowi.event('activity');
 
@@ -216,14 +216,14 @@ module.exports = (crowi: Crowi): Router => {
 
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
-      ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),
-      ldapGroupMembershipAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'),
-      ldapGroupMembershipAttributeType: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType'),
-      ldapGroupChildGroupAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute'),
-      autoGenerateUserOnLdapGroupSync: configManager?.getConfig('crowi', 'external-user-group:ldap:autoGenerateUserOnGroupSync'),
-      preserveDeletedLdapGroups: configManager?.getConfig('crowi', 'external-user-group:ldap:preserveDeletedGroups'),
-      ldapGroupNameAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute'),
-      ldapGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute'),
+      ldapGroupSearchBase: configManager.getConfig('external-user-group:ldap:groupSearchBase'),
+      ldapGroupMembershipAttribute: configManager.getConfig('external-user-group:ldap:groupMembershipAttribute'),
+      ldapGroupMembershipAttributeType: configManager.getConfig('external-user-group:ldap:groupMembershipAttributeType'),
+      ldapGroupChildGroupAttribute: configManager.getConfig('external-user-group:ldap:groupChildGroupAttribute'),
+      autoGenerateUserOnLdapGroupSync: configManager.getConfig('external-user-group:ldap:autoGenerateUserOnGroupSync'),
+      preserveDeletedLdapGroups: configManager.getConfig('external-user-group:ldap:preserveDeletedGroups'),
+      ldapGroupNameAttribute: configManager.getConfig('external-user-group:ldap:groupNameAttribute'),
+      ldapGroupDescriptionAttribute: configManager.getConfig('external-user-group:ldap:groupDescriptionAttribute'),
     };
 
     return res.apiv3(settings);
@@ -231,14 +231,14 @@ module.exports = (crowi: Crowi): Router => {
 
   router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
-      keycloakHost: configManager?.getConfig('crowi', 'external-user-group:keycloak:host'),
-      keycloakGroupRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm'),
-      keycloakGroupSyncClientRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm'),
-      keycloakGroupSyncClientID: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID'),
-      keycloakGroupSyncClientSecret: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret'),
-      autoGenerateUserOnKeycloakGroupSync: configManager?.getConfig('crowi', 'external-user-group:keycloak:autoGenerateUserOnGroupSync'),
-      preserveDeletedKeycloakGroups: configManager?.getConfig('crowi', 'external-user-group:keycloak:preserveDeletedGroups'),
-      keycloakGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute'),
+      keycloakHost: configManager.getConfig('external-user-group:keycloak:host'),
+      keycloakGroupRealm: configManager.getConfig('external-user-group:keycloak:groupRealm'),
+      keycloakGroupSyncClientRealm: configManager.getConfig('external-user-group:keycloak:groupSyncClientRealm'),
+      keycloakGroupSyncClientID: configManager.getConfig('external-user-group:keycloak:groupSyncClientID'),
+      keycloakGroupSyncClientSecret: configManager.getConfig('external-user-group:keycloak:groupSyncClientSecret'),
+      autoGenerateUserOnKeycloakGroupSync: configManager.getConfig('external-user-group:keycloak:autoGenerateUserOnGroupSync'),
+      preserveDeletedKeycloakGroups: configManager.getConfig('external-user-group:keycloak:preserveDeletedGroups'),
+      keycloakGroupDescriptionAttribute: configManager.getConfig('external-user-group:keycloak:groupDescriptionAttribute'),
     };
 
     return res.apiv3(settings);
@@ -269,7 +269,7 @@ module.exports = (crowi: Crowi): Router => {
     }
 
     try {
-      await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+      await configManager.updateConfigs(params, { skipPubsub: true });
       return res.apiv3({}, 204);
     }
     catch (err) {
@@ -301,7 +301,7 @@ module.exports = (crowi: Crowi): Router => {
       };
 
       try {
-        await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+        await configManager.updateConfigs(params, { skipPubsub: true });
         return res.apiv3({}, 204);
       }
       catch (err) {
@@ -319,7 +319,7 @@ module.exports = (crowi: Crowi): Router => {
       );
     }
 
-    const isLdapEnabled = await configManager.getConfig('crowi', 'security:passport-ldap:isEnabled');
+    const isLdapEnabled = await configManager.getConfig('security:passport-ldap:isEnabled');
     if (!isLdapEnabled) {
       return res.apiv3Err(
         new ErrorV3('Authentication using ldap is not set', 'external_user_group.ldap.auth_not_set'), 422,
@@ -349,25 +349,25 @@ module.exports = (crowi: Crowi): Router => {
     }
 
     const getAuthProviderType = () => {
-      let kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+      let kcHost = configManager.getConfig('external-user-group:keycloak:host');
       if (kcHost?.endsWith('/')) {
         kcHost = kcHost.slice(0, -1);
       }
-      const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
+      const kcGroupRealm = configManager.getConfig('external-user-group:keycloak:groupRealm');
 
       // starts with kcHost, contains kcGroupRealm in path
       // see: https://regex101.com/r/3ihDmf/1
       const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`);
 
-      const isOidcEnabled = configManager.getConfig('crowi', 'security:passport-oidc:isEnabled');
-      const oidcIssuerHost = configManager.getConfig('crowi', 'security:passport-oidc:issuerHost');
+      const isOidcEnabled = configManager.getConfig('security:passport-oidc:isEnabled');
+      const oidcIssuerHost = configManager.getConfig('security:passport-oidc:issuerHost');
 
-      if (isOidcEnabled && regex.test(oidcIssuerHost)) return 'oidc';
+      if (isOidcEnabled && oidcIssuerHost != null && regex.test(oidcIssuerHost)) return 'oidc';
 
-      const isSamlEnabled = configManager.getConfig('crowi', 'security:passport-saml:isEnabled');
-      const samlEntryPoint = configManager.getConfig('crowi', 'security:passport-saml:entryPoint');
+      const isSamlEnabled = configManager.getConfig('security:passport-saml:isEnabled');
+      const samlEntryPoint = configManager.getConfig('security:passport-saml:entryPoint');
 
-      if (isSamlEnabled && regex.test(samlEntryPoint)) return 'saml';
+      if (isSamlEnabled && samlEntryPoint != null && regex.test(samlEntryPoint)) return 'saml';
 
       return null;
     };

+ 7 - 6
apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts

@@ -3,19 +3,20 @@ import type { IUserHasId } from '@growi/core';
 import { SocketEventName } from '~/interfaces/websocket';
 import ExternalAccount from '~/server/models/external-account';
 import S2sMessage from '~/server/models/vo/s2s-message';
-import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
-import { S2sMessageHandlable } from '~/server/service/s2s-messaging/handlable';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import type { S2sMessageHandlable } from '~/server/service/s2s-messaging/handlable';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
 import { configManager } from '../../../../server/service/config-manager';
 import { externalAccountService } from '../../../../server/service/external-account';
-import {
+import type {
   ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, IExternalUserGroupHasId,
 } from '../../interfaces/external-user-group';
 import ExternalUserGroup from '../models/external-user-group';
 import ExternalUserGroupRelation from '../models/external-user-group-relation';
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 const logger = loggerFactory('growi:service:external-user-group-sync-service');
 
@@ -37,7 +38,7 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
 
   groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
 
-  authProviderType: string | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
+  authProviderType: IExternalAuthProviderType | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
 
   socketIoService: any;
 
@@ -93,7 +94,7 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
     if (this.authProviderType == null) throw new Error('auth provider type is not set');
     if (this.syncStatus.isExecutingSync) throw new Error('External user group sync is already being executed');
 
-    const preserveDeletedLdapGroups: boolean = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
+    const preserveDeletedLdapGroups = configManager.getConfig(`external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
     const existingExternalUserGroupIds: string[] = [];
 
     const socket = this.socketIoService?.getAdminSocket();
@@ -183,7 +184,7 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
     const authProviderType = this.authProviderType;
     if (authProviderType == null) throw new Error('auth provider type is not set');
 
-    const autoGenerateUserOnGroupSync = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
+    const autoGenerateUserOnGroupSync = configManager.getConfig(`external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
 
     const getExternalAccount = async() => {
       if (autoGenerateUserOnGroupSync && externalAccountService != null) {

+ 2 - 1
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts

@@ -146,7 +146,8 @@ describe('KeycloakUserGroupSyncService.generateExternalUserGroupTrees', () => {
   };
 
   beforeAll(async() => {
-    await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+    await configManager.loadConfigs();
+    await configManager.updateConfigs(configParams, { skipPubsub: true });
     keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
     keycloakUserGroupSyncService.init('oidc');
   });

+ 14 - 13
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts

@@ -1,13 +1,14 @@
 import KeycloakAdminClient from '@keycloak/keycloak-admin-client';
-import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation';
-import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
+import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation';
+import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
 
 import { configManager } from '~/server/service/config-manager';
-import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
-import { ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+import type { ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+import { ExternalGroupProviderType } from '../../interfaces/external-user-group';
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
 
@@ -22,9 +23,9 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 
   kcAdminClient: KeycloakAdminClient;
 
-  realm: string; // realm that contains the groups
+  realm: string | undefined; // realm that contains the groups
 
-  groupDescriptionAttribute: string; // attribute to map to group description
+  groupDescriptionAttribute: string | undefined; // attribute to map to group description
 
   isInitialized = false;
 
@@ -34,10 +35,10 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
   }
 
   init(authProviderType: 'oidc' | 'saml'): void {
-    const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
-    const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
-    const kcGroupSyncClientRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm');
-    const kcGroupDescriptionAttribute = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute');
+    const kcHost = configManager.getConfig('external-user-group:keycloak:host');
+    const kcGroupRealm = configManager.getConfig('external-user-group:keycloak:groupRealm');
+    const kcGroupSyncClientRealm = configManager.getConfig('external-user-group:keycloak:groupSyncClientRealm');
+    const kcGroupDescriptionAttribute = configManager.getConfig('external-user-group:keycloak:groupDescriptionAttribute');
 
     this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
     this.realm = kcGroupRealm;
@@ -70,12 +71,12 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
    * Authenticate to group sync client using client credentials grant type
    */
   private async auth(): Promise<void> {
-    const kcGroupSyncClientID: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID');
-    const kcGroupSyncClientSecret: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret');
+    const kcGroupSyncClientID = configManager.getConfig('external-user-group:keycloak:groupSyncClientID');
+    const kcGroupSyncClientSecret = configManager.getConfig('external-user-group:keycloak:groupSyncClientSecret');
 
     await this.kcAdminClient.auth({
       grantType: 'client_credentials',
-      clientId: kcGroupSyncClientID,
+      clientId: kcGroupSyncClientID ?? '',
       clientSecret: kcGroupSyncClientSecret,
     });
   }

+ 12 - 10
apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts

@@ -1,12 +1,14 @@
 import { configManager } from '~/server/service/config-manager';
-import { ldapService, SearchResultEntry } from '~/server/service/ldap';
-import PassportService from '~/server/service/passport';
-import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import type { SearchResultEntry } from '~/server/service/ldap';
+import { ldapService } from '~/server/service/ldap';
+import type PassportService from '~/server/service/passport';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
+import type { ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
 import {
-  ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, LdapGroupMembershipAttributeType,
+  ExternalGroupProviderType, LdapGroupMembershipAttributeType,
 } from '../../interfaces/external-user-group';
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
@@ -47,11 +49,11 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
   }
 
   override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
-    const groupChildGroupAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute');
-    const groupMembershipAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute');
-    const groupNameAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute');
-    const groupDescriptionAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute');
-    const groupBase: string = ldapService.getGroupSearchBase();
+    const groupChildGroupAttribute = configManager.getConfig('external-user-group:ldap:groupChildGroupAttribute');
+    const groupMembershipAttribute = configManager.getConfig('external-user-group:ldap:groupMembershipAttribute');
+    const groupNameAttribute = configManager.getConfig('external-user-group:ldap:groupNameAttribute');
+    const groupDescriptionAttribute = configManager.getConfig('external-user-group:ldap:groupDescriptionAttribute');
+    const groupBase = ldapService.getGroupSearchBase();
 
     const groupEntries = await ldapService.searchGroupDir();
 
@@ -117,7 +119,7 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
   }
 
   private async getUserInfo(userId: string): Promise<ExternalUserInfo | null> {
-    const groupMembershipAttributeType = configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType');
+    const groupMembershipAttributeType = configManager.getConfig('external-user-group:ldap:groupMembershipAttributeType');
     const attrMapUsername = this.passportService.getLdapAttrNameMappedToUsername();
     const attrMapName = this.passportService.getLdapAttrNameMappedToName();
     const attrMapMail = this.passportService.getLdapAttrNameMappedToMail();

+ 2 - 2
apps/app/src/features/openai/server/routes/middlewares/certify-ai-service.ts

@@ -8,7 +8,7 @@ import { OpenaiServiceTypes } from '../../../interfaces/ai';
 const logger = loggerFactory('growi:middlewares:certify-ai-service');
 
 export const certifyAiService = (req: Request, res: Response & { apiv3Err }, next: NextFunction): void => {
-  const aiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
+  const aiEnabled = configManager.getConfig('app:aiEnabled');
 
   if (!aiEnabled) {
     const message = 'AI_ENABLED is not true';
@@ -16,7 +16,7 @@ export const certifyAiService = (req: Request, res: Response & { apiv3Err }, nex
     return res.apiv3Err(message, 403);
   }
 
-  const openaiServiceType = configManager.getConfig('crowi', 'openai:serviceType');
+  const openaiServiceType = configManager.getConfig('openai:serviceType');
   if (openaiServiceType == null || !OpenaiServiceTypes.includes(openaiServiceType)) {
     const message = 'AI_SERVICE_TYPE is missing or contains an invalid value';
     logger.error(message);

+ 16 - 5
apps/app/src/features/openai/server/services/assistant/assistant.ts

@@ -15,9 +15,20 @@ const AssistantDefaultModelMap: Record<AssistantType, OpenAI.Chat.ChatModel> = {
   [AssistantType.CHAT]: 'gpt-4o-mini',
 };
 
+const isValidChatModel = (model: string): model is OpenAI.Chat.ChatModel => {
+  return model.startsWith('gpt-');
+};
+
 const getAssistantModelByType = (type: AssistantType): OpenAI.Chat.ChatModel => {
-  const configKey = `openai:assistantModel:${type.toLowerCase()}`;
-  return configManager.getConfig('crowi', configKey) ?? AssistantDefaultModelMap[type];
+  const configValue = type === AssistantType.SEARCH
+    ? undefined // TODO: add the value for 'openai:assistantModel:search' to config-definition.ts
+    : configManager.getConfig('openai:assistantModel:chat');
+
+  if (typeof configValue === 'string' && isValidChatModel(configValue)) {
+    return configValue;
+  }
+
+  return AssistantDefaultModelMap[type];
 };
 
 type AssistantType = typeof AssistantType[keyof typeof AssistantType];
@@ -45,7 +56,7 @@ const findAssistantByName = async(assistantName: string): Promise<OpenAI.Beta.As
 };
 
 const getOrCreateAssistant = async(type: AssistantType, nameSuffix?: string): Promise<OpenAI.Beta.Assistant> => {
-  const appSiteUrl = configManager.getConfig('crowi', 'app:siteUrl');
+  const appSiteUrl = configManager.getConfig('app:siteUrl');
   const assistantName = `GROWI ${type} Assistant for ${appSiteUrl}${nameSuffix != null ? ` ${nameSuffix}` : ''}`;
   const assistantModel = getAssistantModelByType(type);
 
@@ -57,7 +68,7 @@ const getOrCreateAssistant = async(type: AssistantType, nameSuffix?: string): Pr
       }));
 
   // update instructions
-  const instructions = configManager.getConfig('crowi', 'openai:chatAssistantInstructions');
+  const instructions = configManager.getConfig('openai:chatAssistantInstructions');
   openaiClient.beta.assistants.update(assistant.id, {
     instructions,
     model: assistantModel,
@@ -75,7 +86,7 @@ const getOrCreateAssistant = async(type: AssistantType, nameSuffix?: string): Pr
 
 //   searchAssistant = await getOrCreateAssistant(AssistantType.SEARCH);
 //   openaiClient.beta.assistants.update(searchAssistant.id, {
-//     instructions: configManager.getConfig('crowi', 'openai:searchAssistantInstructions'),
+//     instructions: configManager.getConfig('openai:searchAssistantInstructions'),
 //     tools: [{ type: 'file_search' }],
 //   });
 

+ 1 - 1
apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts

@@ -13,7 +13,7 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
 
   constructor() {
     // Retrieve OpenAI related values from environment variables
-    const apiKey = configManager.getConfig('crowi', 'openai:apiKey');
+    const apiKey = configManager.getConfig('openai:apiKey');
 
     const isValid = [apiKey].every(value => value != null);
     if (!isValid) {

+ 1 - 1
apps/app/src/features/openai/server/services/client.ts

@@ -3,5 +3,5 @@ import OpenAI from 'openai';
 import { configManager } from '~/server/service/config-manager';
 
 export const openaiClient = new OpenAI({
-  apiKey: configManager?.getConfig('crowi', 'openai:apiKey'), // This is the default and can be omitted
+  apiKey: configManager.getConfig('openai:apiKey'), // This is the default and can be omitted
 });

+ 4 - 4
apps/app/src/features/openai/server/services/cron/thread-deletion-cron.ts

@@ -37,10 +37,10 @@ export class ThreadDeletionCronService {
     }
 
     this.openaiService = openaiService;
-    this.threadDeletionCronExpression = configManager.getConfig('crowi', 'openai:threadDeletionCronExpression');
-    this.threadDeletionCronMaxMinutesUntilRequest = configManager.getConfig('crowi', 'app:openaiThreadDeletionCronMaxMinutesUntilRequest');
-    this.threadDeletionBarchSize = configManager.getConfig('crowi', 'openai:threadDeletionBarchSize');
-    this.threadDeletionApiCallInterval = configManager.getConfig('crowi', 'openai:threadDeletionApiCallInterval');
+    this.threadDeletionCronExpression = configManager.getConfig('openai:threadDeletionCronExpression');
+    this.threadDeletionCronMaxMinutesUntilRequest = configManager.getConfig('app:openaiThreadDeletionCronMaxMinutesUntilRequest');
+    this.threadDeletionBarchSize = configManager.getConfig('openai:threadDeletionBarchSize');
+    this.threadDeletionApiCallInterval = configManager.getConfig('openai:threadDeletionApiCallInterval');
 
     this.cronJob?.stop();
     this.cronJob = this.generateCronJob();

+ 4 - 4
apps/app/src/features/openai/server/services/cron/vector-store-file-deletion-cron.ts

@@ -36,10 +36,10 @@ export class VectorStoreFileDeletionCronService {
     }
 
     this.openaiService = openaiService;
-    this.vectorStoreFileDeletionCronExpression = configManager.getConfig('crowi', 'openai:vectorStoreFileDeletionCronExpression');
-    this.vectorStoreFileDeletionCronMaxMinutesUntilRequest = configManager.getConfig('crowi', 'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest');
-    this.vectorStoreFileDeletionBarchSize = configManager.getConfig('crowi', 'openai:vectorStoreFileDeletionBarchSize');
-    this.vectorStoreFileDeletionApiCallInterval = configManager.getConfig('crowi', 'openai:vectorStoreFileDeletionApiCallInterval');
+    this.vectorStoreFileDeletionCronExpression = configManager.getConfig('openai:vectorStoreFileDeletionCronExpression');
+    this.vectorStoreFileDeletionCronMaxMinutesUntilRequest = configManager.getConfig('app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest');
+    this.vectorStoreFileDeletionBarchSize = configManager.getConfig('openai:vectorStoreFileDeletionBarchSize');
+    this.vectorStoreFileDeletionApiCallInterval = configManager.getConfig('openai:vectorStoreFileDeletionApiCallInterval');
 
     this.cronJob?.stop();
     this.cronJob = this.generateCronJob();

+ 1 - 1
apps/app/src/features/openai/server/services/is-ai-enabled.ts

@@ -1,3 +1,3 @@
 import { configManager } from '~/server/service/config-manager';
 
-export const isAiEnabled = (): boolean => configManager.getConfig('crowi', 'app:aiEnabled');
+export const isAiEnabled = (): boolean => configManager.getConfig('app:aiEnabled');

+ 3 - 3
apps/app/src/features/openai/server/services/openai.ts

@@ -50,7 +50,7 @@ export interface IOpenaiService {
 class OpenaiService implements IOpenaiService {
 
   private get client() {
-    const openaiServiceType = configManager.getConfig('crowi', 'openai:serviceType');
+    const openaiServiceType = configManager.getConfig('openai:serviceType');
     return getClient({ openaiServiceType });
   }
 
@@ -364,8 +364,8 @@ export const getOpenaiService = (): IOpenaiService | undefined => {
     return instance;
   }
 
-  const aiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
-  const openaiServiceType = configManager.getConfig('crowi', 'openai:serviceType');
+  const aiEnabled = configManager.getConfig('app:aiEnabled');
+  const openaiServiceType = configManager.getConfig('openai:serviceType');
   if (aiEnabled && openaiServiceType != null && OpenaiServiceTypes.includes(openaiServiceType)) {
     instance = new OpenaiService();
     return instance;

+ 1 - 0
apps/app/src/features/opentelemetry/server/index.ts

@@ -0,0 +1 @@
+export * from './node-sdk';

+ 76 - 0
apps/app/src/features/opentelemetry/server/logger.ts

@@ -0,0 +1,76 @@
+import { diag, type DiagLogger } from '@opentelemetry/api';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:diag');
+
+
+class DiagLoggerBunyanAdapter implements DiagLogger {
+
+  private parseMessage(message: string, args: unknown[]): [logMessage: string, data: object] {
+    let logMessage = message;
+    let data = {};
+
+    // check whether the message is a JSON string
+    try {
+      const parsedMessage = JSON.parse(message);
+      if (typeof parsedMessage === 'object' && parsedMessage !== null) {
+        data = parsedMessage;
+        // if parsed successfully, use 'message' property as log message
+        logMessage = 'message' in data && typeof data.message === 'string'
+          ? data.message
+          : message;
+      }
+    }
+    catch (e) {
+      // do nothing if the message is not a JSON string
+    }
+
+    // merge additional data
+    if (args.length > 0) {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const argsData = (args as any).reduce((acc, arg) => {
+        if (typeof arg === 'string') {
+          try {
+            const parsed = JSON.parse(arg);
+            return { ...acc, ...parsed };
+          }
+          catch (e) {
+            return { ...acc, additionalInfo: arg };
+          }
+        }
+        return { ...acc, ...arg };
+      }, {});
+      data = { ...data, ...argsData };
+    }
+
+    return [logMessage, data];
+  }
+
+  error(message: string, ...args): void {
+    logger.error(...this.parseMessage(message, args));
+  }
+
+  warn(message: string, ...args): void {
+    logger.warn(...this.parseMessage(message, args));
+  }
+
+  info(message: string, ...args): void {
+    logger.info(...this.parseMessage(message, args));
+  }
+
+  debug(message: string, ...args): void {
+    logger.debug(...this.parseMessage(message, args));
+  }
+
+  verbose(message: string, ...args): void {
+    logger.trace(...this.parseMessage(message, args));
+  }
+
+}
+
+
+export const initLogger = (): void => {
+  // Enable global logger for OpenTelemetry
+  diag.setLogger(new DiagLoggerBunyanAdapter());
+};

+ 67 - 0
apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts

@@ -0,0 +1,67 @@
+import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
+import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
+import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
+import { Resource, type IResource } from '@opentelemetry/resources';
+import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
+import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node';
+import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, SEMRESATTRS_SERVICE_INSTANCE_ID } from '@opentelemetry/semantic-conventions';
+
+import { getGrowiVersion } from '~/utils/growi-version';
+
+type Configuration = Partial<NodeSDKConfiguration> & {
+  resource: IResource;
+};
+
+let resource: Resource;
+let configuration: Configuration;
+
+export const generateNodeSDKConfiguration = (serviceInstanceId?: string): Configuration => {
+  if (configuration == null) {
+    const version = getGrowiVersion();
+
+    resource = new Resource({
+      [ATTR_SERVICE_NAME]: 'growi',
+      [ATTR_SERVICE_VERSION]: version,
+    });
+
+    configuration = {
+      resource,
+      traceExporter: new OTLPTraceExporter(),
+      metricReader: new PeriodicExportingMetricReader({
+        exporter: new OTLPMetricExporter(),
+      }),
+      instrumentations: [getNodeAutoInstrumentations({
+        '@opentelemetry/instrumentation-bunyan': {
+          enabled: false,
+        },
+        // disable fs instrumentation since this generates very large amount of traces
+        // see: https://opentelemetry.io/docs/languages/js/libraries/#registration
+        '@opentelemetry/instrumentation-fs': {
+          enabled: false,
+        },
+      })],
+    };
+  }
+
+  if (serviceInstanceId != null) {
+    configuration.resource = resource.merge(new Resource({
+      [SEMRESATTRS_SERVICE_INSTANCE_ID]: serviceInstanceId,
+    }));
+  }
+
+  return configuration;
+};
+
+// public async shutdownInstrumentation(): Promise<void> {
+//   await this.sdkInstance.shutdown();
+
+//   // メモ: 以下の restart コードは動かない
+//   // span/metrics ともに何も出なくなる
+//   // そもそも、restart するような使い方が出来なさそう?
+//   // see: https://github.com/open-telemetry/opentelemetry-specification/issues/27/
+//   // const sdk = new NodeSDK({...});
+//   // sdk.start();
+//   // await sdk.shutdown().catch(console.error);
+//   // const newSdk = new NodeSDK({...});
+//   // newSdk.start();
+// }

+ 103 - 0
apps/app/src/features/opentelemetry/server/node-sdk.ts

@@ -0,0 +1,103 @@
+import { ConfigSource } from '@growi/core/dist/interfaces';
+import type { NodeSDK } from '@opentelemetry/sdk-node';
+
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:server');
+
+
+let sdkInstance: NodeSDK;
+
+/**
+ * Overwrite "OTEL_SDK_DISABLED" env var before sdk.start() is invoked if needed.
+ * Since otel library sees it.
+ */
+function overwriteSdkDisabled(): void {
+  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+
+  if (instrumentationEnabled && (
+    process.env.OTEL_SDK_DISABLED === 'true'
+    || process.env.OTEL_SDK_DISABLED === '1'
+  )) {
+    logger.warn("OTEL_SDK_DISABLED overwritten with 'false' since GROWI's 'otel:enabled' config is true.");
+    process.env.OTEL_SDK_DISABLED = 'false';
+    return;
+  }
+
+  if (!instrumentationEnabled && (
+    process.env.OTEL_SDK_DISABLED === 'false'
+    || process.env.OTEL_SDK_DISABLED === '0'
+  )) {
+    logger.warn("OTEL_SDK_DISABLED is overwritten with 'true' since GROWI's 'otel:enabled' config is false.");
+    process.env.OTEL_SDK_DISABLED = 'true';
+    return;
+  }
+
+}
+
+export const startInstrumentation = async(): Promise<void> => {
+  if (sdkInstance != null) {
+    logger.warn('OpenTelemetry instrumentation already started');
+    return;
+  }
+
+  // load configs from env
+  await configManager.loadConfigs({ source: ConfigSource.env });
+
+  overwriteSdkDisabled();
+
+  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+  if (instrumentationEnabled) {
+
+    logger.info(`GROWI now collects anonymous telemetry.
+
+This data is used to help improve GROWI, but you can opt-out at any time.
+
+For more information, see https://docs.growi.org/en/admin-guide/telemetry.html.
+`);
+
+    // initialize global logger for development
+    const isDev = process.env.NODE_ENV === 'development';
+    if (isDev) {
+      const { initLogger } = await import('./logger');
+      initLogger();
+    }
+
+    // instanciate NodeSDK
+    const { NodeSDK } = await import('@opentelemetry/sdk-node');
+    const { generateNodeSDKConfiguration } = await import('./node-sdk-configuration');
+
+    sdkInstance = new NodeSDK(generateNodeSDKConfiguration());
+    sdkInstance.start();
+  }
+};
+
+export const initServiceInstanceId = async(): Promise<void> => {
+  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+
+  if (instrumentationEnabled) {
+    const { generateNodeSDKConfiguration } = await import('./node-sdk-configuration');
+
+    const serviceInstanceId = configManager.getConfig('otel:serviceInstanceId')
+      ?? configManager.getConfig('app:serviceInstanceId');
+
+    // overwrite resource
+    const updatedResource = generateNodeSDKConfiguration(serviceInstanceId).resource;
+    (sdkInstance as any).resource = updatedResource;
+  }
+};
+
+// public async shutdownInstrumentation(): Promise<void> {
+//   await this.sdkInstance.shutdown();
+
+//   // メモ: 以下の restart コードは動かない
+//   // span/metrics ともに何も出なくなる
+//   // そもそも、restart するような使い方が出来なさそう?
+//   // see: https://github.com/open-telemetry/opentelemetry-specification/issues/27/
+//   // const sdk = new NodeSDK({...});
+//   // sdk.start();
+//   // await sdk.shutdown().catch(console.error);
+//   // const newSdk = new NodeSDK({...});
+//   // newSdk.start();
+// }

+ 2 - 2
apps/app/src/features/questionnaire/interfaces/condition.ts

@@ -1,7 +1,7 @@
 import type { HasObjectId } from '@growi/core';
+import type { GrowiServiceType } from '@growi/core/dist/consts';
 
-import { GrowiServiceType } from './growi-info';
-import { UserType } from './user-info';
+import type { UserType } from './user-info';
 
 
 interface UserCondition {

+ 18 - 0
apps/app/src/features/questionnaire/interfaces/growi-app-info.ts

@@ -0,0 +1,18 @@
+import type { IGrowiAdditionalInfo, IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { AttachmentMethodType } from '~/interfaces/attachment';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+
+
+export type IGrowiAppAdditionalInfo = IGrowiAdditionalInfo & {
+  attachmentType: AttachmentMethodType
+  activeExternalAccountTypes?: IExternalAuthProviderType[]
+}
+
+// legacy properties (extracted from additionalInfo for growi-questionnaire)
+// see: https://gitlab.weseek.co.jp/tech/growi/growi-questionnaire
+export type IGrowiAppInfoLegacy = Omit<IGrowiInfo<IGrowiAppAdditionalInfo>, 'additionalInfo'>
+  & IGrowiAppAdditionalInfo
+  & {
+    appSiteUrlHashed: string,
+  };

+ 0 - 58
apps/app/src/features/questionnaire/interfaces/growi-info.ts

@@ -1,58 +0,0 @@
-import * as os from 'node:os';
-
-import { IExternalAuthProviderType } from '@growi/core';
-
-export const GrowiServiceType = {
-  cloud: 'cloud',
-  privateCloud: 'private-cloud',
-  onPremise: 'on-premise',
-  others: 'others',
-} as const;
-export const GrowiWikiType = { open: 'open', closed: 'closed' } as const;
-export const GrowiAttachmentType = {
-  aws: 'aws',
-  gcs: 'gcs',
-  gcp: 'gcp',
-  azure: 'azure',
-  gridfs: 'gridfs',
-  mongo: 'mongo',
-  mongodb: 'mongodb',
-  local: 'local',
-  none: 'none',
-} as const;
-export const GrowiDeploymentType = {
-  officialHelmChart: 'official-helm-chart',
-  growiDockerCompose: 'growi-docker-compose',
-  node: 'node',
-  others: 'others',
-} as const;
-export const GrowiExternalAuthProviderType = IExternalAuthProviderType;
-
-export type GrowiServiceType = typeof GrowiServiceType[keyof typeof GrowiServiceType]
-type GrowiWikiType = typeof GrowiWikiType[keyof typeof GrowiWikiType]
-export type GrowiAttachmentType = typeof GrowiAttachmentType[keyof typeof GrowiAttachmentType]
-export type GrowiDeploymentType = typeof GrowiDeploymentType[keyof typeof GrowiDeploymentType]
-export type GrowiExternalAuthProviderType = typeof GrowiExternalAuthProviderType[keyof typeof GrowiExternalAuthProviderType]
-
-interface IGrowiOSInfo {
-  type?: ReturnType<typeof os.type>
-  platform?: ReturnType<typeof os.platform>
-  arch?: ReturnType<typeof os.arch>
-  totalmem?: ReturnType<typeof os.totalmem>
-}
-
-export interface IGrowiInfo {
-  version: string
-  appSiteUrl?: string
-  appSiteUrlHashed: string
-  installedAt: Date
-  installedAtByOldestUser: Date
-  type: GrowiServiceType
-  currentUsersCount: number
-  currentActiveUsersCount: number
-  wikiType: GrowiWikiType
-  attachmentType: GrowiAttachmentType
-  activeExternalAccountTypes?: GrowiExternalAuthProviderType[]
-  osInfo?: IGrowiOSInfo
-  deploymentType?: GrowiDeploymentType
-}

+ 16 - 3
apps/app/src/features/questionnaire/interfaces/proactive-questionnaire-answer.ts

@@ -1,11 +1,24 @@
-import { IGrowiInfo } from './growi-info';
-import { IUserInfo } from './user-info';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { IGrowiAppAdditionalInfo, IGrowiAppInfoLegacy } from './growi-app-info';
+import type { IUserInfo } from './user-info';
 
 
 export interface IProactiveQuestionnaireAnswer {
   satisfaction: number,
   commentText: string,
-  growiInfo: IGrowiInfo,
+  growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>,
+  userInfo: IUserInfo,
+  answeredAt: Date,
+  lengthOfExperience?: string,
+  position?: string,
+  occupation?: string,
+}
+
+export interface IProactiveQuestionnaireAnswerLegacy {
+  satisfaction: number,
+  commentText: string,
+  growiInfo: IGrowiAppInfoLegacy,
   userInfo: IUserInfo,
   answeredAt: Date,
   lengthOfExperience?: string,

+ 14 - 4
apps/app/src/features/questionnaire/interfaces/questionnaire-answer.ts

@@ -1,11 +1,21 @@
-import { IAnswer } from './answer';
-import { IGrowiInfo } from './growi-info';
-import { IUserInfo } from './user-info';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { IAnswer } from './answer';
+import type { IGrowiAppAdditionalInfo, IGrowiAppInfoLegacy } from './growi-app-info';
+import type { IUserInfo } from './user-info';
 
 export interface IQuestionnaireAnswer<ID = string> {
   answers: IAnswer[]
   answeredAt: Date
-  growiInfo: IGrowiInfo
+  growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>
+  userInfo: IUserInfo
+  questionnaireOrder: ID
+}
+
+export interface IQuestionnaireAnswerLegacy<ID = string> {
+  answers: IAnswer[]
+  answeredAt: Date
+  growiInfo: IGrowiAppInfoLegacy,
   userInfo: IUserInfo
   questionnaireOrder: ID
 }

+ 3 - 2
apps/app/src/features/questionnaire/server/models/questionnaire-order.ts

@@ -1,8 +1,9 @@
-import { Model, Schema, Document } from 'mongoose';
+import type { Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import type { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
 
 import conditionSchema from './schema/condition';
 import questionSchema from './schema/question';

+ 2 - 2
apps/app/src/features/questionnaire/server/models/schema/condition.ts

@@ -1,7 +1,7 @@
+import { GrowiServiceType } from '@growi/core/dist/consts';
 import { Schema } from 'mongoose';
 
-import { ICondition } from '../../../interfaces/condition';
-import { GrowiServiceType } from '../../../interfaces/growi-info';
+import type { ICondition } from '../../../interfaces/condition';
 import { UserType } from '../../../interfaces/user-info';
 
 const conditionSchema = new Schema<ICondition>({

+ 26 - 10
apps/app/src/features/questionnaire/server/models/schema/growi-info.ts

@@ -1,21 +1,27 @@
+import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+import { GrowiWikiType } from '@growi/core/dist/interfaces';
 import { Schema } from 'mongoose';
 
-import {
-  GrowiAttachmentType, GrowiDeploymentType, GrowiExternalAuthProviderType, GrowiServiceType, GrowiWikiType, IGrowiInfo,
-} from '../../../interfaces/growi-info';
+import type { IGrowiAppAdditionalInfo } from '~/features/questionnaire/interfaces/growi-app-info';
+import { AttachmentMethodType } from '~/interfaces/attachment';
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
-export const growiInfoSchema = new Schema<IGrowiInfo>({
-  version: { type: String, required: true },
-  appSiteUrl: { type: String },
-  appSiteUrlHashed: { type: String, required: true },
+const growiAdditionalInfoSchema = new Schema<IGrowiAppAdditionalInfo>({
   installedAt: { type: Date, required: true },
   installedAtByOldestUser: { type: Date, required: true },
-  type: { type: String, required: true, enum: Object.values(GrowiServiceType) },
   currentUsersCount: { type: Number, required: true },
   currentActiveUsersCount: { type: Number, required: true },
+  attachmentType: { type: String, required: true, enum: Object.values(AttachmentMethodType) },
+  activeExternalAccountTypes: [{ type: String, enum: Object.values(IExternalAuthProviderType) }],
+});
+
+export const growiInfoSchema = new Schema<IGrowiInfo<IGrowiAppAdditionalInfo> & IGrowiAppAdditionalInfo>({
+  version: { type: String, required: true },
+  appSiteUrl: { type: String },
+  serviceInstanceId: { type: String, required: true },
+  type: { type: String, required: true, enum: Object.values(GrowiServiceType) },
   wikiType: { type: String, required: true, enum: Object.values(GrowiWikiType) },
-  attachmentType: { type: String, required: true, enum: Object.values(GrowiAttachmentType) },
-  activeExternalAccountTypes: [{ type: String, enum: Object.values(GrowiExternalAuthProviderType) }],
   osInfo: {
     type: { type: String },
     platform: String,
@@ -23,4 +29,14 @@ export const growiInfoSchema = new Schema<IGrowiInfo>({
     totalmem: Number,
   },
   deploymentType: { type: String, enum: (<(string | null)[]>Object.values(GrowiDeploymentType)).concat([null]) },
+  additionalInfo: growiAdditionalInfoSchema,
+
+  // legacy properties (extracted from additionalInfo for growi-questionnaire)
+  // see: https://gitlab.weseek.co.jp/tech/growi/growi-questionnaire
+  installedAt: { type: Date },
+  installedAtByOldestUser: { type: Date },
+  currentUsersCount: { type: Number },
+  currentActiveUsersCount: { type: Number },
+  attachmentType: { type: String, enum: Object.values(AttachmentMethodType) },
+  activeExternalAccountTypes: [{ type: String, enum: Object.values(IExternalAuthProviderType) }],
 });

+ 20 - 11
apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts

@@ -6,6 +6,8 @@ import { body, validationResult } from 'express-validator';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import { configManager } from '~/server/service/config-manager';
+import { growiInfoService } from '~/server/service/growi-info';
 import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 
@@ -16,6 +18,7 @@ import { StatusType } from '../../../interfaces/questionnaire-answer-status';
 import ProactiveQuestionnaireAnswer from '../../models/proactive-questionnaire-answer';
 import QuestionnaireAnswer from '../../models/questionnaire-answer';
 import QuestionnaireAnswerStatus from '../../models/questionnaire-answer-status';
+import { convertToLegacyFormat, getSiteUrlHashed } from '../../util/convert-to-legacy-format';
 
 
 const logger = loggerFactory('growi:routes:apiv3:questionnaire');
@@ -59,8 +62,8 @@ module.exports = (crowi: Crowi): Router => {
   };
 
   router.get('/orders', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const growiInfo = await crowi.questionnaireService!.getGrowiInfo();
-    const userInfo = crowi.questionnaireService!.getUserInfo(req.user ?? null, growiInfo.appSiteUrlHashed);
+    const growiInfo = await growiInfoService.getGrowiInfo(true);
+    const userInfo = crowi.questionnaireService.getUserInfo(req.user ?? null, getSiteUrlHashed(growiInfo.appSiteUrl));
 
     try {
       const questionnaireOrders = await crowi.questionnaireService!.getQuestionnaireOrdersToShow(userInfo, growiInfo, req.user?._id ?? null);
@@ -74,15 +77,16 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.get('/is-enabled', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const isEnabled = crowi.configManager!.getConfig('crowi', 'questionnaire:isQuestionnaireEnabled');
+    const isEnabled = configManager.getConfig('questionnaire:isQuestionnaireEnabled');
     return res.apiv3({ isEnabled });
   });
 
   router.post('/proactive/answer', accessTokenParser, loginRequired, validators.proactiveAnswer, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const sendQuestionnaireAnswer = async() => {
-      const questionnaireServerOrigin = crowi.configManager?.getConfig('crowi', 'app:questionnaireServerOrigin');
-      const growiInfo = await crowi.questionnaireService!.getGrowiInfo();
-      const userInfo = crowi.questionnaireService!.getUserInfo(req.user ?? null, growiInfo.appSiteUrlHashed);
+      const questionnaireServerOrigin = configManager.getConfig('app:questionnaireServerOrigin');
+      const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
+      const growiInfo = await growiInfoService.getGrowiInfo(true);
+      const userInfo = crowi.questionnaireService.getUserInfo(req.user ?? null, getSiteUrlHashed(growiInfo.appSiteUrl));
 
       const proactiveQuestionnaireAnswer: IProactiveQuestionnaireAnswer = {
         satisfaction: req.body.satisfaction,
@@ -95,8 +99,10 @@ module.exports = (crowi: Crowi): Router => {
         answeredAt: new Date(),
       };
 
+      const proactiveQuestionnaireAnswerLegacy = convertToLegacyFormat(proactiveQuestionnaireAnswer, isAppSiteUrlHashed);
+
       try {
-        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive`, proactiveQuestionnaireAnswer);
+        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive`, proactiveQuestionnaireAnswerLegacy);
       }
       catch (err) {
         if (err.request != null) {
@@ -126,9 +132,10 @@ module.exports = (crowi: Crowi): Router => {
 
   router.put('/answer', accessTokenParser, loginRequired, validators.answer, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const sendQuestionnaireAnswer = async(user: IUserHasId, answers: IAnswer[]) => {
-      const questionnaireServerOrigin = crowi.configManager?.getConfig('crowi', 'app:questionnaireServerOrigin');
-      const growiInfo = await crowi.questionnaireService!.getGrowiInfo();
-      const userInfo = crowi.questionnaireService!.getUserInfo(user, growiInfo.appSiteUrlHashed);
+      const questionnaireServerOrigin = crowi.configManager.getConfig('app:questionnaireServerOrigin');
+      const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
+      const growiInfo = await growiInfoService.getGrowiInfo(true);
+      const userInfo = crowi.questionnaireService.getUserInfo(user, getSiteUrlHashed(growiInfo.appSiteUrl));
 
       const questionnaireAnswer: IQuestionnaireAnswer = {
         growiInfo,
@@ -138,8 +145,10 @@ module.exports = (crowi: Crowi): Router => {
         questionnaireOrder: req.body.questionnaireOrderId,
       };
 
+      const questionnaireAnswerLegacy = convertToLegacyFormat(questionnaireAnswer, isAppSiteUrlHashed);
+
       try {
-        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer`, questionnaireAnswer);
+        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer`, questionnaireAnswerLegacy);
       }
       catch (err) {
         if (err.request != null) {

+ 22 - 11
apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts

@@ -1,14 +1,17 @@
 import axiosRetry from 'axios-retry';
 
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import { getRandomIntInRange } from '~/utils/rand';
 
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
-import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import type { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
 import ProactiveQuestionnaireAnswer from '../models/proactive-questionnaire-answer';
 import QuestionnaireAnswer from '../models/questionnaire-answer';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
 import QuestionnaireOrder from '../models/questionnaire-order';
+import { convertToLegacyFormat } from '../util/convert-to-legacy-format';
 
 const logger = loggerFactory('growi:service:questionnaire-cron');
 
@@ -26,20 +29,19 @@ axiosRetry(axios, { retries: 3 });
  */
 class QuestionnaireCronService {
 
-  crowi: any;
+  crowi: Crowi;
 
   cronJob: any;
 
-  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(crowi) {
+  constructor(crowi: Crowi) {
     this.crowi = crowi;
   }
 
   sleep = (msec: number): Promise<void> => new Promise(resolve => setTimeout(resolve, msec));
 
   startCron(): void {
-    const cronSchedule = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireCronSchedule');
-    const maxHoursUntilRequest = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireCronMaxHoursUntilRequest');
+    const cronSchedule = this.crowi.configManager.getConfig('app:questionnaireCronSchedule');
+    const maxHoursUntilRequest = this.crowi.configManager.getConfig('app:questionnaireCronMaxHoursUntilRequest');
 
     const maxSecondsUntilRequest = maxHoursUntilRequest * 60 * 60;
 
@@ -53,7 +55,8 @@ class QuestionnaireCronService {
   }
 
   async executeJob(): Promise<void> {
-    const questionnaireServerOrigin = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireServerOrigin');
+    const questionnaireServerOrigin = configManager.getConfig('app:questionnaireServerOrigin');
+    const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
 
     const fetchQuestionnaireOrders = async(): Promise<IQuestionnaireOrder[]> => {
       const response = await axios.get(`${questionnaireServerOrigin}/questionnaire-order/index`);
@@ -75,15 +78,23 @@ class QuestionnaireCronService {
 
     const resendQuestionnaireAnswers = async() => {
       const questionnaireAnswers = await QuestionnaireAnswer.find()
-        .select('-_id -answers._id  -growiInfo._id -userInfo._id');
+        .select('-_id -answers._id  -growiInfo._id -userInfo._id')
+        .lean();
       const proactiveQuestionnaireAnswers = await ProactiveQuestionnaireAnswer.find()
-        .select('-_id -growiInfo._id -userInfo._id');
+        .select('-_id -growiInfo._id -userInfo._id')
+        .lean();
 
-      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/batch`, { questionnaireAnswers })
+      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/batch`, {
+        // convert to legacy format
+        questionnaireAnswers: questionnaireAnswers.map(answer => convertToLegacyFormat(answer, isAppSiteUrlHashed)),
+      })
         .then(async() => {
           await QuestionnaireAnswer.deleteMany();
         });
-      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive/batch`, { proactiveQuestionnaireAnswers })
+      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive/batch`, {
+        // convert to legacy format
+        proactiveQuestionnaireAnswers: proactiveQuestionnaireAnswers.map(answer => convertToLegacyFormat(answer, isAppSiteUrlHashed)),
+      })
         .then(async() => {
           await ProactiveQuestionnaireAnswer.deleteMany();
         });

+ 301 - 0
apps/app/src/features/questionnaire/server/service/questionnaire.integ.ts

@@ -0,0 +1,301 @@
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+import { mock } from 'vitest-mock-extended';
+
+import pkg from '^/package.json';
+
+
+import type UserEvent from '~/server/events/user';
+import { configManager } from '~/server/service/config-manager';
+
+import type Crowi from '../../../../server/crowi';
+import type { IGrowiAppAdditionalInfo } from '../../interfaces/growi-app-info';
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import { UserType } from '../../interfaces/user-info';
+import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
+import QuestionnaireOrder from '../models/questionnaire-order';
+
+import QuestionnaireService from './questionnaire';
+
+
+describe('QuestionnaireService', () => {
+  const appVersion = pkg.version;
+
+  let questionnaireService: QuestionnaireService;
+
+  let User;
+  let user;
+
+  beforeAll(async() => {
+
+    await configManager.loadConfigs();
+
+    const crowiMock = mock<Crowi>({
+      version: appVersion,
+      event: vi.fn().mockImplementation((eventName) => {
+        if (eventName === 'user') {
+          return mock<UserEvent>({
+            on: vi.fn(),
+          });
+        }
+      }),
+    });
+    const userModelFactory = (await import('~/server/models/user')).default;
+    User = userModelFactory(crowiMock);
+
+    await User.deleteMany({}); // clear users
+    user = await User.create({
+      name: 'Example for Questionnaire Service Test',
+      username: 'questionnaire test user',
+      email: 'questionnaireTestUser@example.com',
+      password: 'usertestpass',
+      createdAt: '2000-01-01',
+    });
+
+    questionnaireService = new QuestionnaireService(crowiMock);
+  });
+
+  describe('getUserInfo', () => {
+    test('Should get correct user info when user given', () => {
+      const userInfo = questionnaireService.getUserInfo(user, 'growiurlhashfortest');
+      expect(userInfo).not.toBeNull();
+      assert(userInfo != null);
+
+      expect(userInfo.type).equal(UserType.general);
+      assert(userInfo.type === UserType.general);
+
+      expect(userInfo.userIdHash).toBeTruthy();
+      expect(userInfo.userIdHash).not.toBe(user._id);
+
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      delete (userInfo as any).userIdHash;
+
+      expect(userInfo).toEqual({ type: 'general', userCreatedAt: new Date('2000-01-01') });
+    });
+
+    test('Should get correct user info when user is null', () => {
+      const userInfo = questionnaireService.getUserInfo(null, '');
+      expect(userInfo).toEqual({ type: 'guest' });
+    });
+  });
+
+  describe('getQuestionnaireOrdersToShow', () => {
+    let doc1;
+    let doc2;
+    let doc3;
+    let doc4;
+    let doc5;
+    let doc6;
+    let doc7;
+    let doc8;
+    let doc9;
+    let doc10;
+    let doc11;
+    let doc12;
+
+    beforeAll(async() => {
+      const questionnaireToBeShown = {
+        shortTitle: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        title: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        showFrom: '2022-12-11',
+        showUntil: '2100-12-12',
+        condition: {
+          user: {
+            types: ['general'],
+            daysSinceCreation: {
+              moreThanOrEqualTo: 365,
+              lessThanOrEqualTo: 365 * 1000,
+            },
+          },
+          growi: {
+            types: ['on-premise'],
+            versionRegExps: [appVersion],
+          },
+        },
+        createdAt: '2023-01-01',
+        updatedAt: '2023-01-01',
+      };
+
+      // insert initial db data
+      doc1 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert finished data
+      doc2 = await QuestionnaireOrder.create({
+        ...questionnaireToBeShown,
+        showFrom: '2020-12-11',
+        showUntil: '2021-12-12',
+      });
+      // insert data for admin or guest
+      doc3 = await QuestionnaireOrder.create({
+        ...questionnaireToBeShown,
+        condition: {
+          user: {
+            types: ['admin', 'guest'],
+          },
+          growi: {
+            types: ['on-premise'],
+            versionRegExps: [appVersion],
+          },
+        },
+      });
+      // insert answered data
+      doc4 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert skipped data
+      doc5 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert denied data
+      doc6 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert data for different growi type
+      doc7 = await QuestionnaireOrder.create(
+        {
+          ...questionnaireToBeShown,
+          condition: {
+            user: {
+              types: ['general'],
+            },
+            growi: {
+              types: ['cloud'],
+              versionRegExps: [appVersion],
+            },
+          },
+        },
+      );
+      // insert data for different growi version
+      doc8 = await QuestionnaireOrder.create(
+        {
+          ...questionnaireToBeShown,
+          condition: {
+            user: {
+              types: ['general'],
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: ['1.0.0-alpha'],
+            },
+          },
+        },
+      );
+      // insert data for users that used GROWI for less than or equal to a year
+      doc9 = await QuestionnaireOrder.create(
+        {
+          ...questionnaireToBeShown,
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                lessThanOrEqualTo: 365,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [appVersion],
+            },
+          },
+        },
+      );
+      // insert data for users that used GROWI for more than or equal to 1000 years
+      doc10 = await QuestionnaireOrder.create(
+        {
+          ...questionnaireToBeShown,
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                moreThanOrEqualTo: 365 * 1000,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [appVersion],
+            },
+          },
+        },
+      );
+      // insert data for users that used GROWI for more than a month and less than 6 months
+      doc11 = await QuestionnaireOrder.create(
+        {
+          ...questionnaireToBeShown,
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                moreThanOrEqualTo: 30,
+                lessThanOrEqualTo: 30 * 6,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [appVersion],
+            },
+          },
+        },
+      );
+
+      await QuestionnaireAnswerStatus.insertMany([
+        {
+          user: user._id,
+          questionnaireOrderId: doc4._id,
+          status: StatusType.answered,
+        },
+        {
+          user: user._id,
+          questionnaireOrderId: doc5._id,
+          status: StatusType.skipped,
+        },
+        {
+          user: user._id,
+          questionnaireOrderId: doc6._id,
+          status: StatusType.skipped,
+        },
+      ]);
+    });
+
+    test('Should get questionnaire orders to show', async() => {
+      const growiInfo = mock<IGrowiInfo<IGrowiAppAdditionalInfo>>({
+        type: 'on-premise',
+        version: appVersion,
+      });
+      const userInfo = questionnaireService.getUserInfo(user, 'appSiteUrlHashed');
+
+      const questionnaireOrderDocuments = await questionnaireService.getQuestionnaireOrdersToShow(userInfo, growiInfo, user._id);
+
+      expect(questionnaireOrderDocuments[0].toObject()).toMatchObject(
+        {
+          __v: 0,
+          shortTitle: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          title: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          showFrom: new Date('2022-12-11'),
+          showUntil: new Date('2100-12-12'),
+          questions: [],
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                moreThanOrEqualTo: 365,
+                lessThanOrEqualTo: 365 * 1000,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [appVersion],
+            },
+          },
+          createdAt: new Date('2023-01-01'),
+          updatedAt: new Date('2023-01-01'),
+        },
+      );
+
+    });
+
+  });
+
+});

+ 8 - 70
apps/app/src/features/questionnaire/server/service/questionnaire.ts

@@ -1,18 +1,13 @@
 import crypto from 'crypto';
-import * as os from 'node:os';
 
 import type { IUserHasId } from '@growi/core';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
 
+import type Crowi from '~/server/crowi';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
-// eslint-disable-next-line import/no-named-as-default
-import { Config } from '~/server/models/config';
-import { aclService } from '~/server/service/acl';
 import loggerFactory from '~/utils/logger';
 
-import type { IGrowiInfo } from '../../interfaces/growi-info';
-import {
-  GrowiWikiType, GrowiExternalAuthProviderType, GrowiServiceType, GrowiAttachmentType, GrowiDeploymentType,
-} from '../../interfaces/growi-info';
+import type { IGrowiAppAdditionalInfo } from '../../interfaces/growi-app-info';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
 import { type IUserInfo, UserType } from '../../interfaces/user-info';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
@@ -25,72 +20,13 @@ const logger = loggerFactory('growi:service:questionnaire');
 
 class QuestionnaireService {
 
-  crowi: any;
+  crowi: Crowi;
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(crowi) {
+  constructor(crowi: Crowi) {
     this.crowi = crowi;
   }
 
-  async getGrowiInfo(): Promise<IGrowiInfo> {
-    const User = this.crowi.model('User');
-
-    const appSiteUrl = this.crowi.appService.getSiteUrl();
-    const hasher = crypto.createHash('sha256');
-    hasher.update(appSiteUrl);
-    const appSiteUrlHashed = hasher.digest('hex');
-
-    // Get the oldest user who probably installed this GROWI.
-    // https://mongoosejs.com/docs/6.x/docs/api.html#model_Model-findOne
-    // https://stackoverflow.com/questions/13443069/mongoose-findone-with-sorting
-    const user = await User.findOne({ createdAt: { $ne: null } }).sort({ createdAt: 1 });
-
-    const installedAtByOldestUser = user ? user.createdAt : null;
-
-    const appInstalledConfig = await Config.findOne({ key: 'app:installed' });
-    const installedAt = appInstalledConfig != null && appInstalledConfig.createdAt != null ? appInstalledConfig.createdAt : installedAtByOldestUser;
-
-    const currentUsersCount = await User.countDocuments();
-    const currentActiveUsersCount = await User.countActiveUsers();
-
-    const isGuestAllowedToRead = aclService.isGuestAllowedToRead();
-    const wikiType = isGuestAllowedToRead ? GrowiWikiType.open : GrowiWikiType.closed;
-
-    const activeExternalAccountTypes: GrowiExternalAuthProviderType[] = Object.values(GrowiExternalAuthProviderType).filter((type) => {
-      return this.crowi.configManager.getConfig('crowi', `security:passport-${type}:isEnabled`);
-    });
-
-    const typeStr = this.crowi.configManager.getConfig('crowi', 'app:serviceType');
-    const type = Object.values(GrowiServiceType).includes(typeStr) ? typeStr : null;
-
-    const attachmentTypeStr = this.crowi.configManager.getConfig('crowi', 'app:fileUploadType');
-    const attachmentType = Object.values(GrowiAttachmentType).includes(attachmentTypeStr) ? attachmentTypeStr : null;
-
-    const deploymentTypeStr = this.crowi.configManager.getConfig('crowi', 'app:deploymentType');
-    const deploymentType = Object.values(GrowiDeploymentType).includes(deploymentTypeStr) ? deploymentTypeStr : null;
-
-    return {
-      version: this.crowi.version,
-      osInfo: {
-        type: os.type(),
-        platform: os.platform(),
-        arch: os.arch(),
-        totalmem: os.totalmem(),
-      },
-      appSiteUrl: this.crowi.configManager.getConfig('crowi', 'questionnaire:isAppSiteUrlHashed') ? null : appSiteUrl,
-      appSiteUrlHashed,
-      installedAt,
-      installedAtByOldestUser,
-      type,
-      currentUsersCount,
-      currentActiveUsersCount,
-      wikiType,
-      attachmentType,
-      activeExternalAccountTypes,
-      deploymentType,
-    };
-  }
-
   getUserInfo(user: IUserHasId | null, appSiteUrlHashed: string): IUserInfo {
     if (user != null) {
       const hasher = crypto.createHmac('sha256', appSiteUrlHashed);
@@ -106,7 +42,9 @@ class QuestionnaireService {
     return { type: UserType.guest };
   }
 
-  async getQuestionnaireOrdersToShow(userInfo: IUserInfo, growiInfo: IGrowiInfo, userId: ObjectIdLike | null): Promise<QuestionnaireOrderDocument[]> {
+  async getQuestionnaireOrdersToShow(
+      userInfo: IUserInfo, growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>, userId: ObjectIdLike | null,
+  ): Promise<QuestionnaireOrderDocument[]> {
     const currentDate = new Date();
 
     let questionnaireOrders = await QuestionnaireOrder.find({

+ 9 - 6
apps/app/src/features/questionnaire/server/util/condition.ts

@@ -1,7 +1,10 @@
-import { ICondition } from '../../interfaces/condition';
-import { IGrowiInfo } from '../../interfaces/growi-info';
-import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
-import { IUserInfo, UserType } from '../../interfaces/user-info';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { ICondition } from '../../interfaces/condition';
+import type { IGrowiAppAdditionalInfo } from '../../interfaces/growi-app-info';
+import type { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import type { IUserInfo } from '../../interfaces/user-info';
+import { UserType } from '../../interfaces/user-info';
 
 
 const checkUserInfo = (condition: ICondition, userInfo: IUserInfo): boolean => {
@@ -39,7 +42,7 @@ const checkUserInfo = (condition: ICondition, userInfo: IUserInfo): boolean => {
   return true;
 };
 
-const checkGrowiInfo = (condition: ICondition, growiInfo: IGrowiInfo): boolean => {
+const checkGrowiInfo = (condition: ICondition, growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>): boolean => {
   const { growi: { types, versionRegExps } } = condition;
 
   if (!types.includes(growiInfo.type)) {
@@ -53,7 +56,7 @@ const checkGrowiInfo = (condition: ICondition, growiInfo: IGrowiInfo): boolean =
   return true;
 };
 
-export const isShowableCondition = (order: IQuestionnaireOrder, userInfo: IUserInfo, growiInfo: IGrowiInfo): boolean => {
+export const isShowableCondition = (order: IQuestionnaireOrder, userInfo: IUserInfo, growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>): boolean => {
   const { condition } = order;
 
   if (!checkUserInfo(condition, userInfo)) {

+ 128 - 0
apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.spec.ts

@@ -0,0 +1,128 @@
+import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+import { GrowiWikiType } from '@growi/core/dist/interfaces';
+import {
+  describe, test, expect,
+} from 'vitest';
+import { mock } from 'vitest-mock-extended';
+
+import { AttachmentMethodType } from '../../../../interfaces/attachment';
+import type { IGrowiAppAdditionalInfo, IGrowiAppInfoLegacy } from '../../interfaces/growi-app-info';
+
+import { convertToLegacyFormat } from './convert-to-legacy-format';
+
+describe('convertToLegacyFormat', () => {
+  test('should return same object when input is already in legacy format', () => {
+    const growiInfoLegacy: IGrowiAppInfoLegacy = {
+      version: '1.0.0',
+      appSiteUrl: 'https://example.com',
+      appSiteUrlHashed: '100680ad546ce6a577f42f52df33b4cfdca756859e664b8d7de329b150d09ce9',
+      serviceInstanceId: 'service-instance-id',
+      type: GrowiServiceType.cloud,
+      wikiType: GrowiWikiType.open,
+      deploymentType: GrowiDeploymentType.others,
+      osInfo: {
+        type: 'Linux',
+        platform: 'linux',
+        arch: 'x64',
+        totalmem: 8589934592,
+      },
+
+      // legacy properties
+      installedAt: new Date(),
+      installedAtByOldestUser: new Date(),
+      currentUsersCount: 1,
+      currentActiveUsersCount: 1,
+      attachmentType: AttachmentMethodType.local,
+    };
+
+    const legacyData = {
+      someData: 'test',
+      growiInfo: growiInfoLegacy,
+    };
+
+    const result = convertToLegacyFormat(legacyData);
+    expect(result).toStrictEqual(legacyData);
+  });
+
+  test('should convert new format to legacy format', () => {
+    const installedAt = new Date();
+    const installedAtByOldestUser = new Date();
+
+    const growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo> = {
+      version: '1.0.0',
+      appSiteUrl: 'https://example.com',
+      serviceInstanceId: 'service-instance-id',
+      type: GrowiServiceType.cloud,
+      wikiType: GrowiWikiType.open,
+      deploymentType: GrowiDeploymentType.others,
+      osInfo: {
+        type: 'Linux',
+        platform: 'linux',
+        arch: 'x64',
+        totalmem: 8589934592,
+      },
+      additionalInfo: {
+        installedAt,
+        installedAtByOldestUser,
+        currentUsersCount: 1,
+        currentActiveUsersCount: 1,
+        attachmentType: AttachmentMethodType.local,
+      },
+    };
+    const newFormatData = {
+      someData: 'test',
+      growiInfo,
+    };
+
+    const growiInfoLegacy: IGrowiAppInfoLegacy = {
+      version: '1.0.0',
+      appSiteUrl: 'https://example.com',
+      appSiteUrlHashed: '100680ad546ce6a577f42f52df33b4cfdca756859e664b8d7de329b150d09ce9',
+      serviceInstanceId: 'service-instance-id',
+      type: GrowiServiceType.cloud,
+      wikiType: GrowiWikiType.open,
+      deploymentType: GrowiDeploymentType.others,
+      osInfo: {
+        type: 'Linux',
+        platform: 'linux',
+        arch: 'x64',
+        totalmem: 8589934592,
+      },
+
+      // legacy properties
+      installedAt,
+      installedAtByOldestUser,
+      currentUsersCount: 1,
+      currentActiveUsersCount: 1,
+      attachmentType: AttachmentMethodType.local,
+    };
+    const expected = {
+      someData: 'test',
+      growiInfo: growiInfoLegacy,
+    };
+
+    const result = convertToLegacyFormat(newFormatData);
+    expect(result).toStrictEqual(expected);
+  });
+
+  test('should convert new format and omit appSiteUrl', () => {
+    // arrange
+    const growiInfo = mock<IGrowiInfo<IGrowiAppAdditionalInfo>>({
+      appSiteUrl: 'https://example.com',
+      additionalInfo: {
+        installedAt: new Date(),
+        installedAtByOldestUser: new Date(),
+        currentUsersCount: 1,
+        currentActiveUsersCount: 1,
+        attachmentType: AttachmentMethodType.local,
+      },
+    });
+
+    // act
+    const result = convertToLegacyFormat({ growiInfo }, true);
+
+    // assert
+    expect(result.growiInfo.appSiteUrl).toBeUndefined();
+  });
+});

+ 40 - 0
apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.ts

@@ -0,0 +1,40 @@
+import assert from 'assert';
+import crypto from 'crypto';
+
+import type { IGrowiAppInfoLegacy } from '../../interfaces/growi-app-info';
+
+
+type IHasGrowiAppInfoLegacy<T> = T & {
+  growiInfo: IGrowiAppInfoLegacy;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function isLegacy<T extends { growiInfo: any }>(data: T): data is IHasGrowiAppInfoLegacy<T> {
+  return !('additionalInfo' in data.growiInfo);
+}
+
+export function getSiteUrlHashed(siteUrl: string): string {
+  const hasher = crypto.createHash('sha256');
+  hasher.update(siteUrl);
+  return hasher.digest('hex');
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function convertToLegacyFormat<T extends { growiInfo: any }>(questionnaireAnswer: T, isAppSiteUrlHashed = false): IHasGrowiAppInfoLegacy<T> {
+  if (isLegacy(questionnaireAnswer)) {
+    return questionnaireAnswer;
+  }
+
+  const { additionalInfo, appSiteUrl, ...rest } = questionnaireAnswer.growiInfo;
+  assert(additionalInfo != null);
+
+  return {
+    ...questionnaireAnswer,
+    growiInfo: {
+      appSiteUrl: isAppSiteUrlHashed ? undefined : appSiteUrl,
+      appSiteUrlHashed: getSiteUrlHashed(appSiteUrl),
+      ...rest,
+      ...additionalInfo,
+    },
+  };
+}

+ 2 - 1
apps/app/src/features/templates/server/routes/apiv3/index.ts

@@ -8,6 +8,7 @@ import { param, query } from 'express-validator';
 
 import { PLUGIN_STORING_PATH } from '~/features/growi-plugin/server/consts';
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
+import type Crowi from '~/server/crowi';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
@@ -32,7 +33,7 @@ const validator = {
 let presetTemplateSummaries: TemplateSummary[];
 
 
-module.exports = (crowi) => {
+module.exports = (crowi: Crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
   router.get('/', loginRequiredStrictly, validator.list, apiV3FormValidator, async(req, res: ApiV3Response) => {

+ 1 - 0
apps/app/src/interfaces/activity.ts

@@ -365,6 +365,7 @@ export const ActionGroupSize = {
   Medium: 'MEDIUM',
   Large: 'LARGE',
 } as const;
+export type ActionGroupSize = typeof ActionGroupSize[keyof typeof ActionGroupSize];
 
 export const SmallActionGroup = {
   ACTION_USER_LOGIN_WITH_LOCAL,

+ 13 - 1
apps/app/src/interfaces/attachment.ts

@@ -1,7 +1,19 @@
-import type { IAttachmentHasId } from '@growi/core';
+import type { IAttachmentHasId } from '@growi/core/dist/interfaces';
 
 import type { PaginateResult } from './mongoose-utils';
 
+export const AttachmentMethodType = {
+  aws: 'aws',
+  gcs: 'gcs',
+  gcp: 'gcp',
+  azure: 'azure',
+  gridfs: 'gridfs',
+  mongo: 'mongo',
+  mongodb: 'mongodb',
+  local: 'local',
+  none: 'none',
+} as const;
+export type AttachmentMethodType = typeof AttachmentMethodType[keyof typeof AttachmentMethodType]
 
 export type IResAttachmentList = {
   paginateResult: PaginateResult<IAttachmentHasId>

+ 3 - 2
apps/app/src/interfaces/crowi-request.ts

@@ -2,13 +2,14 @@ import type { IUser } from '@growi/core';
 import type { Request } from 'express';
 import type { HydratedDocument } from 'mongoose';
 
+import type Crowi from '~/server/crowi';
+
 
 export interface CrowiProperties {
 
   user?: HydratedDocument<IUser>,
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  crowi: any,
+  crowi: Crowi,
 
   session: any,
 

+ 9 - 0
apps/app/src/interfaces/external-auth-provider.ts

@@ -0,0 +1,9 @@
+export const IExternalAuthProviderType = {
+  ldap: 'ldap',
+  saml: 'saml',
+  oidc: 'oidc',
+  google: 'google',
+  github: 'github',
+} as const;
+
+export type IExternalAuthProviderType = typeof IExternalAuthProviderType[keyof typeof IExternalAuthProviderType]

+ 0 - 7
apps/app/src/interfaces/page-listing-results.ts

@@ -18,13 +18,6 @@ export interface ChildrenResult {
   children: Partial<IPageForItem>[]
 }
 
-
-export interface TargetAndAncestors {
-  targetAndAncestors: Partial<IPageForItem>[]
-  rootPage: Partial<IPageForItem>,
-}
-
-
 export interface V5MigrationStatus {
   isV5Compatible : boolean,
   migratablePagesCount: number

+ 38 - 0
apps/app/src/migrations/19700101000000-foremost-1000-20241123211930-remove-index-for-ns-from-configs.js

@@ -0,0 +1,38 @@
+import mongoose from 'mongoose';
+
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:migrate:remove-index-for-ns-from-configs');
+
+async function dropIndexIfExists(db, collectionName, indexName) {
+  // check existence of the collection
+  const items = await db.listCollections({ name: collectionName }, { nameOnly: true }).toArray();
+  if (items.length === 0) {
+    return;
+  }
+
+  const collection = await db.collection(collectionName);
+  if (await collection.indexExists(indexName)) {
+    await collection.dropIndex(indexName);
+  }
+}
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    await mongoose.connect(getMongoUri(), mongoOptions);
+
+    // drop index
+    await dropIndexIfExists(db, 'configs', 'ns_1_key_1');
+
+    // create index
+    const collection = await db.collection('configs');
+    await collection.createIndex({ key: 1 }, { unique: true });
+  },
+
+  async down() {
+    // No rollback
+  },
+};

+ 24 - 0
apps/app/src/migrations/19700101000000-foremost-1010-20250109000000-generate-service-instance-id.js

@@ -0,0 +1,24 @@
+import mongoose from 'mongoose';
+import { v4 as uuidv4 } from 'uuid';
+
+import { configManager } from '~/server/service/config-manager';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:migrate:generate-service-instance-id');
+
+module.exports = {
+  async up(db) {
+    logger.info('Generate serviceInstanceId for the system');
+    await mongoose.connect(getMongoUri(), mongoOptions);
+
+    await configManager.loadConfigs();
+
+    await configManager.updateConfig('app:serviceInstanceId', uuidv4(), { skipPubsub: true });
+  },
+
+  async down() {
+    // No rollback
+  },
+};

+ 2 - 6
apps/app/src/migrations/20180927102719-init-serverurl.js

@@ -24,7 +24,6 @@ module.exports = {
 
     // find 'app:siteUrl'
     const siteUrlConfig = await Config.findOne({
-      ns: 'crowi',
       key: 'app:siteUrl',
     });
     // exit if exists
@@ -35,7 +34,6 @@ module.exports = {
 
     // find all callbackUrls
     const configs = await Config.find({
-      ns: 'crowi',
       $or: [
         { key: 'security:passport-github:callbackUrl' },
         { key: 'security:passport-google:callbackUrl' },
@@ -63,11 +61,10 @@ module.exports = {
     }
 
     if (siteUrl != null) {
-      const ns = 'crowi';
       const key = 'app:siteUrl';
       await Config.findOneAndUpdate(
-        { ns, key },
-        { ns, key, value: JSON.stringify(siteUrl) },
+        { key },
+        { key, value: JSON.stringify(siteUrl) },
         { upsert: true },
       );
       logger.info('Migration has successfully applied');
@@ -80,7 +77,6 @@ module.exports = {
 
     // remote 'app:siteUrl'
     await Config.findOneAndDelete({
-      ns: 'crowi',
       key: 'app:siteUrl',
     });
 

+ 6 - 6
apps/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js

@@ -15,18 +15,18 @@ module.exports = {
     // enable passport and delete configs for crowi classic auth
     await Promise.all([
       Config.findOneAndUpdate(
-        { ns: 'crowi', key: 'security:isEnabledPassport' },
-        { ns: 'crowi', key: 'security:isEnabledPassport', value: JSON.stringify(true) },
+        { key: 'security:isEnabledPassport' },
+        { key: 'security:isEnabledPassport', value: JSON.stringify(true) },
         { upsert: true },
       ),
       Config.findOneAndUpdate(
-        { ns: 'crowi', key: 'google:clientId' },
-        { ns: 'crowi', key: 'google:clientId', value: JSON.stringify(null) },
+        { key: 'google:clientId' },
+        { key: 'google:clientId', value: JSON.stringify(null) },
         { upsert: true },
       ),
       Config.findOneAndUpdate(
-        { ns: 'crowi', key: 'google:clientSecret' },
-        { ns: 'crowi', key: 'google:clientSecret', value: JSON.stringify(null) },
+        { key: 'google:clientSecret' },
+        { key: 'google:clientSecret', value: JSON.stringify(null) },
         { upsert: true },
       ),
     ]);

+ 3 - 6
apps/app/src/migrations/20190618104011-add-config-app-installed.js

@@ -9,9 +9,9 @@ const logger = loggerFactory('growi:migrate:add-config-app-installed');
 
 /**
  * BEFORE
- *   - Config document { ns: 'crowi', key: 'app:installed' } does not exist
+ *   - Config document { key: 'app:installed' } does not exist
  * AFTER
- *   - Config document { ns: 'crowi', key: 'app:installed' } is created
+ *   - Config document { key: 'app:installed' } is created
  *     - value will be true if one or more users exist
  *     - value will be false if no users exist
  */
@@ -23,9 +23,8 @@ module.exports = {
 
     const User = userModelFactory();
 
-    // find 'app:siteUrl'
+    // find 'app:installed'
     const appInstalled = await Config.findOne({
-      ns: 'crowi',
       key: 'app:installed',
     });
     // exit if exists
@@ -38,7 +37,6 @@ module.exports = {
 
     if (userCount > 0) {
       await Config.create({
-        ns: 'crowi',
         key: 'app:installed',
         value: true,
       });
@@ -53,7 +51,6 @@ module.exports = {
 
     // remote 'app:siteUrl'
     await Config.findOneAndDelete({
-      ns: 'crowi',
       key: 'app:installed',
     });
 

+ 0 - 1
apps/app/src/migrations/20200512005851-remove-behavior-type.js

@@ -23,7 +23,6 @@ module.exports = {
     await mongoose.connect(getMongoUri(), mongoOptions);
 
     const insertConfig = new Config({
-      ns: 'crowi',
       key: 'customize:behavior',
       value: JSON.stringify('growi'),
     });

+ 0 - 1
apps/app/src/migrations/20200827045151-remove-layout-setting.js

@@ -45,7 +45,6 @@ module.exports = {
     const insertLayoutType = (theme.value === '"kibela"') ? 'kibela' : 'growi';
 
     const insertConfig = new Config({
-      ns: 'crowi',
       key: 'customize:layout',
       value: JSON.stringify(insertLayoutType),
     });

+ 0 - 2
apps/app/src/migrations/20200828024025-copy-aws-setting.js

@@ -25,7 +25,6 @@ module.exports = {
           insertOne: {
             document: {
               key: 'mail:sesAccessKeyId',
-              ns: 'crowi',
               value: accessKeyId.value,
             },
           },
@@ -39,7 +38,6 @@ module.exports = {
           insertOne: {
             document: {
               key: 'mail:sesSecretAccessKey',
-              ns: 'crowi',
               value: secretAccessKey.value,
             },
           },

+ 0 - 3
apps/app/src/migrations/20200901034313-update-mail-transmission.js

@@ -13,11 +13,9 @@ module.exports = {
     await mongoose.connect(getMongoUri(), mongoOptions);
 
     const sesAccessKeyId = await Config.findOne({
-      ns: 'crowi',
       key: 'mail:sesAccessKeyId',
     });
     const transmissionMethod = await Config.findOne({
-      ns: 'crowi',
       key: 'mail:transmissionMethod',
     });
 
@@ -47,7 +45,6 @@ module.exports = {
 
     // remote 'mail:transmissionMethod'
     await Config.findOneAndDelete({
-      ns: 'crowi',
       key: 'mail:transmissionMethod',
     });
 

+ 0 - 1
apps/app/src/migrations/20200903080025-remove-timeline-type.js.js

@@ -24,7 +24,6 @@ module.exports = {
     await mongoose.connect(getMongoUri(), mongoOptions);
 
     const insertConfig = new Config({
-      ns: 'crowi',
       key: 'customize:isEnabledTimeline',
       value: true,
     });

+ 0 - 5
apps/app/src/migrations/20220311011114-convert-page-delete-config.js

@@ -15,7 +15,6 @@ module.exports = {
     await mongoose.connect(getMongoUri(), mongoOptions);
 
     const isNewConfigExists = await Config.count({
-      ns: 'crowi',
       key: 'security:pageDeletionAuthority',
     }) > 0;
 
@@ -26,7 +25,6 @@ module.exports = {
     }
 
     const oldConfig = await Config.findOne({
-      ns: 'crowi',
       key: 'security:pageCompleteDeletionAuthority',
     });
 
@@ -37,17 +35,14 @@ module.exports = {
       await Config.insertMany(
         [
           {
-            ns: 'crowi',
             key: 'security:pageDeletionAuthority',
             value: oldValue,
           },
           {
-            ns: 'crowi',
             key: 'security:pageRecursiveDeletionAuthority',
             value: `"${PageRecursiveDeleteConfigValue.Inherit}"`,
           },
           {
-            ns: 'crowi',
             key: 'security:pageRecursiveCompleteDeletionAuthority',
             value: `"${PageRecursiveDeleteCompConfigValue.Inherit}"`,
           },

+ 0 - 1
apps/app/src/migrations/20221014130200-remove-customize-is-saved-states-of-tab-changes.js

@@ -23,7 +23,6 @@ module.exports = {
     await mongoose.connect(getMongoUri(), mongoOptions);
 
     const insertConfig = new Config({
-      ns: 'crowi',
       key: 'customize:isSavedStatesOfTabChanges',
       value: false,
     });

+ 0 - 1
apps/app/src/migrations/20230213090921-remove-presentation-configurations.js

@@ -24,7 +24,6 @@ module.exports = {
     await mongoose.connect(getMongoUri(), mongoOptions);
 
     const insertConfig = new Config({
-      ns: 'crowi',
       key: 'markdown:presentation:pageBreakSeparator',
       value: 2,
     });

+ 48 - 43
apps/app/src/pages/[[...path]].page.tsx

@@ -175,7 +175,6 @@ type Props = CommonProps & {
   isAclEnabled: boolean,
   // hasSlackConfig: boolean,
   drawioUri: string | null,
-  noCdn: string,
   // highlightJsStyle: string,
   isAllReplyShown: boolean,
   isContainerFluid: boolean,
@@ -233,7 +232,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   // useIsMailerSetup(props.isMailerSetup);
   useIsAclEnabled(props.isAclEnabled);
   // useHasSlackConfig(props.hasSlackConfig);
-  // useNoCdn(props.noCdn);
   useDefaultIndentSize(props.adminPreferredIndentSize);
   useIsIndentSizeForced(props.isIndentSizeForced);
   useDisableLinkSharing(props.disableLinkSharing);
@@ -476,24 +474,29 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     }
   }
 
-  const pageWithMeta: IPageToShowRevisionWithMeta | null = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
-  const page = pageWithMeta?.data as unknown as PageDocument;
+  const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
+  const { data: page, meta } = pageWithMeta ?? {};
 
   // add user to seen users
   if (page != null && user != null) {
     await page.seen(user);
   }
 
+  props.pageWithMeta = null;
+
   // populate & check if the revision is latest
   if (page != null) {
     page.initLatestRevisionField(revisionId);
     props.isLatestRevision = page.isLatestRevision();
-    const ssrMaxRevisionBodyLength = configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
+    const ssrMaxRevisionBodyLength = configManager.getConfig('app:ssrMaxRevisionBodyLength');
     props.skipSSR = await skipSSR(page, ssrMaxRevisionBodyLength);
-    await page.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
-  }
+    const populatedPage = await page.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
 
-  props.pageWithMeta = pageWithMeta;
+    props.pageWithMeta = {
+      data: populatedPage,
+      meta,
+    };
+  }
 }
 
 async function injectRoutingInformation(context: GetServerSidePropsContext, props: Props): Promise<void> {
@@ -558,64 +561,66 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
   const {
-    searchService, configManager, aclService,
+    configManager, searchService, aclService, fileUploadService,
+    slackIntegrationService, passportService,
   } = crowi;
 
-  props.aiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
+  props.aiEnabled = configManager.getConfig('app:aiEnabled');
 
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
-  props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
-  props.elasticsearchMaxBodyLengthToIndex = configManager.getConfig('crowi', 'app:elasticsearchMaxBodyLengthToIndex');
+  props.isSearchScopeChildrenAsDefault = configManager.getConfig('customize:isSearchScopeChildrenAsDefault');
+  props.elasticsearchMaxBodyLengthToIndex = configManager.getConfig('app:elasticsearchMaxBodyLengthToIndex');
 
-  props.isRomUserAllowedToComment = configManager.getConfig('crowi', 'security:isRomUserAllowedToComment');
+  props.isRomUserAllowedToComment = configManager.getConfig('security:isRomUserAllowedToComment');
 
-  props.isSlackConfigured = crowi.slackIntegrationService.isSlackConfigured;
+  props.isSlackConfigured = slackIntegrationService.isSlackConfigured;
   // props.isMailerSetup = mailService.isMailerSetup;
   props.isAclEnabled = aclService.isAclEnabled();
   // props.hasSlackConfig = slackNotificationService.hasSlackConfig();
-  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
-  props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
-  // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
-  props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
-  props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
-  props.isEnabledStaleNotification = configManager.getConfig('crowi', 'customize:isEnabledStaleNotification');
-  props.disableLinkSharing = configManager.getConfig('crowi', 'security:disableLinkSharing');
-  props.isUploadAllFileAllowed = crowi.fileUploadService.getFileUploadEnabled();
-  props.isUploadEnabled = crowi.fileUploadService.getIsUploadable();
+  props.drawioUri = configManager.getConfig('app:drawioUri');
+  // props.highlightJsStyle = configManager.getConfig('customize:highlightJsStyle');
+  props.isAllReplyShown = configManager.getConfig('customize:isAllReplyShown');
+  props.isContainerFluid = configManager.getConfig('customize:isContainerFluid');
+  props.isEnabledStaleNotification = configManager.getConfig('customize:isEnabledStaleNotification');
+  props.disableLinkSharing = configManager.getConfig('security:disableLinkSharing');
+  props.isUploadAllFileAllowed = fileUploadService.getFileUploadEnabled();
+  props.isUploadEnabled = fileUploadService.getIsUploadable();
 
-  props.isLocalAccountRegistrationEnabled = crowi.passportService.isLocalStrategySetup
-  && configManager.getConfig('crowi', 'security:registrationMode') !== RegistrationMode.CLOSED;
+  props.isLocalAccountRegistrationEnabled = passportService.isLocalStrategySetup
+  && configManager.getConfig('security:registrationMode') !== RegistrationMode.CLOSED;
 
-  props.adminPreferredIndentSize = configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize');
-  props.isIndentSizeForced = configManager.getConfig('markdown', 'markdown:isIndentSizeForced');
+  props.adminPreferredIndentSize = configManager.getConfig('markdown:adminPreferredIndentSize');
+  props.isIndentSizeForced = configManager.getConfig('markdown:isIndentSizeForced');
 
-  props.isEnabledAttachTitleHeader = configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
+  props.isEnabledAttachTitleHeader = configManager.getConfig('customize:isEnabledAttachTitleHeader');
 
   props.sidebarConfig = {
-    isSidebarCollapsedMode: configManager.getConfig('crowi', 'customize:isSidebarCollapsedMode'),
-    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+    isSidebarCollapsedMode: configManager.getConfig('customize:isSidebarCollapsedMode'),
+    isSidebarClosedAtDockMode: configManager.getConfig('customize:isSidebarClosedAtDockMode'),
   };
 
   props.rendererConfig = {
-    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
-    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
-    isEnabledMarp: configManager.getConfig('crowi', 'customize:isEnabledMarp'),
-    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
-    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+    isEnabledLinebreaks: configManager.getConfig('markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
+    isEnabledMarp: configManager.getConfig('customize:isEnabledMarp'),
+    adminPreferredIndentSize: configManager.getConfig('markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown:isIndentSizeForced'),
 
-    drawioUri: configManager.getConfig('crowi', 'app:drawioUri'),
-    plantumlUri: configManager.getConfig('crowi', 'app:plantumlUri'),
+    drawioUri: configManager.getConfig('app:drawioUri'),
+    plantumlUri: configManager.getConfig('app:plantumlUri'),
 
     // XSS Options
-    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    sanitizeType: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    customAttrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    customTagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
-    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+    isEnabledXssPrevention: configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
+    sanitizeType: configManager.getConfig('markdown:rehypeSanitize:option'),
+    customTagWhitelist: configManager.getConfig('markdown:rehypeSanitize:tagNames'),
+    customAttrWhitelist: configManager.getConfig('markdown:rehypeSanitize:attributes') != null
+      ? JSON.parse(configManager.getConfig('markdown:rehypeSanitize:attributes'))
+      : undefined,
+    highlightJsStyleBorder: configManager.getConfig('customize:highlightJsStyleBorder'),
   };
 
-  props.ssrMaxRevisionBodyLength = configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
+  props.ssrMaxRevisionBodyLength = configManager.getConfig('app:ssrMaxRevisionBodyLength');
 }
 
 /**

+ 18 - 16
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -94,30 +94,32 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
-  props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
-  props.isEnabledMarp = configManager.getConfig('crowi', 'customize:isEnabledMarp');
+  props.isSearchScopeChildrenAsDefault = configManager.getConfig('customize:isSearchScopeChildrenAsDefault');
+  props.isEnabledMarp = configManager.getConfig('customize:isEnabledMarp');
 
   props.sidebarConfig = {
-    isSidebarCollapsedMode: configManager.getConfig('crowi', 'customize:isSidebarCollapsedMode'),
-    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+    isSidebarCollapsedMode: configManager.getConfig('customize:isSidebarCollapsedMode'),
+    isSidebarClosedAtDockMode: configManager.getConfig('customize:isSidebarClosedAtDockMode'),
   };
 
   props.rendererConfig = {
-    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
-    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
-    isEnabledMarp: configManager.getConfig('crowi', 'customize:isEnabledMarp'),
-    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
-    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+    isEnabledLinebreaks: configManager.getConfig('markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
+    isEnabledMarp: configManager.getConfig('customize:isEnabledMarp'),
+    adminPreferredIndentSize: configManager.getConfig('markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown:isIndentSizeForced'),
 
-    drawioUri: configManager.getConfig('crowi', 'app:drawioUri'),
-    plantumlUri: configManager.getConfig('crowi', 'app:plantumlUri'),
+    drawioUri: configManager.getConfig('app:drawioUri'),
+    plantumlUri: configManager.getConfig('app:plantumlUri'),
 
     // XSS Options
-    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    sanitizeType: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    customAttrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    customTagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
-    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+    isEnabledXssPrevention: configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
+    sanitizeType: configManager.getConfig('markdown:rehypeSanitize:option'),
+    customTagWhitelist: crowi.configManager.getConfig('markdown:rehypeSanitize:tagNames'),
+    customAttrWhitelist: configManager.getConfig('markdown:rehypeSanitize:attributes') != null
+      ? JSON.parse(configManager.getConfig('markdown:rehypeSanitize:attributes'))
+      : undefined,
+    highlightJsStyleBorder: crowi.configManager.getConfig('customize:highlightJsStyleBorder'),
   };
 }
 

+ 19 - 17
apps/app/src/pages/_search.page.tsx

@@ -121,33 +121,35 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
-  props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
-  props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
+  props.isSearchScopeChildrenAsDefault = configManager.getConfig('customize:isSearchScopeChildrenAsDefault');
+  props.isContainerFluid = configManager.getConfig('customize:isContainerFluid');
 
   props.sidebarConfig = {
-    isSidebarCollapsedMode: configManager.getConfig('crowi', 'customize:isSidebarCollapsedMode'),
-    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+    isSidebarCollapsedMode: configManager.getConfig('customize:isSidebarCollapsedMode'),
+    isSidebarClosedAtDockMode: configManager.getConfig('customize:isSidebarClosedAtDockMode'),
   };
 
   props.rendererConfig = {
-    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
-    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
-    isEnabledMarp: configManager.getConfig('crowi', 'customize:isEnabledMarp'),
-    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
-    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+    isEnabledLinebreaks: configManager.getConfig('markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
+    isEnabledMarp: configManager.getConfig('customize:isEnabledMarp'),
+    adminPreferredIndentSize: configManager.getConfig('markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown:isIndentSizeForced'),
 
-    drawioUri: configManager.getConfig('crowi', 'app:drawioUri'),
-    plantumlUri: configManager.getConfig('crowi', 'app:plantumlUri'),
+    drawioUri: configManager.getConfig('app:drawioUri'),
+    plantumlUri: configManager.getConfig('app:plantumlUri'),
 
     // XSS Options
-    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    sanitizeType: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    customAttrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    customTagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
-    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+    isEnabledXssPrevention: configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
+    sanitizeType: configManager.getConfig('markdown:rehypeSanitize:option'),
+    customTagWhitelist: crowi.configManager.getConfig('markdown:rehypeSanitize:tagNames'),
+    customAttrWhitelist: configManager.getConfig('markdown:rehypeSanitize:attributes') != null
+      ? JSON.parse(configManager.getConfig('markdown:rehypeSanitize:attributes'))
+      : undefined,
+    highlightJsStyleBorder: crowi.configManager.getConfig('customize:highlightJsStyleBorder'),
   };
 
-  props.showPageLimitationL = configManager.getConfig('crowi', 'customize:showPageLimitationL');
+  props.showPageLimitationL = configManager.getConfig('customize:showPageLimitationL');
 }
 
 /**

+ 1 - 1
apps/app/src/pages/admin/ai-integration.page.tsx

@@ -50,7 +50,7 @@ const injectServerConfigurations = async(context: GetServerSidePropsContext, pro
   const { crowi } = req;
   const { configManager } = crowi;
 
-  props.aiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
+  props.aiEnabled = configManager.getConfig('app:aiEnabled');
 };
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {

+ 2 - 2
apps/app/src/pages/admin/audit-log.page.tsx

@@ -57,8 +57,8 @@ const injectServerConfigurations = async(context: GetServerSidePropsContext, pro
   const { crowi } = req;
   const { activityService } = crowi;
 
-  props.auditLogEnabled = crowi.configManager.getConfig('crowi', 'app:auditLogEnabled');
-  props.activityExpirationSeconds = crowi.configManager.getConfig('crowi', 'app:activityExpirationSeconds');
+  props.auditLogEnabled = crowi.configManager.getConfig('app:auditLogEnabled');
+  props.activityExpirationSeconds = crowi.configManager.getConfig('app:activityExpirationSeconds');
   props.auditLogAvailableActions = activityService.getAvailableActions(false);
 };
 

+ 3 - 2
apps/app/src/pages/admin/customize.page.tsx

@@ -12,6 +12,7 @@ import { Provider } from 'unstated';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CommonProps } from '~/pages/utils/commons';
 import { generateCustomTitle } from '~/pages/utils/commons';
+import { configManager } from '~/server/service/config-manager';
 import { useCustomizeTitle, useCurrentUser, useIsCustomizedLogoUploaded } from '~/stores-universal/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -22,7 +23,7 @@ const ForbiddenPage = dynamic(() => import('~/client/components/Admin/ForbiddenP
 
 
 type Props = CommonProps & {
-  customizeTitle: string,
+  customizeTitle?: string,
   isCustomizedLogoUploaded: boolean,
 };
 
@@ -66,7 +67,7 @@ const injectServerConfigurations = async(context: GetServerSidePropsContext, pro
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
 
-  props.customizeTitle = crowi.configManager.getConfig('crowi', 'customize:title');
+  props.customizeTitle = crowi.configManager.getConfig('customize:title');
   props.isCustomizedLogoUploaded = await crowi.attachmentService.isBrandLogoExist();
 };
 

+ 1 - 1
apps/app/src/pages/admin/data-transfer.page.tsx

@@ -60,7 +60,7 @@ const injectServerConfigurations = async(context: GetServerSidePropsContext, pro
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
 
-  props.growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
+  props.growiCloudUri = await crowi.configManager.getConfig('app:growiCloudUri');
 };
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {

+ 5 - 5
apps/app/src/pages/admin/index.page.tsx

@@ -25,12 +25,12 @@ const ForbiddenPage = dynamic(() => import('~/client/components/Admin/ForbiddenP
 
 
 type Props = CommonProps & {
-  growiCloudUri: string,
-  growiAppIdForGrowiCloud: number,
+  growiCloudUri?: string,
+  growiAppIdForGrowiCloud?: number,
 };
 
 
-const AdminHomepage: NextPage<Props> = (props) => {
+const AdminHomepage: NextPage<Props> = (props: Props) => {
   useCurrentUser(props.currentUser ?? null);
   useGrowiCloudUri(props.growiCloudUri);
   useGrowiAppIdForGrowiCloud(props.growiAppIdForGrowiCloud);
@@ -71,8 +71,8 @@ const injectServerConfigurations = async(context: GetServerSidePropsContext, pro
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
 
-  props.growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
-  props.growiAppIdForGrowiCloud = await crowi.configManager.getConfig('crowi', 'app:growiAppIdForCloud');
+  props.growiCloudUri = crowi.configManager.getConfig('app:growiCloudUri');
+  props.growiAppIdForGrowiCloud = crowi.configManager.getConfig('app:growiAppIdForCloud');
 };
 
 

+ 2 - 2
apps/app/src/pages/admin/security.page.tsx

@@ -92,9 +92,9 @@ const AdminSecuritySettingsPage: NextPage<Props> = (props) => {
 const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
-  const { appService, mailService } = crowi;
+  const { growiInfoService, mailService } = crowi;
 
-  props.siteUrl = appService.getSiteUrl();
+  props.siteUrl = growiInfoService.getSiteUrl();
   props.isMailerSetup = mailService.isMailerSetup;
 };
 

+ 2 - 2
apps/app/src/pages/admin/slack-integration.page.tsx

@@ -49,9 +49,9 @@ const AdminSlackIntegrationPage: NextPage<Props> = (props) => {
 const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
-  const { appService } = crowi;
+  const { growiInfoService } = crowi;
 
-  props.siteUrl = appService.getSiteUrl();
+  props.siteUrl = growiInfoService.getSiteUrl();
 };
 
 

+ 1 - 1
apps/app/src/pages/installer.page.tsx

@@ -79,7 +79,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   const { crowi } = req;
   const { configManager } = crowi;
 
-  props.minPasswordLength = configManager.getConfig('crowi', 'app:minPasswordLength');
+  props.minPasswordLength = configManager.getConfig('app:minPasswordLength');
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {

+ 11 - 12
apps/app/src/pages/login/index.page.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
 
-import { IExternalAuthProviderType } from '@growi/core';
 import type {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -13,6 +12,7 @@ import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import { isExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import type { RegistrationMode } from '~/interfaces/registration-mode';
 import type { CommonProps } from '~/pages/utils/commons';
 import { getServerSideCommonProps, generateCustomTitle, getNextI18NextConfig } from '~/pages/utils/commons';
@@ -95,11 +95,10 @@ function injectEnabledStrategies(context: GetServerSidePropsContext, props: Prop
   } = crowi;
 
   props.enabledExternalAuthType = [
-    configManager.getConfig('crowi', 'security:passport-google:isEnabled') === true ? IExternalAuthProviderType.google : undefined,
-    configManager.getConfig('crowi', 'security:passport-github:isEnabled') === true ? IExternalAuthProviderType.github : undefined,
-    // configManager.getConfig('crowi', 'security:passport-facebook:isEnabled') ?? IExternalAuthProviderType.facebook : undefined,
-    configManager.getConfig('crowi', 'security:passport-saml:isEnabled') === true ? IExternalAuthProviderType.saml : undefined,
-    configManager.getConfig('crowi', 'security:passport-oidc:isEnabled') === true ? IExternalAuthProviderType.oidc : undefined,
+    configManager.getConfig('security:passport-google:isEnabled') === true ? IExternalAuthProviderType.google : undefined,
+    configManager.getConfig('security:passport-github:isEnabled') === true ? IExternalAuthProviderType.github : undefined,
+    configManager.getConfig('security:passport-saml:isEnabled') === true ? IExternalAuthProviderType.saml : undefined,
+    configManager.getConfig('security:passport-oidc:isEnabled') === true ? IExternalAuthProviderType.oidc : undefined,
 
   ]
     .filter((authType): authType is Exclude<typeof authType, undefined> => authType != null);
@@ -114,15 +113,15 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
     passportService,
   } = crowi;
 
-  props.isPasswordResetEnabled = crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled');
+  props.isPasswordResetEnabled = configManager.getConfig('security:passport-local:isPasswordResetEnabled');
   props.isMailerSetup = mailService.isMailerSetup;
   props.isLocalStrategySetup = passportService.isLocalStrategySetup;
   props.isLdapStrategySetup = passportService.isLdapStrategySetup;
-  props.isLdapSetupFailed = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
-  props.registrationWhitelist = configManager.getConfig('crowi', 'security:registrationWhitelist');
-  props.isEmailAuthenticationEnabled = configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled');
-  props.registrationMode = configManager.getConfig('crowi', 'security:registrationMode');
-  props.minPasswordLength = configManager.getConfig('crowi', 'app:minPasswordLength');
+  props.isLdapSetupFailed = configManager.getConfig('security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
+  props.registrationWhitelist = configManager.getConfig('security:registrationWhitelist');
+  props.isEmailAuthenticationEnabled = configManager.getConfig('security:passport-local:isEmailAuthenticationEnabled');
+  props.registrationMode = configManager.getConfig('security:registrationMode');
+  props.minPasswordLength = configManager.getConfig('app:minPasswordLength');
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно