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

Merge branch 'feat/enhanced-access-token' into fix/167097-codeql-problems

Shun Miyazawa 11 месяцев назад
Родитель
Сommit
10a12eb6c1
28 измененных файлов с 1072 добавлено и 117 удалено
  1. 9 0
      apps/app/bin/openapi/definition-apiv1.js
  2. 9 0
      apps/app/bin/openapi/definition-apiv3.js
  3. 1 1
      apps/app/bin/openapi/generate-spec-apiv3.sh
  4. 92 0
      apps/app/public/static/locales/en_US/commons.json
  5. 92 0
      apps/app/public/static/locales/fr_FR/commons.json
  6. 87 3
      apps/app/public/static/locales/ja_JP/commons.json
  7. 92 0
      apps/app/public/static/locales/zh_CN/commons.json
  8. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  9. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.module.scss
  10. 13 10
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.tsx
  11. 25 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/Header.tsx
  12. 13 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/NextLinkWrapper.tsx
  13. 57 23
      apps/app/src/features/openai/server/routes/edit/index.ts
  14. 3 1
      apps/app/src/interfaces/scope.ts
  15. 79 32
      apps/app/src/server/routes/apiv3/app-settings.js
  16. 3 3
      apps/app/src/server/routes/apiv3/export.js
  17. 2 2
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  18. 18 14
      apps/app/src/server/service/config-manager/config-definition.ts
  19. 87 0
      apps/app/src/server/service/config-manager/config-manager.integ.ts
  20. 104 4
      apps/app/src/server/service/config-manager/config-manager.spec.ts
  21. 24 12
      apps/app/src/server/service/config-manager/config-manager.ts
  22. 8 5
      apps/app/src/server/service/file-uploader/aws/index.ts
  23. 4 3
      apps/app/src/server/service/file-uploader/azure.ts
  24. 3 2
      apps/app/src/server/service/file-uploader/gcs/index.ts
  25. 4 1
      packages/core/src/interfaces/config-manager.ts
  26. 1 0
      packages/core/src/interfaces/index.ts
  27. 164 0
      packages/core/src/interfaces/primitive/string.spec.ts
  28. 77 0
      packages/core/src/interfaces/primitive/string.ts

+ 9 - 0
apps/app/bin/openapi/definition-apiv1.js

@@ -7,6 +7,15 @@ module.exports = {
     version: pkg.version,
     version: pkg.version,
   },
   },
   servers: [
   servers: [
+    {
+      url: '{server}/_api',
+      variables: {
+        server: {
+          default: 'https://demo.growi.org',
+          description: 'The base URL for the GROWI API except for the version path (/_api). This can be set to your GROWI instance URL.',
+        },
+      },
+    },
     {
     {
       url: 'https://demo.growi.org/_api',
       url: 'https://demo.growi.org/_api',
     },
     },

+ 9 - 0
apps/app/bin/openapi/definition-apiv3.js

@@ -7,6 +7,15 @@ module.exports = {
     version: pkg.version,
     version: pkg.version,
   },
   },
   servers: [
   servers: [
+    {
+      url: '{server}/_api/v3',
+      variables: {
+        server: {
+          default: 'https://demo.growi.org',
+          description: 'The base URL for the GROWI API except for the version path (/_api/v3). This can be set to your GROWI instance URL.',
+        },
+      },
+    },
     {
     {
       url: 'https://demo.growi.org/_api/v3',
       url: 'https://demo.growi.org/_api/v3',
     },
     },

+ 1 - 1
apps/app/bin/openapi/generate-spec-apiv3.sh

@@ -19,6 +19,6 @@ swagger-jsdoc \
   "${APP_PATH}/src/server/models/openapi/**/*.{js,ts}"
   "${APP_PATH}/src/server/models/openapi/**/*.{js,ts}"
 
 
 if [ $? -eq 0 ]; then
 if [ $? -eq 0 ]; then
-  pnpm dlx tsx "${APP_PATH}/bin/openapi/generate-operation-ids/cli.ts" "${OUT}" --out "${OUT}" --overwrite-existing
+  npx tsx "${APP_PATH}/bin/openapi/generate-operation-ids/cli.ts" "${OUT}" --out "${OUT}" --overwrite-existing
   echo "OpenAPI spec generated and transformed: ${OUT}"
   echo "OpenAPI spec generated and transformed: ${OUT}"
 fi
 fi

+ 92 - 0
apps/app/public/static/locales/en_US/commons.json

@@ -159,5 +159,97 @@
     "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
     "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
     "once_transfer_key_used": "Once the transfer key is used for transfer, it cannot be used for any other transfer.",
     "once_transfer_key_used": "Once the transfer key is used for transfer, it cannot be used for any other transfer.",
     "transfer_to_growi_cloud": "For more details, please click <a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>here.</a>"
     "transfer_to_growi_cloud": "For more details, please click <a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>here.</a>"
+  },
+  "accesstoken_scopes_desc": {
+    "read": {
+      "all": "Grants permission to view all content.",
+      "admin": {
+        "all": "Grants permission to view admin features.",
+        "top": "Grants permission to view Wiki management top.",
+        "app": "Grants permission to view app settings.",
+        "security": "Grants permission to view security settings.",
+        "markdown": "Grants permission to view markdown settings.",
+        "customize": "Grants permission to view customization settings.",
+        "import_data": "Grants permission to view data import settings.",
+        "export_data": "Grants permission to view data archive settings.",
+        "data_transfer": "Grants permission to view data transfer settings.",
+        "external_notification": "Grants permission to view external notification settings.",
+        "slack_integration": "Grants permission to view Slack integration settings.",
+        "legacy_slack_integration": "Grants permission to view legacy Slack integration settings.",
+        "user_management": "Grants permission to view user management.",
+        "user_group_management": "Grants permission to view user group management.",
+        "audit_log": "Grants permission to view audit logs.",
+        "plugin": "Grants permission to view plugin settings.",
+        "ai_integration": "Grants permission to view AI integration settings.",
+        "full_text_search": "Grants permission to view full text search management."
+      },
+      "user_settings": {
+        "all": "Grants permission to view user settings.",
+        "info": "Grants permission to view user information.",
+        "external_account": "Grants permission to view external accounts.",
+        "password": "Grants permission to view password settings.",
+        "api": {
+          "all": "Grants permission to view API settings.",
+          "api_token": "Grants permission to view API token settings.",
+          "access_token": "Grants permission to view access token settings."
+        },
+        "in_app_notification": "Grants permission to view in-app notification settings.",
+        "other": "Grants permission to view other settings."
+      },
+      "features": {
+        "all": "Grants permission to view features.",
+        "ai_assistant": "Grants permission to view AI assistant features.",
+        "page": "Grants permission to view page features.",
+        "share_link": "Grants permission to view share link features.",
+        "bookmark": "Grants permission to view bookmark features.",
+        "questionnaire": "Grants permission to view questionnaire features.",
+        "attachment": "Grants permission to view attachment features."
+      }
+    },
+    "write": {
+      "all": "Grants permission to edit all content.",
+      "admin": {
+        "all": "Grants permission to edit admin features.",
+        "top": "Grants permission to edit Wiki management top.",
+        "app": "Grants permission to edit app settings.",
+        "security": "Grants permission to edit security settings.",
+        "markdown": "Grants permission to edit markdown settings.",
+        "customize": "Grants permission to edit customization settings.",
+        "import_data": "Grants permission to edit data import settings.",
+        "export_data": "Grants permission to edit data archive settings.",
+        "data_transfer": "Grants permission to edit data transfer settings.",
+        "external_notification": "Grants permission to edit external notification settings.",
+        "slack_integration": "Grants permission to edit Slack integration settings.",
+        "legacy_slack_integration": "Grants permission to edit legacy Slack integration settings.",
+        "user_management": "Grants permission to edit user management.",
+        "user_group_management": "Grants permission to edit user group management.",
+        "audit_log": "Grants permission to edit audit logs.",
+        "plugin": "Grants permission to edit plugin settings.",
+        "ai_integration": "Grants permission to edit AI integration settings.",
+        "full_text_search": "Grants permission to edit full text search management."
+      },
+      "user_settings": {
+        "all": "Grants permission to edit user settings.",
+        "info": "Grants permission to edit user information.",
+        "external_account": "Grants permission to edit external accounts.",
+        "password": "Grants permission to edit password settings.",
+        "api": {
+          "all": "Grants permission to edit API settings.",
+          "api_token": "Grants permission to edit API token settings.",
+          "access_token": "Grants permission to edit access token settings."
+        },
+        "in_app_notification": "Grants permission to edit in-app notification settings.",
+        "other": "Grants permission to edit other settings."
+      },
+      "features": {
+        "all": "Grants permission to edit features.",
+        "ai_assistant": "Grants permission to edit AI assistant features.",
+        "page": "Grants permission to edit page features.",
+        "share_link": "Grants permission to edit share link features.",
+        "bookmark": "Grants permission to edit bookmark features.",
+        "questionnaire": "Grants permission to edit questionnaire features.",
+        "attachment": "Grants permission to edit attachment features."
+      }
+    }
   }
   }
 }
 }

+ 92 - 0
apps/app/public/static/locales/fr_FR/commons.json

@@ -145,5 +145,97 @@
     "transfer_key_limit": "Les clés de transfert sont valides durant une heure.",
     "transfer_key_limit": "Les clés de transfert sont valides durant une heure.",
     "once_transfer_key_used": "Les clés de transfert sont à usage unique.",
     "once_transfer_key_used": "Les clés de transfert sont à usage unique.",
     "transfer_to_growi_cloud": "Pour plus de détails, veuillez cliquer <a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>ici.</a>"
     "transfer_to_growi_cloud": "Pour plus de détails, veuillez cliquer <a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>ici.</a>"
+  },
+  "accesstoken_scopes_desc": {
+    "read": {
+      "all": "Accorde la permission de voir tout le contenu.",
+      "admin": {
+        "all": "Accorde la permission de voir les fonctionnalités d'administration.",
+        "top": "Accorde la permission de voir la page principale de gestion du Wiki.",
+        "app": "Accorde la permission de voir les paramètres de l'application.",
+        "security": "Accorde la permission de voir les paramètres de sécurité.",
+        "markdown": "Accorde la permission de voir les paramètres markdown.",
+        "customize": "Accorde la permission de voir les paramètres de personnalisation.",
+        "import_data": "Accorde la permission de voir les paramètres d'importation de données.",
+        "export_data": "Accorde la permission de voir les paramètres d'archivage de données.",
+        "data_transfer": "Accorde la permission de voir les paramètres de transfert de données.",
+        "external_notification": "Accorde la permission de voir les paramètres de notification externe.",
+        "slack_integration": "Accorde la permission de voir les paramètres d'intégration Slack.",
+        "legacy_slack_integration": "Accorde la permission de voir les paramètres d'intégration Slack (ancien).",
+        "user_management": "Accorde la permission de voir la gestion des utilisateurs.",
+        "user_group_management": "Accorde la permission de voir la gestion des groupes d'utilisateurs.",
+        "audit_log": "Accorde la permission de voir les journaux d'audit.",
+        "plugin": "Accorde la permission de voir les paramètres des plugins.",
+        "ai_integration": "Accorde la permission de voir les paramètres d'intégration IA.",
+        "full_text_search": "Accorde la permission de voir la gestion de la recherche en texte intégral."
+      },
+      "user_settings": {
+        "all": "Accorde la permission de voir les paramètres utilisateur.",
+        "info": "Accorde la permission de voir les informations utilisateur.",
+        "external_account": "Accorde la permission de voir les comptes externes.",
+        "password": "Accorde la permission de voir les paramètres de mot de passe.",
+        "api": {
+          "all": "Accorde la permission de voir les paramètres API.",
+          "api_token": "Accorde la permission de voir les paramètres de jeton API.",
+          "access_token": "Accorde la permission de voir les paramètres de jeton d'accès."
+        },
+        "in_app_notification": "Accorde la permission de voir les paramètres de notification dans l'application.",
+        "other": "Accorde la permission de voir les autres paramètres."
+      },
+      "features": {
+        "all": "Accorde la permission de voir les fonctionnalités.",
+        "ai_assistant": "Accorde la permission de voir les fonctionnalités d'assistant IA.",
+        "page": "Accorde la permission de voir les fonctionnalités de page.",
+        "share_link": "Accorde la permission de voir les fonctionnalités de lien de partage.",
+        "bookmark": "Accorde la permission de voir les fonctionnalités de signet.",
+        "questionnaire": "Accorde la permission de voir les fonctionnalités de questionnaire.",
+        "attachment": "Accorde la permission de voir les fonctionnalités de pièce jointe."
+      }
+    },
+    "write": {
+      "all": "Accorde la permission de modifier tout le contenu.",
+      "admin": {
+        "all": "Accorde la permission de modifier les fonctionnalités d'administration.",
+        "top": "Accorde la permission de modifier la page principale de gestion du Wiki.",
+        "app": "Accorde la permission de modifier les paramètres de l'application.",
+        "security": "Accorde la permission de modifier les paramètres de sécurité.",
+        "markdown": "Accorde la permission de modifier les paramètres markdown.",
+        "customize": "Accorde la permission de modifier les paramètres de personnalisation.",
+        "import_data": "Accorde la permission de modifier les paramètres d'importation de données.",
+        "export_data": "Accorde la permission de modifier les paramètres d'archivage de données.",
+        "data_transfer": "Accorde la permission de modifier les paramètres de transfert de données.",
+        "external_notification": "Accorde la permission de modifier les paramètres de notification externe.",
+        "slack_integration": "Accorde la permission de modifier les paramètres d'intégration Slack.",
+        "legacy_slack_integration": "Accorde la permission de modifier les paramètres d'intégration Slack (ancien).",
+        "user_management": "Accorde la permission de modifier la gestion des utilisateurs.",
+        "user_group_management": "Accorde la permission de modifier la gestion des groupes d'utilisateurs.",
+        "audit_log": "Accorde la permission de modifier les journaux d'audit.",
+        "plugin": "Accorde la permission de modifier les paramètres des plugins.",
+        "ai_integration": "Accorde la permission de modifier les paramètres d'intégration IA.",
+        "full_text_search": "Accorde la permission de modifier la gestion de la recherche en texte intégral."
+      },
+      "user_settings": {
+        "all": "Accorde la permission de modifier les paramètres utilisateur.",
+        "info": "Accorde la permission de modifier les informations utilisateur.",
+        "external_account": "Accorde la permission de modifier les comptes externes.",
+        "password": "Accorde la permission de modifier les paramètres de mot de passe.",
+        "api": {
+          "all": "Accorde la permission de modifier les paramètres API.",
+          "api_token": "Accorde la permission de modifier les paramètres de jeton API.",
+          "access_token": "Accorde la permission de modifier les paramètres de jeton d'accès."
+        },
+        "in_app_notification": "Accorde la permission de modifier les paramètres de notification dans l'application.",
+        "other": "Accorde la permission de modifier les autres paramètres."
+      },
+      "features": {
+        "all": "Accorde la permission de modifier les fonctionnalités.",
+        "ai_assistant": "Accorde la permission de modifier les fonctionnalités d'assistant IA.",
+        "page": "Accorde la permission de modifier les fonctionnalités de page.",
+        "share_link": "Accorde la permission de modifier les fonctionnalités de lien de partage.",
+        "bookmark": "Accorde la permission de modifier les fonctionnalités de signet.",
+        "questionnaire": "Accorde la permission de modifier les fonctionnalités de questionnaire.",
+        "attachment": "Accorde la permission de modifier les fonctionnalités de pièce jointe."
+      }
+    }
   }
   }
 }
 }

+ 87 - 3
apps/app/public/static/locales/ja_JP/commons.json

@@ -164,10 +164,94 @@
   },
   },
 
 
   "accesstoken_scopes_desc": {
   "accesstoken_scopes_desc": {
-    "read":{
+    "read": {
       "all": "全ての閲覧権限を付与できます。",
       "all": "全ての閲覧権限を付与できます。",
-      "admin":{
-        "all": "管理者の閲覧権限を付与できます。"
+      "admin": {
+        "all": "管理者機能の閲覧権限を付与できます。",
+        "top": "Wiki管理トップの閲覧権限を付与できます。",
+        "app": "アプリ設定の閲覧権限を付与できます。",
+        "security": "セキュリティ設定の閲覧権限を付与できます。",
+        "markdown": "マークダウン設定の閲覧権限を付与できます。",
+        "customize": "カスタマイズの閲覧権限を付与できます。",
+        "import_data": "データインポートの閲覧権限を付与できます。",
+        "export_data": "データアーカイブの閲覧権限を付与できます。",
+        "data_transfer": "データ移行の閲覧権限を付与できます。",
+        "external_notification": "外部ツールへの通知の閲覧権限を付与できます。",
+        "slack_integration": "Slack連携の閲覧権限を付与できます。",
+        "legacy_slack_integration": "Slack連携(レガシー)の閲覧権限を付与できます。",
+        "user_management": "ユーザー管理の閲覧権限を付与できます。",
+        "user_group_management": "ユーザーグループ管理の閲覧権限を付与できます。",
+        "audit_log": "監査ログの閲覧権限を付与できます。",
+        "plugin": "プラグインの閲覧権限を付与できます。",
+        "ai_integration": "AI連携設定の閲覧権限を付与できます。",
+        "full_text_search": "全文検索管理の閲覧権限を付与できます。"
+      },
+      "user_settings": {
+        "all": "ユーザー設定の閲覧権限を付与できます。",
+        "info": "ユーザー情報の閲覧権限を付与できます。",
+        "external_account": "外部アカウントの閲覧権限を付与できます。",
+        "password": "パスワード設定の閲覧権限を付与できます。",
+        "api": {
+          "all": "API 設定の閲覧権限を付与できます。",
+          "api_token": "API トークン設定の閲覧権限を付与できます。",
+          "access_token": "アクセストークン設定の閲覧権限を付与できます。"
+        },
+        "in_app_notification": "アプリ内通知設定の閲覧権限を付与できます。",
+        "other": "その他設定の閲覧権限を付与できます。"
+      },
+      "features": {
+        "all": "機能の閲覧権限を付与できます。",
+        "ai_assistant": "AIアシスタント機能の閲覧権限を付与できます。",
+        "page": "ページ機能の閲覧権限を付与できます。",
+        "share_link": "共有リンク機能の閲覧権限を付与できます。",
+        "bookmark": "ブックマーク機能の閲覧権限を付与できます。",
+        "questionnaire": "アンケート機能の閲覧権限を付与できます。",
+        "attachment": "添付ファイル機能の閲覧権限を付与できます。"
+      }
+    },
+    "write": {
+      "all": "全ての編集権限を付与できます。",
+      "admin": {
+        "all": "管理者機能の編集権限を付与できます。",
+        "top": "Wiki管理トップの編集権限を付与できます。",
+        "app": "アプリ設定の編集権限を付与できます。",
+        "security": "セキュリティ設定の編集権限を付与できます。",
+        "markdown": "マークダウン設定の編集権限を付与できます。",
+        "customize": "カスタマイズの編集権限を付与できます。",
+        "import_data": "データインポートの編集権限を付与できます。",
+        "export_data": "データアーカイブの編集権限を付与できます。",
+        "data_transfer": "データ移行の編集権限を付与できます。",
+        "external_notification": "外部ツールへの通知の編集権限を付与できます。",
+        "slack_integration": "Slack連携の編集権限を付与できます。",
+        "legacy_slack_integration": "Slack連携(レガシー)の編集権限を付与できます。",
+        "user_management": "ユーザー管理の編集権限を付与できます。",
+        "user_group_management": "ユーザーグループ管理の編集権限を付与できます。",
+        "audit_log": "監査ログの編集権限を付与できます。",
+        "plugin": "プラグインの編集権限を付与できます。",
+        "ai_integration": "AI連携設定の編集権限を付与できます。",
+        "full_text_search": "全文検索管理の編集権限を付与できます。"
+      },
+      "user_settings": {
+        "all": "ユーザー設定の編集権限を付与できます。",
+        "info": "ユーザー情報の編集権限を付与できます。",
+        "external_account": "外部アカウントの編集権限を付与できます。",
+        "password": "パスワード設定の編集権限を付与できます。",
+        "api": {
+          "all": "API 設定の編集権限を付与できます。",
+          "api_token": "API トークン設定の編集権限を付与できます。",
+          "access_token": "アクセストークン設定の編集権限を付与できます。"
+        },
+        "in_app_notification": "アプリ内通知設定の編集権限を付与できます。",
+        "other": "その他設定の編集権限を付与できます。"
+      },
+      "features": {
+        "all": "機能の編集権限を付与できます。",
+        "ai_assistant": "AIアシスタント機能の編集権限を付与できます。",
+        "page": "ページ機能の編集権限を付与できます。",
+        "share_link": "共有リンク機能の編集権限を付与できます。",
+        "bookmark": "ブックマーク機能の編集権限を付与できます。",
+        "questionnaire": "アンケート機能の編集権限を付与できます。",
+        "attachment": "添付ファイル機能の編集権限を付与できます。"
       }
       }
     }
     }
   }
   }

+ 92 - 0
apps/app/public/static/locales/zh_CN/commons.json

@@ -162,5 +162,97 @@
     "transfer_key_limit": "迁移密钥在签发后一小时内有效。",
     "transfer_key_limit": "迁移密钥在签发后一小时内有效。",
     "once_transfer_key_used": "一旦迁移密钥被用于迁移,它将不再可用于进一步的迁移。",
     "once_transfer_key_used": "一旦迁移密钥被用于迁移,它将不再可用于进一步的迁移。",
     "transfer_to_growi_cloud": "有关更多详情,请点击<a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>此处</a>。"
     "transfer_to_growi_cloud": "有关更多详情,请点击<a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>此处</a>。"
+  },
+  "accesstoken_scopes_desc": {
+    "read": {
+      "all": "授予查看所有内容的权限。",
+      "admin": {
+        "all": "授予查看管理功能的权限。",
+        "top": "授予查看Wiki管理顶部的权限。",
+        "app": "授予查看应用程序设置的权限。",
+        "security": "授予查看安全设置的权限。",
+        "markdown": "授予查看markdown设置的权限。",
+        "customize": "授予查看自定义设置的权限。",
+        "import_data": "授予查看数据导入设置的权限。",
+        "export_data": "授予查看数据归档设置的权限。",
+        "data_transfer": "授予查看数据迁移设置的权限。",
+        "external_notification": "授予查看外部通知设置的权限。",
+        "slack_integration": "授予查看Slack集成设置的权限。",
+        "legacy_slack_integration": "授予查看旧版Slack集成设置的权限。",
+        "user_management": "授予查看用户管理的权限。",
+        "user_group_management": "授予查看用户组管理的权限。",
+        "audit_log": "授予查看审计日志的权限。",
+        "plugin": "授予查看插件设置的权限。",
+        "ai_integration": "授予查看AI集成设置的权限。",
+        "full_text_search": "授予查看全文搜索管理的权限。"
+      },
+      "user_settings": {
+        "all": "授予查看用户设置的权限。",
+        "info": "授予查看用户信息的权限。",
+        "external_account": "授予查看外部账户的权限。",
+        "password": "授予查看密码设置的权限。",
+        "api": {
+          "all": "授予查看API设置的权限。",
+          "api_token": "授予查看API令牌设置的权限。",
+          "access_token": "授予查看访问令牌设置的权限。"
+        },
+        "in_app_notification": "授予查看应用内通知设置的权限。",
+        "other": "授予查看其他设置的权限。"
+      },
+      "features": {
+        "all": "授予查看功能的权限。",
+        "ai_assistant": "授予查看AI助手功能的权限。",
+        "page": "授予查看页面功能的权限。",
+        "share_link": "授予查看共享链接功能的权限。",
+        "bookmark": "授予查看书签功能的权限。",
+        "questionnaire": "授予查看问卷功能的权限。",
+        "attachment": "授予查看附件功能的权限。"
+      }
+    },
+    "write": {
+      "all": "授予编辑所有内容的权限。",
+      "admin": {
+        "all": "授予编辑管理功能的权限。",
+        "top": "授予编辑Wiki管理顶部的权限。",
+        "app": "授予编辑应用程序设置的权限。",
+        "security": "授予编辑安全设置的权限。",
+        "markdown": "授予编辑markdown设置的权限。",
+        "customize": "授予编辑自定义设置的权限。",
+        "import_data": "授予编辑数据导入设置的权限。",
+        "export_data": "授予编辑数据归档设置的权限。",
+        "data_transfer": "授予编辑数据迁移设置的权限。",
+        "external_notification": "授予编辑外部通知设置的权限。",
+        "slack_integration": "授予编辑Slack集成设置的权限。",
+        "legacy_slack_integration": "授予编辑旧版Slack集成设置的权限。",
+        "user_management": "授予编辑用户管理的权限。",
+        "user_group_management": "授予编辑用户组管理的权限。",
+        "audit_log": "授予编辑审计日志的权限。",
+        "plugin": "授予编辑插件设置的权限。",
+        "ai_integration": "授予编辑AI集成设置的权限。",
+        "full_text_search": "授予编辑全文搜索管理的权限。"
+      },
+      "user_settings": {
+        "all": "授予编辑用户设置的权限。",
+        "info": "授予编辑用户信息的权限。",
+        "external_account": "授予编辑外部账户的权限。",
+        "password": "授予编辑密码设置的权限。",
+        "api": {
+          "all": "授予编辑API设置的权限。",
+          "api_token": "授予编辑API令牌设置的权限。",
+          "access_token": "授予编辑访问令牌设置的权限。"
+        },
+        "in_app_notification": "授予编辑应用内通知设置的权限。",
+        "other": "授予编辑其他设置的权限。"
+      },
+      "features": {
+        "all": "授予编辑功能的权限。",
+        "ai_assistant": "授予编辑AI助手功能的权限。",
+        "page": "授予编辑页面功能的权限。",
+        "share_link": "授予编辑共享链接功能的权限。",
+        "bookmark": "授予编辑书签功能的权限。",
+        "questionnaire": "授予编辑问卷功能的权限。",
+        "attachment": "授予编辑附件功能的权限。"
+      }
+    }
   }
   }
 }
 }

+ 1 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -29,7 +29,7 @@ import {
 import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
 import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
 import { useSWRxThreads } from '../../../stores/thread';
 import { useSWRxThreads } from '../../../stores/thread';
 
 
-import { MessageCard } from './MessageCard';
+import { MessageCard } from './MessageCard/MessageCard';
 import { ResizableTextarea } from './ResizableTextArea';
 import { ResizableTextarea } from './ResizableTextArea';
 
 
 import styles from './AiAssistantSidebar.module.scss';
 import styles from './AiAssistantSidebar.module.scss';

+ 0 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.module.scss → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.module.scss


+ 13 - 10
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.tsx

@@ -1,10 +1,10 @@
 import { type JSX } from 'react';
 import { type JSX } from 'react';
 
 
-import type { LinkProps } from 'next/link';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import ReactMarkdown from 'react-markdown';
 import ReactMarkdown from 'react-markdown';
 
 
-import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
+import { Header } from './ReactMarkdownComponents/Header';
+import { NextLinkWrapper } from './ReactMarkdownComponents/NextLinkWrapper';
 
 
 import styles from './MessageCard.module.scss';
 import styles from './MessageCard.module.scss';
 
 
@@ -24,13 +24,6 @@ const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
 
 
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 
 
-const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
-  return (
-    <NextLink href={props.href} className="link-primary">
-      {props.children}
-    </NextLink>
-  );
-};
 
 
 const AssistantMessageCard = ({
 const AssistantMessageCard = ({
   children,
   children,
@@ -51,7 +44,17 @@ const AssistantMessageCard = ({
           { children.length > 0
           { children.length > 0
             ? (
             ? (
               <>
               <>
-                <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
+                <ReactMarkdown components={{
+                  a: NextLinkWrapper,
+                  h1: ({ children }) => <Header level={1}>{children}</Header>,
+                  h2: ({ children }) => <Header level={2}>{children}</Header>,
+                  h3: ({ children }) => <Header level={3}>{children}</Header>,
+                  h4: ({ children }) => <Header level={4}>{children}</Header>,
+                  h5: ({ children }) => <Header level={5}>{children}</Header>,
+                  h6: ({ children }) => <Header level={6}>{children}</Header>,
+                }}
+                >{children}
+                </ReactMarkdown>
                 { additionalItem }
                 { additionalItem }
               </>
               </>
             )
             )

+ 25 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/Header.tsx

@@ -0,0 +1,25 @@
+type Level = 1 | 2 | 3 | 4 | 5 | 6;
+
+const fontSizes: Record<Level, string> = {
+  1: '1.5rem',
+  2: '1.25rem',
+  3: '1rem',
+  4: '0.875rem',
+  5: '0.75rem',
+  6: '0.625rem',
+};
+
+export const Header = ({ children, level }: { children: React.ReactNode, level: Level}): JSX.Element => {
+  const Tag = `h${level}` as keyof JSX.IntrinsicElements;
+
+  return (
+    <Tag
+      style={{
+        fontSize: fontSizes[level],
+        lineHeight: 1.4,
+      }}
+    >
+      {children}
+    </Tag>
+  );
+};

+ 13 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/NextLinkWrapper.tsx

@@ -0,0 +1,13 @@
+import React from 'react';
+
+import type { LinkProps } from 'next/link';
+
+import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
+
+export const NextLinkWrapper = (props: LinkProps & {children: React.ReactNode, href: string}): JSX.Element => {
+  return (
+    <NextLink href={props.href} className="link-primary">
+      {props.children}
+    </NextLink>
+  );
+};

+ 57 - 23
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -70,29 +70,63 @@ const withMarkdownCaution = `# IMPORTANT:
 `;
 `;
 
 
 function instruction(withMarkdown: boolean): string {
 function instruction(withMarkdown: boolean): string {
-  return `# RESPONSE FORMAT:
-You must respond with a JSON object in the following format example:
-{
-  "contents": [
-    { "message": "Your brief message about the upcoming change or proposal.\n\n" },
-    { "replace": "New text 1" },
-    { "message": "Additional explanation if needed" },
-    { "replace": "New text 2" },
-    ...more items if needed
-    { "message": "Your friendly message explaining what changes were made or suggested." }
-  ]
-}
-
-The array should contain:
-- [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
-- Objects with a "message" key for explanatory text to the user if needed.
-- Edit markdown according to user instructions and include it line by line in the 'replace' object. ${withMarkdown ? 'Return original text for lines that do not need editing.' : ''}
-- [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
-
-${withMarkdown ? withMarkdownCaution : ''}
-
-# Multilingual Support:
-Always provide messages in the same language as the user's request.`;
+  return `
+  # USER INTENT DETECTION:
+  First, analyze the user's message to determine their intent:
+  - **Consultation Type**: Questions, discussions, explanations, or advice seeking WITHOUT explicit request to edit/modify/generate text
+  - **Edit Type**: Clear requests to edit, modify, fix, generate, create, or write content
+
+  ## EXAMPLES OF USER INTENT:
+  ### Consultation Type Examples:
+  - "What do you think about this code?"
+  - "Please give me advice on this text structure"
+  - "Why is this error occurring?"
+  - "Is there a better approach?"
+  - "Can you explain how this works?"
+  - "What are the pros and cons of this method?"
+  - "How should I organize this document?"
+
+  ### Edit Type Examples:
+  - "Please fix the following"
+  - "Add a function that..."
+  - "Rewrite this section to..."
+  - "Correct the errors in this code"
+  - "Generate a new paragraph about..."
+  - "Modify this to include..."
+  - "Create a template for..."
+
+  # RESPONSE FORMAT:
+  ## For Consultation Type (discussion/advice only):
+  Respond with a JSON object containing ONLY message objects:
+  {
+    "contents": [
+      { "message": "Your thoughtful response to the user's question or consultation.\n\nYou can use multiple paragraphs as needed." }
+    ]
+  }
+
+  ## For Edit Type (explicit editing request):
+  Respond with a JSON object in the following format:
+  {
+    "contents": [
+      { "message": "Your brief message about the upcoming change or proposal.\n\n" },
+      { "replace": "New text 1" },
+      { "message": "Additional explanation if needed" },
+      { "replace": "New text 2" },
+      ...more items if needed
+      { "message": "Your friendly message explaining what changes were made or suggested." }
+    ]
+  }
+
+  The array should contain:
+  - [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
+  - Objects with a "message" key for explanatory text to the user if needed.
+  - Edit markdown according to user instructions and include it line by line in the 'replace' object. ${withMarkdown ? 'Return original text for lines that do not need editing.' : ''}
+  - [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
+
+  ${withMarkdown ? withMarkdownCaution : ''}
+
+  # Multilingual Support:
+  Always provide messages in the same language as the user's request.`;
 }
 }
 /* eslint-disable max-len */
 /* eslint-disable max-len */
 
 

+ 3 - 1
apps/app/src/interfaces/scope.ts

@@ -4,6 +4,8 @@
 
 
 // If you want to add a new scope, you only need to add a new key to the SCOPE_SEED object.
 // If you want to add a new scope, you only need to add a new key to the SCOPE_SEED object.
 
 
+// Change translation file contents (accesstoken_scopes_desc) when scope structure is modified
+
 const SCOPE_SEED_ADMIN = {
 const SCOPE_SEED_ADMIN = {
   admin: {
   admin: {
     top: {},
     top: {},
@@ -12,7 +14,7 @@ const SCOPE_SEED_ADMIN = {
     markdown: {},
     markdown: {},
     customize: {},
     customize: {},
     import_data: {},
     import_data: {},
-    exporet_data: {},
+    export_data: {},
     data_transfer: {},
     data_transfer: {},
     external_notification: {},
     external_notification: {},
     slack_integration: {},
     slack_integration: {},

+ 79 - 32
apps/app/src/server/routes/apiv3/app-settings.js

@@ -1,4 +1,6 @@
-import { ConfigSource } from '@growi/core/dist/interfaces';
+import {
+  ConfigSource, toNonBlankString, toNonBlankStringOrUndefined,
+} from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 
 
@@ -368,6 +370,7 @@ module.exports = (crowi) => {
       body('gcsBucket').trim(),
       body('gcsBucket').trim(),
       body('gcsUploadNamespace').trim(),
       body('gcsUploadNamespace').trim(),
       body('gcsReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
       body('gcsReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+      body('s3Bucket').trim(),
       body('s3Region')
       body('s3Region')
         .trim()
         .trim()
         .if(value => value !== '')
         .if(value => value !== '')
@@ -388,7 +391,6 @@ module.exports = (crowi) => {
           }
           }
           return true;
           return true;
         }),
         }),
-      body('s3Bucket').trim(),
       body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('s3SecretAccessKey').trim(),
       body('s3SecretAccessKey').trim(),
       body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
       body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
@@ -897,42 +899,88 @@ module.exports = (crowi) => {
     addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
     addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
       const { fileUploadType } = req.body;
       const { fileUploadType } = req.body;
 
 
-      const requestParams = {
-        'app:fileUploadType': fileUploadType,
-      };
-
-      if (fileUploadType === 'gcs') {
-        requestParams['gcs:apiKeyJsonPath'] = req.body.gcsApiKeyJsonPath;
-        requestParams['gcs:bucket'] = req.body.gcsBucket;
-        requestParams['gcs:uploadNamespace'] = req.body.gcsUploadNamespace;
-        requestParams['gcs:referenceFileWithRelayMode'] = req.body.gcsReferenceFileWithRelayMode;
+      if (fileUploadType === 'aws') {
+        try {
+          try {
+            toNonBlankString(req.body.s3Bucket);
+          }
+          catch (err) {
+            throw new Error('S3 Bucket name is required');
+          }
+          try {
+            toNonBlankString(req.body.s3Region);
+          }
+          catch (err) {
+            throw new Error('S3 Region is required');
+          }
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'aws:s3Region': toNonBlankString(req.body.s3Region),
+            'aws:s3Bucket': toNonBlankString(req.body.s3Bucket),
+            'aws:referenceFileWithRelayMode': req.body.s3ReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+          await configManager.updateConfigs({
+            'app:s3CustomEndpoint': toNonBlankStringOrUndefined(req.body.s3CustomEndpoint),
+            'aws:s3AccessKeyId': toNonBlankStringOrUndefined(req.body.s3AccessKeyId),
+            'aws:s3SecretAccessKey': toNonBlankStringOrUndefined(req.body.s3SecretAccessKey),
+          },
+          {
+            skipPubsub: true,
+            removeIfUndefined: true,
+          });
+        }
+        catch (err) {
+          const msg = `Error occurred in updating AWS S3 settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
       }
       }
 
 
-      if (fileUploadType === 'aws') {
-        requestParams['aws:s3Region'] = req.body.s3Region;
-        requestParams['aws:s3CustomEndpoint'] = req.body.s3CustomEndpoint;
-        requestParams['aws:s3Bucket'] = req.body.s3Bucket;
-        requestParams['aws:s3AccessKeyId'] = req.body.s3AccessKeyId;
-        requestParams['aws:referenceFileWithRelayMode'] = req.body.s3ReferenceFileWithRelayMode;
+      if (fileUploadType === 'gcs') {
+        try {
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'gcs:referenceFileWithRelayMode': req.body.gcsReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+          await configManager.updateConfigs({
+            'gcs:apiKeyJsonPath': toNonBlankStringOrUndefined(req.body.gcsApiKeyJsonPath),
+            'gcs:bucket': toNonBlankStringOrUndefined(req.body.gcsBucket),
+            'gcs:uploadNamespace': toNonBlankStringOrUndefined(req.body.gcsUploadNamespace),
+          },
+          { skipPubsub: true, removeIfUndefined: true });
+        }
+        catch (err) {
+          const msg = `Error occurred in updating GCS settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
       }
       }
 
 
       if (fileUploadType === 'azure') {
       if (fileUploadType === 'azure') {
-        requestParams['azure:tenantId'] = req.body.azureTenantId;
-        requestParams['azure:clientId'] = req.body.azureClientId;
-        requestParams['azure:clientSecret'] = req.body.azureClientSecret;
-        requestParams['azure:storageAccountName'] = req.body.azureStorageAccountName;
-        requestParams['azure:storageContainerName'] = req.body.azureStorageContainerName;
-        requestParams['azure:referenceFileWithRelayMode'] = req.body.azureReferenceFileWithRelayMode;
+        try {
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'azure:referenceFileWithRelayMode': req.body.azureReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+          await configManager.updateConfigs({
+            'azure:tenantId': toNonBlankStringOrUndefined(req.body.azureTenantId),
+            'azure:clientId': toNonBlankStringOrUndefined(req.body.azureClientId),
+            'azure:clientSecret': toNonBlankStringOrUndefined(req.body.azureClientSecret),
+            'azure:storageAccountName': toNonBlankStringOrUndefined(req.body.azureStorageAccountName),
+            'azure:storageContainerName': toNonBlankStringOrUndefined(req.body.azureStorageContainerName),
+          }, { skipPubsub: true, removeIfUndefined: true });
+        }
+        catch (err) {
+          const msg = `Error occurred in updating Azure settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
       }
       }
 
 
       try {
       try {
-        await configManager.updateConfigs(requestParams, { skipPubsub: true });
-
-        const s3SecretAccessKey = req.body.s3SecretAccessKey;
-        if (fileUploadType === 'aws' && s3SecretAccessKey != null && s3SecretAccessKey.trim() !== '') {
-          await configManager.updateConfigs({ 'aws:s3SecretAccessKey': s3SecretAccessKey }, { skipPubsub: true });
-        }
-
         await crowi.setUpFileUpload(true);
         await crowi.setUpFileUpload(true);
         crowi.fileUploaderSwitchService.publishUpdatedMessage();
         crowi.fileUploaderSwitchService.publishUpdatedMessage();
 
 
@@ -968,11 +1016,10 @@ module.exports = (crowi) => {
         return res.apiv3({ responseParams });
         return res.apiv3({ responseParams });
       }
       }
       catch (err) {
       catch (err) {
-        const msg = 'Error occurred in updating fileUploadType';
+        const msg = 'Error occurred in retrieving file upload configurations';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
       }
       }
-
     });
     });
 
 
   /**
   /**

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

@@ -172,7 +172,7 @@ module.exports = (crowi) => {
    *                  status:
    *                  status:
    *                    $ref: '#/components/schemas/ExportStatus'
    *                    $ref: '#/components/schemas/ExportStatus'
    */
    */
-  router.get('/status', accessTokenParser([SCOPE.READ.ADMIN.EXPORET_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
+  router.get('/status', accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
     const status = await exportService.getStatus();
     const status = await exportService.getStatus();
 
 
     // TODO: use res.apiv3
     // TODO: use res.apiv3
@@ -212,7 +212,7 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    type: boolean
    *                    description: whether the request is succeeded
    *                    description: whether the request is succeeded
    */
    */
-  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORET_DATA], { acceptLegacy: true }), loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, addActivity, async(req, res) => {
     // TODO: add express validator
     // TODO: add express validator
     try {
     try {
       const { collections } = req.body;
       const { collections } = req.body;
@@ -261,7 +261,7 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    type: boolean
    *                    description: whether the request is succeeded
    *                    description: whether the request is succeeded
    */
    */
-  router.delete('/:fileName', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORET_DATA], { acceptLegacy: true }), loginRequired, adminRequired,
+  router.delete('/:fileName', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired,
     validator.deleteFile, apiV3FormValidator, addActivity,
     validator.deleteFile, apiV3FormValidator, addActivity,
     async(req, res) => {
     async(req, res) => {
     // TODO: add express validator
     // TODO: add express validator

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

@@ -467,7 +467,7 @@ module.exports = (crowi: Crowi): Router => {
    *                    description: The transfer key
    *                    description: The transfer key
    */
    */
   receiveRouter.post('/generate-key',
   receiveRouter.post('/generate-key',
-    accessTokenParser([SCOPE.WRITE.ADMIN.EXPORET_DATA], { acceptLegacy: true }),
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
     adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
     adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
       const appSiteUrl = req.body.appSiteUrl ?? configManager.getConfig('app:siteUrl');
       const appSiteUrl = req.body.appSiteUrl ?? configManager.getConfig('app:siteUrl');
 
 
@@ -534,7 +534,7 @@ module.exports = (crowi: Crowi): Router => {
    *                    description: The message of the result
    *                    description: The message of the result
    */
    */
   pushRouter.post('/transfer',
   pushRouter.post('/transfer',
-    accessTokenParser([SCOPE.WRITE.ADMIN.EXPORET_DATA], { acceptLegacy: true }),
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
     loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
       const { transferKey, collections, optionsMap } = req.body;
       const { transferKey, collections, optionsMap } = req.body;
 
 

+ 18 - 14
apps/app/src/server/service/config-manager/config-definition.ts

@@ -1,6 +1,9 @@
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
-import type { ConfigDefinition, Lang } from '@growi/core/dist/interfaces';
-import { defineConfig } from '@growi/core/dist/interfaces';
+import type { ConfigDefinition, Lang, NonBlankString } from '@growi/core/dist/interfaces';
+import {
+  toNonBlankString,
+  defineConfig,
+} from '@growi/core/dist/interfaces';
 import type OpenAI from 'openai';
 import type OpenAI from 'openai';
 
 
 import { ActionGroupSize } from '~/interfaces/activity';
 import { ActionGroupSize } from '~/interfaces/activity';
@@ -821,28 +824,29 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'S3_OBJECT_ACL',
     envVarName: 'S3_OBJECT_ACL',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'aws:s3Bucket': defineConfig<string>({
-    defaultValue: 'growi',
+  'aws:s3Bucket': defineConfig<NonBlankString>({
+    defaultValue: toNonBlankString('growi'),
   }),
   }),
-  'aws:s3Region': defineConfig<string>({
-    defaultValue: 'ap-northeast-1',
+  'aws:s3Region': defineConfig<NonBlankString>({
+    defaultValue: toNonBlankString('ap-northeast-1'),
   }),
   }),
-  'aws:s3AccessKeyId': defineConfig<string | undefined>({
+  'aws:s3AccessKeyId': defineConfig<NonBlankString | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'aws:s3SecretAccessKey': defineConfig<string | undefined>({
+  'aws:s3SecretAccessKey': defineConfig<NonBlankString | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
+    isSecret: true,
   }),
   }),
-  'aws:s3CustomEndpoint': defineConfig<string | undefined>({
+  'aws:s3CustomEndpoint': defineConfig<NonBlankString | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
 
 
   // GCS Settings
   // GCS Settings
-  'gcs:apiKeyJsonPath': defineConfig<string | undefined>({
+  'gcs:apiKeyJsonPath': defineConfig<NonBlankString | undefined>({
     envVarName: 'GCS_API_KEY_JSON_PATH',
     envVarName: 'GCS_API_KEY_JSON_PATH',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'gcs:bucket': defineConfig<string | undefined>({
+  'gcs:bucket': defineConfig<NonBlankString | undefined>({
     envVarName: 'GCS_BUCKET',
     envVarName: 'GCS_BUCKET',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
@@ -868,15 +872,15 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'AZURE_REFERENCE_FILE_WITH_RELAY_MODE',
     envVarName: 'AZURE_REFERENCE_FILE_WITH_RELAY_MODE',
     defaultValue: false,
     defaultValue: false,
   }),
   }),
-  'azure:tenantId': defineConfig<string | undefined>({
+  'azure:tenantId': defineConfig<NonBlankString | undefined>({
     envVarName: 'AZURE_TENANT_ID',
     envVarName: 'AZURE_TENANT_ID',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'azure:clientId': defineConfig<string | undefined>({
+  'azure:clientId': defineConfig<NonBlankString | undefined>({
     envVarName: 'AZURE_CLIENT_ID',
     envVarName: 'AZURE_CLIENT_ID',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'azure:clientSecret': defineConfig<string | undefined>({
+  'azure:clientSecret': defineConfig<NonBlankString | undefined>({
     envVarName: 'AZURE_CLIENT_SECRET',
     envVarName: 'AZURE_CLIENT_SECRET',
     defaultValue: undefined,
     defaultValue: undefined,
     isSecret: true,
     isSecret: true,

+ 87 - 0
apps/app/src/server/service/config-manager/config-manager.integ.ts

@@ -107,6 +107,55 @@ describe('ConfigManager', () => {
     });
     });
   });
   });
 
 
+  describe('updateConfig', () => {
+    beforeEach(async() => {
+      await Config.deleteMany({ key: /app.*/ }).exec();
+      await Config.create({ key: 'app:siteUrl', value: JSON.stringify('initial value') });
+    });
+
+    test('updates a single config', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config?.value).toEqual(JSON.stringify('initial value'));
+
+      // act
+      await configManager.updateConfig('app:siteUrl', 'updated value');
+
+      // assert
+      const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(updatedConfig?.value).toEqual(JSON.stringify('updated value'));
+    });
+
+    test('removes config when value is undefined and removeIfUndefined is true', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config?.value).toEqual(JSON.stringify('initial value'));
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true });
+
+      // assert
+      const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(updatedConfig).toBeNull(); // should be removed
+    });
+
+    test('does not update config when value is undefined and removeIfUndefined is false', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config?.value).toEqual(JSON.stringify('initial value'));
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined);
+
+      // assert
+      const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(updatedConfig?.value).toEqual(JSON.stringify('initial value')); // should remain unchanged
+    });
+  });
+
   describe('updateConfigs', () => {
   describe('updateConfigs', () => {
     beforeEach(async() => {
     beforeEach(async() => {
       await Config.deleteMany({ key: /app.*/ }).exec();
       await Config.deleteMany({ key: /app.*/ }).exec();
@@ -133,6 +182,44 @@ describe('ConfigManager', () => {
       expect(updatedConfig1?.value).toEqual(JSON.stringify('new value1'));
       expect(updatedConfig1?.value).toEqual(JSON.stringify('new value1'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
     });
     });
+
+    test('removes config when value is undefined and removeIfUndefined is true', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config1?.value).toEqual(JSON.stringify('value1'));
+
+      // act
+      await configManager.updateConfigs({
+        'app:siteUrl': undefined,
+        'app:fileUploadType': 'aws',
+      }, { removeIfUndefined: true });
+
+      // assert
+      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      expect(updatedConfig1).toBeNull(); // should be removed
+      expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
+    });
+
+    test('does not update config when value is undefined and removeIfUndefined is false', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config1?.value).toEqual(JSON.stringify('value1'));
+
+      // act
+      await configManager.updateConfigs({
+        'app:siteUrl': undefined,
+        'app:fileUploadType': 'aws',
+      });
+
+      // assert
+      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      expect(updatedConfig1?.value).toEqual(JSON.stringify('value1')); // should remain unchanged
+      expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
+    });
   });
   });
 
 
   describe('removeConfigs', () => {
   describe('removeConfigs', () => {

+ 104 - 4
apps/app/src/server/service/config-manager/config-manager.spec.ts

@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
   ConfigMock: {
   ConfigMock: {
     updateOne: vi.fn(),
     updateOne: vi.fn(),
     bulkWrite: vi.fn(),
     bulkWrite: vi.fn(),
+    deleteOne: vi.fn(),
   },
   },
 }));
 }));
 vi.mock('../../models/config', () => ({
 vi.mock('../../models/config', () => ({
@@ -40,6 +41,9 @@ describe('ConfigManager test', () => {
     let loadConfigsSpy;
     let loadConfigsSpy;
     beforeEach(async() => {
     beforeEach(async() => {
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
+      // Reset mocks
+      mocks.ConfigMock.updateOne.mockClear();
+      mocks.ConfigMock.deleteOne.mockClear();
     });
     });
 
 
     test('invoke publishUpdateMessage()', async() => {
     test('invoke publishUpdateMessage()', async() => {
@@ -70,6 +74,42 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });
     });
 
 
+    test('remove config when value is undefined and removeIfUndefined is true', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true });
+
+      // assert
+      expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledWith({ key: 'app:siteUrl' });
+      expect(mocks.ConfigMock.updateOne).not.toHaveBeenCalled();
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
+    test('update config with undefined value when removeIfUndefined is false', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined);
+
+      // assert
+      expect(mocks.ConfigMock.updateOne).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.updateOne).toHaveBeenCalledWith(
+        { key: 'app:siteUrl' },
+        { value: JSON.stringify(undefined) },
+        { upsert: true },
+      );
+      expect(mocks.ConfigMock.deleteOne).not.toHaveBeenCalled();
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
   });
   });
 
 
   describe('updateConfigs()', () => {
   describe('updateConfigs()', () => {
@@ -77,18 +117,20 @@ describe('ConfigManager test', () => {
     let loadConfigsSpy;
     let loadConfigsSpy;
     beforeEach(async() => {
     beforeEach(async() => {
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
+      // Reset mocks
+      mocks.ConfigMock.bulkWrite.mockClear();
     });
     });
 
 
     test('invoke publishUpdateMessage()', async() => {
     test('invoke publishUpdateMessage()', async() => {
-      // arrenge
+      // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
       vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
       vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
 
 
       // act
       // act
-      await configManager.updateConfig('app:siteUrl', '');
+      await configManager.updateConfigs({ 'app:siteUrl': 'https://example.com' });
 
 
       // assert
       // assert
-      // expect(Config.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
@@ -102,10 +144,68 @@ describe('ConfigManager test', () => {
       await configManager.updateConfigs({ 'app:siteUrl': '' }, { skipPubsub: true });
       await configManager.updateConfigs({ 'app:siteUrl': '' }, { skipPubsub: true });
 
 
       // assert
       // assert
-      // expect(Config.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });
     });
+
+    test('remove configs when values are undefined and removeIfUndefined is true', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfigs(
+        { 'app:siteUrl': undefined, 'app:title': 'GROWI' },
+        { removeIfUndefined: true },
+      );
+
+      // assert
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
+      const operations = mocks.ConfigMock.bulkWrite.mock.calls[0][0];
+      expect(operations).toHaveLength(2);
+      expect(operations[0]).toEqual({ deleteOne: { filter: { key: 'app:siteUrl' } } });
+      expect(operations[1]).toEqual({
+        updateOne: {
+          filter: { key: 'app:title' },
+          update: { value: JSON.stringify('GROWI') },
+          upsert: true,
+        },
+      });
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
+    test('update configs including undefined values when removeIfUndefined is false', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfigs({ 'app:siteUrl': undefined, 'app:title': 'GROWI' });
+
+      // assert
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
+      const operations = mocks.ConfigMock.bulkWrite.mock.calls[0][0];
+      expect(operations).toHaveLength(2); // both operations should be included
+      expect(operations[0]).toEqual({
+        updateOne: {
+          filter: { key: 'app:siteUrl' },
+          update: { value: JSON.stringify(undefined) },
+          upsert: true,
+        },
+      });
+      expect(operations[1]).toEqual({
+        updateOne: {
+          filter: { key: 'app:title' },
+          update: { value: JSON.stringify('GROWI') },
+          upsert: true,
+        },
+      });
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
   });
   });
 
 
   describe('getManagedEnvVars()', () => {
   describe('getManagedEnvVars()', () => {

+ 24 - 12
apps/app/src/server/service/config-manager/config-manager.ts

@@ -111,11 +111,17 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     // Dynamic import to avoid loading database modules too early
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
     const { Config } = await import('../../models/config');
 
 
-    await Config.updateOne(
-      { key },
-      { value: JSON.stringify(value) },
-      { upsert: true },
-    );
+    if (options?.removeIfUndefined && value === undefined) {
+      // remove the config if the value is undefined and removeIfUndefined is true
+      await Config.deleteOne({ key });
+    }
+    else {
+      await Config.updateOne(
+        { key },
+        { value: JSON.stringify(value) },
+        { upsert: true },
+      );
+    }
 
 
     await this.loadConfigs({ source: 'db' });
     await this.loadConfigs({ source: 'db' });
 
 
@@ -128,13 +134,19 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     // Dynamic import to avoid loading database modules too early
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
     const { Config } = await import('../../models/config');
 
 
-    const operations = Object.entries(updates).map(([key, value]) => ({
-      updateOne: {
-        filter: { key },
-        update: { value: JSON.stringify(value) },
-        upsert: true,
-      },
-    }));
+    const operations = Object.entries(updates).map(([key, value]) => {
+      return (options?.removeIfUndefined && value === undefined)
+        // remove the config if the value is undefined
+        ? { deleteOne: { filter: { key } } }
+        // update
+        : {
+          updateOne: {
+            filter: { key },
+            update: { value: JSON.stringify(value) },
+            upsert: true,
+          },
+        };
+    });
 
 
     await Config.bulkWrite(operations);
     await Config.bulkWrite(operations);
     await this.loadConfigs({ source: 'db' });
     await this.loadConfigs({ source: 'db' });

+ 8 - 5
apps/app/src/server/service/file-uploader/aws/index.ts

@@ -13,6 +13,8 @@ import {
   AbortMultipartUploadCommand,
   AbortMultipartUploadCommand,
 } from '@aws-sdk/client-s3';
 } from '@aws-sdk/client-s3';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
+import type { NonBlankString } from '@growi/core/dist/interfaces';
+import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
@@ -79,14 +81,15 @@ const getS3PutObjectCannedAcl = (): ObjectCannedACL | undefined => {
   return undefined;
   return undefined;
 };
 };
 
 
-const getS3Bucket = (): string | undefined => {
-  return configManager.getConfig('aws:s3Bucket') ?? undefined; // return undefined when getConfig() returns null
+const getS3Bucket = (): NonBlankString | undefined => {
+  return toNonBlankStringOrUndefined(configManager.getConfig('aws:s3Bucket')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
 };
 };
 
 
 const S3Factory = (): S3Client => {
 const S3Factory = (): S3Client => {
   const accessKeyId = configManager.getConfig('aws:s3AccessKeyId');
   const accessKeyId = configManager.getConfig('aws:s3AccessKeyId');
   const secretAccessKey = configManager.getConfig('aws:s3SecretAccessKey');
   const secretAccessKey = configManager.getConfig('aws:s3SecretAccessKey');
-  const s3CustomEndpoint = configManager.getConfig('aws:s3CustomEndpoint') || undefined;
+  const s3Region = toNonBlankStringOrUndefined(configManager.getConfig('aws:s3Region')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
+  const s3CustomEndpoint = toNonBlankStringOrUndefined(configManager.getConfig('aws:s3CustomEndpoint'));
 
 
   return new S3Client({
   return new S3Client({
     credentials: accessKeyId != null && secretAccessKey != null
     credentials: accessKeyId != null && secretAccessKey != null
@@ -95,9 +98,9 @@ const S3Factory = (): S3Client => {
         secretAccessKey,
         secretAccessKey,
       }
       }
       : undefined,
       : undefined,
-    region: configManager.getConfig('aws:s3Region'),
+    region: s3Region,
     endpoint: s3CustomEndpoint,
     endpoint: s3CustomEndpoint,
-    forcePathStyle: !!s3CustomEndpoint, // s3ForcePathStyle renamed to forcePathStyle in v3
+    forcePathStyle: s3CustomEndpoint != null, // s3ForcePathStyle renamed to forcePathStyle in v3
   });
   });
 };
 };
 
 

+ 4 - 3
apps/app/src/server/service/file-uploader/azure.ts

@@ -17,6 +17,7 @@ import {
   type BlockBlobUploadResponse,
   type BlockBlobUploadResponse,
   type BlockBlobParallelUploadOptions,
   type BlockBlobParallelUploadOptions,
 } from '@azure/storage-blob';
 } from '@azure/storage-blob';
+import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
 
 
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { FilePathOnStoragePrefix, ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
 import { FilePathOnStoragePrefix, ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
@@ -60,9 +61,9 @@ function getAzureConfig(): AzureConfig {
 }
 }
 
 
 function getCredential(): TokenCredential {
 function getCredential(): TokenCredential {
-  const tenantId = configManager.getConfig('azure:tenantId');
-  const clientId = configManager.getConfig('azure:clientId');
-  const clientSecret = configManager.getConfig('azure:clientSecret');
+  const tenantId = toNonBlankStringOrUndefined(configManager.getConfig('azure:tenantId'));
+  const clientId = toNonBlankStringOrUndefined(configManager.getConfig('azure:clientId'));
+  const clientSecret = toNonBlankStringOrUndefined(configManager.getConfig('azure:clientSecret'));
 
 
   if (tenantId == null || clientId == null || clientSecret == null) {
   if (tenantId == null || clientId == null || clientSecret == null) {
     throw new Error(`Azure Blob Storage missing required configuration: tenantId=${tenantId}, clientId=${clientId}, clientSecret=${clientSecret}`);
     throw new Error(`Azure Blob Storage missing required configuration: tenantId=${tenantId}, clientId=${clientId}, clientSecret=${clientSecret}`);

+ 3 - 2
apps/app/src/server/service/file-uploader/gcs/index.ts

@@ -2,6 +2,7 @@ import type { Readable } from 'stream';
 import { pipeline } from 'stream/promises';
 import { pipeline } from 'stream/promises';
 
 
 import { Storage } from '@google-cloud/storage';
 import { Storage } from '@google-cloud/storage';
+import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
 import axios from 'axios';
 import axios from 'axios';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -24,7 +25,7 @@ const logger = loggerFactory('growi:service:fileUploaderGcs');
 
 
 
 
 function getGcsBucket(): string {
 function getGcsBucket(): string {
-  const gcsBucket = configManager.getConfig('gcs:bucket');
+  const gcsBucket = toNonBlankStringOrUndefined(configManager.getConfig('gcs:bucket')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
   if (gcsBucket == null) {
   if (gcsBucket == null) {
     throw new Error('GCS bucket is not configured.');
     throw new Error('GCS bucket is not configured.');
   }
   }
@@ -34,7 +35,7 @@ function getGcsBucket(): string {
 let storage: Storage;
 let storage: Storage;
 function getGcsInstance() {
 function getGcsInstance() {
   if (storage == null) {
   if (storage == null) {
-    const keyFilename = configManager.getConfig('gcs:apiKeyJsonPath');
+    const keyFilename = toNonBlankStringOrUndefined(configManager.getConfig('gcs:apiKeyJsonPath')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
     // see https://googleapis.dev/nodejs/storage/latest/Storage.html
     // see https://googleapis.dev/nodejs/storage/latest/Storage.html
     storage = keyFilename != null
     storage = keyFilename != null
       ? new Storage({ keyFilename }) // Create a client with explicit credentials
       ? new Storage({ keyFilename }) // Create a client with explicit credentials

+ 4 - 1
packages/core/src/interfaces/config-manager.ts

@@ -43,7 +43,10 @@ export type RawConfigData<K extends string, V extends Record<K, any>> = Record<K
   definition?: ConfigDefinition<V[K]>;
   definition?: ConfigDefinition<V[K]>;
 }>;
 }>;
 
 
-export type UpdateConfigOptions = { skipPubsub?: boolean };
+export type UpdateConfigOptions = {
+  skipPubsub?: boolean;
+  removeIfUndefined?: boolean;
+};
 
 
 /**
 /**
  * Interface for managing configuration values
  * Interface for managing configuration values

+ 1 - 0
packages/core/src/interfaces/index.ts

@@ -1,3 +1,4 @@
+export * from './primitive/string';
 export * from './attachment';
 export * from './attachment';
 export * from './color-scheme';
 export * from './color-scheme';
 export * from './color-scheme';
 export * from './color-scheme';

+ 164 - 0
packages/core/src/interfaces/primitive/string.spec.ts

@@ -0,0 +1,164 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  isNonEmptyString,
+  toNonEmptyString,
+  toNonEmptyStringOrUndefined,
+  isNonBlankString,
+  toNonBlankString,
+  toNonBlankStringOrUndefined,
+} from './string';
+
+describe('isNonEmptyString', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${true}       | ${'non-empty string'}
+    ${'world'}    | ${true}       | ${'non-empty string'}
+    ${'a'}        | ${true}       | ${'single character'}
+    ${'1'}        | ${true}       | ${'numeric string'}
+    ${' '}        | ${true}       | ${'space character'}
+    ${'   '}      | ${true}       | ${'multiple spaces'}
+    ${''}         | ${false}      | ${'empty string'}
+    ${null}       | ${false}      | ${'null'}
+    ${undefined}  | ${false}      | ${'undefined'}
+  `('should return $expected for $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(isNonEmptyString(input)).toBe(expected);
+  });
+});
+
+describe('isNonBlankString', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${true}       | ${'non-blank string'}
+    ${'world'}    | ${true}       | ${'non-blank string'}
+    ${'a'}        | ${true}       | ${'single character'}
+    ${'1'}        | ${true}       | ${'numeric string'}
+    ${' '}        | ${false}      | ${'space character'}
+    ${'   '}      | ${false}      | ${'multiple spaces'}
+    ${'\t'}       | ${false}      | ${'tab character'}
+    ${'\n'}       | ${false}      | ${'newline character'}
+    ${''}         | ${false}      | ${'empty string'}
+    ${null}       | ${false}      | ${'null'}
+    ${undefined}  | ${false}      | ${'undefined'}
+  `('should return $expected for $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(isNonBlankString(input)).toBe(expected);
+  });
+});
+
+describe('toNonEmptyStringOrUndefined', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${'hello'}    | ${'non-empty string'}
+    ${'world'}    | ${'world'}    | ${'non-empty string'}
+    ${'a'}        | ${'a'}        | ${'single character'}
+    ${'1'}        | ${'1'}        | ${'numeric string'}
+    ${' '}        | ${' '}        | ${'space character'}
+    ${'   '}      | ${'   '}      | ${'multiple spaces'}
+    ${''}         | ${undefined}  | ${'empty string'}
+    ${null}       | ${undefined}  | ${'null'}
+    ${undefined}  | ${undefined}  | ${'undefined'}
+  `('should return $expected for $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(toNonEmptyStringOrUndefined(input)).toBe(expected);
+  });
+});
+
+describe('toNonBlankStringOrUndefined', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${'hello'}    | ${'non-blank string'}
+    ${'world'}    | ${'world'}    | ${'non-blank string'}
+    ${'a'}        | ${'a'}        | ${'single character'}
+    ${'1'}        | ${'1'}        | ${'numeric string'}
+    ${' '}        | ${undefined}  | ${'space character'}
+    ${'   '}      | ${undefined}  | ${'multiple spaces'}
+    ${'\t'}       | ${undefined}  | ${'tab character'}
+    ${'\n'}       | ${undefined}  | ${'newline character'}
+    ${''}         | ${undefined}  | ${'empty string'}
+    ${null}       | ${undefined}  | ${'null'}
+    ${undefined}  | ${undefined}  | ${'undefined'}
+  `('should return $expected for $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(toNonBlankStringOrUndefined(input)).toBe(expected);
+  });
+});
+
+describe('toNonEmptyString', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${'hello'}    | ${'non-empty string'}
+    ${'world'}    | ${'world'}    | ${'non-empty string'}
+    ${'a'}        | ${'a'}        | ${'single character'}
+    ${'1'}        | ${'1'}        | ${'numeric string'}
+    ${' '}        | ${' '}        | ${'space character'}
+    ${'   '}      | ${'   '}      | ${'multiple spaces'}
+  `('should return $expected for valid $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(toNonEmptyString(input)).toBe(expected);
+  });
+
+  /* eslint-disable indent */
+  it.each`
+    input         | description
+    ${''}         | ${'empty string'}
+    ${null}       | ${'null'}
+    ${undefined}  | ${'undefined'}
+  `('should throw error for invalid $description: $input', ({ input }) => {
+  /* eslint-enable indent */
+    expect(() => toNonEmptyString(input)).toThrow('Expected a non-empty string, but received:');
+  });
+});
+
+describe('toNonBlankString', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${'hello'}    | ${'non-blank string'}
+    ${'world'}    | ${'world'}    | ${'non-blank string'}
+    ${'a'}        | ${'a'}        | ${'single character'}
+    ${'1'}        | ${'1'}        | ${'numeric string'}
+  `('should return $expected for valid $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(toNonBlankString(input)).toBe(expected);
+  });
+
+  /* eslint-disable indent */
+  it.each`
+    input         | description
+    ${' '}        | ${'space character'}
+    ${'   '}      | ${'multiple spaces'}
+    ${'\t'}       | ${'tab character'}
+    ${'\n'}       | ${'newline character'}
+    ${''}         | ${'empty string'}
+    ${null}       | ${'null'}
+    ${undefined}  | ${'undefined'}
+  `('should throw error for invalid $description: $input', ({ input }) => {
+  /* eslint-enable indent */
+    expect(() => toNonBlankString(input)).toThrow('Expected a non-blank string, but received:');
+  });
+});
+
+describe('type safety', () => {
+  it('should maintain type safety with branded types', () => {
+    const validString = 'test';
+
+    const nonEmptyResult = toNonEmptyStringOrUndefined(validString);
+    const nonBlankResult = toNonBlankStringOrUndefined(validString);
+
+    expect(nonEmptyResult).toBe(validString);
+    expect(nonBlankResult).toBe(validString);
+
+    // These types should be different at compile time
+    // but we can't easily test that in runtime
+    if (nonEmptyResult !== undefined && nonBlankResult !== undefined) {
+      expect(nonEmptyResult).toBe(nonBlankResult);
+    }
+  });
+});

+ 77 - 0
packages/core/src/interfaces/primitive/string.ts

@@ -0,0 +1,77 @@
+/**
+ * A branded type representing a string that is guaranteed to be non-empty (length > 0).
+ * This type allows distinguishing non-empty strings from regular strings at compile time.
+ */
+export type NonEmptyString = string & { readonly __brand: unique symbol };
+
+/**
+ * Checks if a value is a non-empty string.
+ * @param value - The value to check
+ * @returns True if the value is a string with length > 0, false otherwise
+ */
+export const isNonEmptyString = (value: string | null | undefined): value is NonEmptyString => {
+  return value != null && value.length > 0;
+};
+
+/**
+ * Converts a string to NonEmptyString type.
+ * @param value - The string to convert
+ * @returns The string as NonEmptyString type
+ * @throws Error if the value is null, undefined, or empty string
+ */
+export const toNonEmptyString = (value: string): NonEmptyString => {
+  // throw Error if the value is null, undefined or empty
+  if (!isNonEmptyString(value)) throw new Error(`Expected a non-empty string, but received: ${value}`);
+  return value;
+};
+
+/**
+ * Converts a string to NonEmptyString type or returns undefined.
+ * @param value - The string to convert
+ * @returns The string as NonEmptyString type, or undefined if the value is null, undefined, or empty
+ */
+export const toNonEmptyStringOrUndefined = (value: string | null | undefined): NonEmptyString | undefined => {
+  // return undefined if the value is null, undefined or empty
+  if (!isNonEmptyString(value)) return undefined;
+  return value;
+};
+
+/**
+ * A branded type representing a string that is guaranteed to be non-blank.
+ * A non-blank string contains at least one non-whitespace character.
+ * This type allows distinguishing non-blank strings from regular strings at compile time.
+ */
+export type NonBlankString = string & { readonly __brand: unique symbol };
+
+/**
+ * Checks if a value is a non-blank string.
+ * A non-blank string is a string that contains at least one non-whitespace character.
+ * @param value - The value to check
+ * @returns True if the value is a string with trimmed length > 0, false otherwise
+ */
+export const isNonBlankString = (value: string | null | undefined): value is NonBlankString => {
+  return value != null && value.trim().length > 0;
+};
+
+/**
+ * Converts a string to NonBlankString type.
+ * @param value - The string to convert
+ * @returns The string as NonBlankString type
+ * @throws Error if the value is null, undefined, empty string, or contains only whitespace characters
+ */
+export const toNonBlankString = (value: string): NonBlankString => {
+  // throw Error if the value is null, undefined or empty
+  if (!isNonBlankString(value)) throw new Error(`Expected a non-blank string, but received: ${value}`);
+  return value;
+};
+
+/**
+ * Converts a string to NonBlankString type or returns undefined.
+ * @param value - The string to convert
+ * @returns The string as NonBlankString type, or undefined if the value is null, undefined, empty, or contains only whitespace characters
+ */
+export const toNonBlankStringOrUndefined = (value: string | null | undefined): NonBlankString | undefined => {
+  // return undefined if the value is null, undefined or blank (empty or whitespace only)
+  if (!isNonBlankString(value)) return undefined;
+  return value;
+};