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

Merge branch 'master' into feat/97800-159429-add-shortcuts-when-editing

Shun Miyazawa 11 месяцев назад
Родитель
Сommit
b84d521edd
79 измененных файлов с 1757 добавлено и 764 удалено
  1. 9 0
      .vscode/settings.json
  2. 27 1
      CHANGELOG.md
  3. 9 7
      README.md
  4. 16 13
      README_JP.md
  5. 9 2
      apps/app/bin/swagger-jsdoc/definition-apiv1.js
  6. 9 2
      apps/app/bin/swagger-jsdoc/definition-apiv3.js
  7. 4 3
      apps/app/package.json
  8. 0 15
      apps/app/playwright/20-basic-features/presentation.spec.ts
  9. 3 35
      apps/app/playwright/20-basic-features/use-tools.spec.ts
  10. 1 0
      apps/app/public/static/locales/en_US/translation.json
  11. 1 0
      apps/app/public/static/locales/fr_FR/translation.json
  12. 1 0
      apps/app/public/static/locales/ja_JP/translation.json
  13. 1 0
      apps/app/public/static/locales/zh_CN/translation.json
  14. 1 1
      apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx
  15. 5 3
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx
  16. 4 2
      apps/app/src/client/components/PageHeader/PageHeader.tsx
  17. 28 8
      apps/app/src/client/components/PageHeader/PageTitleHeader.tsx
  18. 19 11
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx
  19. 1 0
      apps/app/src/client/components/PageTags/TagsInput.tsx
  20. 1 1
      apps/app/src/client/components/SearchPage/SearchResultContent.tsx
  21. 5 0
      apps/app/src/client/components/SearchTypeahead.tsx
  22. 12 8
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx
  23. 1 1
      apps/app/src/components/PageView/PageView.tsx
  24. 1 1
      apps/app/src/components/ShareLinkPageView/ShareLinkPageView.tsx
  25. 32 16
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  26. 10 44
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx
  27. 84 56
      apps/app/src/features/openai/client/services/editor-assistant.tsx
  28. 6 18
      apps/app/src/features/openai/client/services/knowledge-assistant.tsx
  29. 55 17
      apps/app/src/features/openai/server/models/vector-store-file-relation.ts
  30. 1 1
      apps/app/src/features/openai/server/routes/delete-thread.ts
  31. 1 1
      apps/app/src/features/openai/server/routes/get-threads.ts
  32. 4 0
      apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts
  33. 1 0
      apps/app/src/features/openai/server/services/client-delegator/interfaces.ts
  34. 4 0
      apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts
  35. 221 23
      apps/app/src/features/openai/server/services/openai.ts
  36. 46 0
      apps/app/src/features/openai/server/utils/is-vector-store-compatible.ts
  37. 12 6
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  38. 10 1
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  39. 12 6
      apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts
  40. 47 0
      apps/app/src/migrations/20250522105040-delete-old-index-for-vector-store-file-relation.js
  41. 10 1
      apps/app/src/server/crowi/index.js
  42. 59 0
      apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts
  43. 18 2
      apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts
  44. 2 1
      apps/app/src/server/routes/apiv3/activity.ts
  45. 6 3
      apps/app/src/server/routes/apiv3/app-settings.js
  46. 3 2
      apps/app/src/server/routes/apiv3/attachment.js
  47. 12 6
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  48. 4 2
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  49. 10 5
      apps/app/src/server/routes/apiv3/import.js
  50. 8 4
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  51. 9 5
      apps/app/src/server/routes/apiv3/page-listing.ts
  52. 2 5
      apps/app/src/server/routes/apiv3/page/index.ts
  53. 0 5
      apps/app/src/server/routes/apiv3/pages/index.js
  54. 19 9
      apps/app/src/server/routes/apiv3/slack-integration.js
  55. 60 8
      apps/app/src/server/service/attachment.js
  56. 1 11
      apps/app/src/server/service/page/index.ts
  57. 1 2
      apps/app/src/stores/page-listing.tsx
  58. 10 6
      apps/pdf-converter/package.json
  59. 55 0
      apps/pdf-converter/src/controllers/pdf.spec.ts
  60. 16 8
      apps/pdf-converter/src/controllers/pdf.ts
  61. 1 1
      apps/pdf-converter/src/server.ts
  62. 24 14
      apps/pdf-converter/src/service/pdf-convert.ts
  63. 2 1
      apps/pdf-converter/tsconfig.json
  64. 12 0
      apps/pdf-converter/vitest.config.ts
  65. 2 2
      apps/slackbot-proxy/package.json
  66. 1 0
      apps/slackbot-proxy/src/services/InstallerService.ts
  67. 2 2
      biome.json
  68. 2 2
      package.json
  69. 0 162
      packages/core/src/utils/path-utils.js
  70. 162 11
      packages/core/src/utils/path-utils.spec.ts
  71. 146 0
      packages/core/src/utils/path-utils.ts
  72. 11 4
      packages/pdf-converter-client/src/index.ts
  73. 7 5
      packages/remark-lsx/src/client/services/renderer/lsx.ts
  74. 3 1
      packages/remark-lsx/src/server/routes/list-pages/index.spec.ts
  75. 4 2
      packages/remark-lsx/src/server/routes/list-pages/index.ts
  76. 1 1
      packages/slack/package.json
  77. 25 13
      packages/slack/src/utils/respond-util-factory.ts
  78. 43 0
      packages/slack/src/utils/response-url-validator.ts
  79. 290 155
      pnpm-lock.yaml

+ 9 - 0
.vscode/settings.json

@@ -3,6 +3,14 @@
 
 
   "eslint.workingDirectories": [{ "mode": "auto" }],
   "eslint.workingDirectories": [{ "mode": "auto" }],
 
 
+  "[typescript]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
+  "[javascript]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
   // use vscode-stylelint
   // use vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   "stylelint.validate": ["css", "less", "scss"],
   "stylelint.validate": ["css", "less", "scss"],
@@ -32,4 +40,5 @@
   "typescript.surveys.enabled": false,
   "typescript.surveys.enabled": false,
 
 
   "vitest.filesWatcherInclude": "**/*"
   "vitest.filesWatcherInclude": "**/*"
+
 }
 }

+ 27 - 1
CHANGELOG.md

@@ -1,9 +1,35 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.2.4...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.2.5...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v7.2.5](https://github.com/weseek/growi/compare/v7.2.4...v7.2.5) - 2025-05-28
+
+### 💎 Features
+
+* feat: Save attachment to VectorStore (#9906) @miya
+
+### 🚀 Improvement
+
+* imprv: Headers and CopyDropdown UX (#9973) @yuki-takei
+* imprv(api): Support Bearer token (#9971) @yuki-takei
+* imprv: Add orgId and appId to bulk export path (#9946) @arafubeatbox
+
+### 🐛 Bug Fixes
+
+* fix: Prevent submission during text composition in SearchTypeahead (#9977) @miya
+* fix(ai): First user message is not displayed (#9976) @miya
+* fix: Vectorstore file not rebuilt after page update (#9951) @miya
+
+### 🧰 Maintenance
+
+* ci(deps-dev): bump vite from 5.4.17 to 5.4.19 (#9911) @[dependabot[bot]](https://github.com/apps/dependabot)
+* support: Prevent SSRF for slack integration (#9952) @yuki-takei
+* support: Remark lsx package biome migration (#9948) @arafubeatbox
+* support: UI package biome migration (#9945) @arafubeatbox
+* support: Migrate linter/formatter to biome for @growi/slack package (#9917) @arafubeatbox
+
 ## [v7.2.4](https://github.com/weseek/growi/compare/v7.2.3...v7.2.4) - 2025-05-15
 ## [v7.2.4](https://github.com/weseek/growi/compare/v7.2.3...v7.2.4) - 2025-05-15
 
 
 ### 🐛 Bug Fixes
 ### 🐛 Bug Fixes

+ 9 - 7
README.md

@@ -2,12 +2,12 @@
 
 
 <p align="center">
 <p align="center">
   <a href="https://growi.org">
   <a href="https://growi.org">
-    <img src="https://user-images.githubusercontent.com/1638767/38254268-d4476bbe-3793-11e8-964c-8865d690baff.png" width="240px">
+    <img src="https://github.com/user-attachments/assets/0acf1409-cea7-4f0e-841c-af5bd8be6711" width="360px">
   </a>
   </a>
 </p>
 </p>
 <p align="center">
 <p align="center">
-  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
-  <a href="https://communityinviter.com/apps/wsgrowi/invite/">join our Slack team</a>
+  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg" alt="Latest version"></a>
+  <a href="https://communityinviter.com/apps/wsgrowi/invite/"><img src="https://img.shields.io/badge/Slack-Join%20Us-4A154B?style=flat&logo=slack&logoColor=white" alt="Slack - Join US"></a>
 </p>
 </p>
 
 
 <p align="center">
 <p align="center">
@@ -16,12 +16,14 @@
 
 
 # GROWI
 # GROWI
 
 
-[![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
+[![CodeQL](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml)
+[![Node CI for app development](https://github.com/weseek/growi/actions/workflows/ci-app.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app.yml)
+[![Node CI for app production](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml)
+
+## Demonstration
+<video src="https://private-user-images.githubusercontent.com/34241526/333079483-fee540d7-2fa6-46d7-833e-74014c5340e3.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTY0NDk2OTEsIm5iZiI6MTcxNjQ0OTM5MSwicGF0aCI6Ii8zNDI0MTUyNi8zMzMwNzk0ODMtZmVlNTQwZDctMmZhNi00NmQ3LTgzM2UtNzQwMTRjNTM0MGUzLm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA1MjMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNTIzVDA3Mjk1MVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTBkYWFkMmYyYmIwMTI2YWE3ZmQzZTFiNWU3ZThkMDc5NDA5N2Q3YWE5ZGM1NDgwNjk0OGNjYjZmOTJkM2IzZGQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.FAvLseWBzE62yFA7wt26uERamvEVQdIGRVdBwk0uLhE"></video>
 
 
-|                                                     demonstration                                                     |
-| :-------------------------------------------------------------------------------------------------------------------: |
-| ![sample image](https://user-images.githubusercontent.com/42988650/70600974-6b29cc80-1c34-11ea-94ef-33c39c6a00dc.gif) |
 
 
 ## Table Of Contents
 ## Table Of Contents
 
 

+ 16 - 13
README_JP.md

@@ -1,13 +1,14 @@
 - [English 🇺🇸](./README.md)
 - [English 🇺🇸](./README.md)
-  <p align="center">
-    <a href="https://growi.org">
-      <img src="https://user-images.githubusercontent.com/1638767/38254268-d4476bbe-3793-11e8-964c-8865d690baff.png" width="240px">
-    </a>
-  </p>
-  <p align="center">
-    <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
-    <a href="https://communityinviter.com/apps/wsgrowi/invite/">join our Slack team</a>
-  </p>
+
+<p align="center">
+  <a href="https://growi.org">
+    <img src="https://github.com/user-attachments/assets/0acf1409-cea7-4f0e-841c-af5bd8be6711" width="360px">
+  </a>
+</p>
+<p align="center">
+  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg" alt="Latest version"></a>
+  <a href="https://communityinviter.com/apps/wsgrowi/invite/"><img src="https://img.shields.io/badge/Slack-Join%20Us-4A154B?style=flat&logo=slack&logoColor=white" alt="Slack - Join US"></a>
+</p>
 
 
 <p align="center">
 <p align="center">
   <a href="https://docs.growi.org">ドキュメント</a> / <a href="https://demo.growi.org">デモ</a>
   <a href="https://docs.growi.org">ドキュメント</a> / <a href="https://demo.growi.org">デモ</a>
@@ -15,12 +16,14 @@
 
 
 # GROWI
 # GROWI
 
 
-[![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
+[![CodeQL](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml)
+[![Node CI for app development](https://github.com/weseek/growi/actions/workflows/ci-app.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app.yml)
+[![Node CI for app production](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml)
+
+## デモ
+<video src="https://private-user-images.githubusercontent.com/34241526/333079216-cec7f7d8-c3cc-4ee7-bc94-167b056d4ce2.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTY0NDk0MDQsIm5iZiI6MTcxNjQ0OTEwNCwicGF0aCI6Ii8zNDI0MTUyNi8zMzMwNzkyMTYtY2VjN2Y3ZDgtYzNjYy00ZWU3LWJjOTQtMTY3YjA1NmQ0Y2UyLm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA1MjMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNTIzVDA3MjUwNFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWQ2M2IwZjc0ZGNhOWQxNWE4MGIyZTZlMzQ0ZDQ4MGZlNjEzMWE3MTQ1YmMwYzg3MmY1NWMyZWI2NzQ3NGIwMTkmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.qLfu5120JrkdfpghXlLG8wCn0p4JNZ7W8AD3zUJTIYY"></video>
 
 
-|                                                 デモンストレーション                                                 |
-| :-------------------------------------------------------------------------------------------------------------------: |
-| ![sample image](https://user-images.githubusercontent.com/42988650/70600974-6b29cc80-1c34-11ea-94ef-33c39c6a00dc.gif) |
 
 
 ## 目次
 ## 目次
 
 

+ 9 - 2
apps/app/bin/swagger-jsdoc/definition-apiv1.js

@@ -13,15 +13,22 @@ module.exports = {
   ],
   ],
   security: [
   security: [
     {
     {
-      api_key: [],
+      bearer: [],
+      accessTokenInQuery: [],
     },
     },
   ],
   ],
   components: {
   components: {
     securitySchemes: {
     securitySchemes: {
-      api_key: {
+      bearer: {
+        type: 'http',
+        scheme: 'bearer',
+        description: 'Access token generated by each GROWI users',
+      },
+      accessTokenInQuery: {
         type: 'apiKey',
         type: 'apiKey',
         name: 'access_token',
         name: 'access_token',
         in: 'query',
         in: 'query',
+        description: 'Access token generated by each GROWI users',
       },
       },
     },
     },
   },
   },

+ 9 - 2
apps/app/bin/swagger-jsdoc/definition-apiv3.js

@@ -13,15 +13,22 @@ module.exports = {
   ],
   ],
   security: [
   security: [
     {
     {
-      api_key: [],
+      bearer: [],
+      accessTokenInQuery: [],
     },
     },
   ],
   ],
   components: {
   components: {
     securitySchemes: {
     securitySchemes: {
-      api_key: {
+      bearer: {
+        type: 'http',
+        scheme: 'bearer',
+        description: 'Access token generated by each GROWI users',
+      },
+      accessTokenInQuery: {
         type: 'apiKey',
         type: 'apiKey',
         name: 'access_token',
         name: 'access_token',
         in: 'query',
         in: 'query',
+        description: 'Access token generated by each GROWI users',
       },
       },
       cookieAuth: {
       cookieAuth: {
         type: 'apiKey',
         type: 'apiKey',

+ 4 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.2.5-RC.0",
+  "version": "7.2.6-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
@@ -73,6 +73,7 @@
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
     "@growi/core": "workspace:^",
     "@growi/core": "workspace:^",
+    "@growi/pdf-converter-client": "workspace:^",
     "@growi/pluginkit": "workspace:^",
     "@growi/pluginkit": "workspace:^",
     "@growi/presentation": "workspace:^",
     "@growi/presentation": "workspace:^",
     "@growi/preset-templates": "workspace:^",
     "@growi/preset-templates": "workspace:^",
@@ -82,20 +83,20 @@
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/remark-lsx": "workspace:^",
     "@growi/remark-lsx": "workspace:^",
     "@growi/slack": "workspace:^",
     "@growi/slack": "workspace:^",
-    "@growi/pdf-converter-client": "workspace:^",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@opentelemetry/api": "^1.9.0",
     "@opentelemetry/api": "^1.9.0",
     "@opentelemetry/auto-instrumentations-node": "^0.55.1",
     "@opentelemetry/auto-instrumentations-node": "^0.55.1",
     "@opentelemetry/exporter-metrics-otlp-grpc": "^0.57.0",
     "@opentelemetry/exporter-metrics-otlp-grpc": "^0.57.0",
     "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0",
     "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0",
     "@opentelemetry/resources": "^1.28.0",
     "@opentelemetry/resources": "^1.28.0",
-    "@opentelemetry/semantic-conventions": "^1.28.0",
     "@opentelemetry/sdk-metrics": "^1.28.0",
     "@opentelemetry/sdk-metrics": "^1.28.0",
     "@opentelemetry/sdk-node": "^0.57.0",
     "@opentelemetry/sdk-node": "^0.57.0",
     "@opentelemetry/sdk-trace-node": "^1.28.0",
     "@opentelemetry/sdk-trace-node": "^1.28.0",
+    "@opentelemetry/semantic-conventions": "^1.28.0",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@slack/webhook": "^6.0.0",
     "@types/async": "^3.2.24",
     "@types/async": "^3.2.24",
+    "@types/multer": "^1.4.12",
     "JSONStream": "^1.3.5",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",

+ 0 - 15
apps/app/playwright/20-basic-features/presentation.spec.ts

@@ -10,19 +10,4 @@ test('Presentation', async({ page }) => {
   // check the content of the h1
   // check the content of the h1
   await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
   await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
     .toHaveText(/Welcome to GROWI/);
     .toHaveText(/Welcome to GROWI/);
-
-  // forward the slide with keyboard
-  await page.keyboard.press('ArrowRight');
-
-  // check the content of the h1
-  await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
-    .toHaveText(/What can you do with GROWI?/);
-
-  // forward the slide with button
-  await page.getByRole('application').getByLabel('next slide').click();
-
-  // check the content of the h2
-  await expect(page.getByRole('application').getByRole('heading', { level: 2 }))
-    .toHaveText(/1. Knowledge Management: Create pages to store information and knowledge/);
-
 });
 });

+ 3 - 35
apps/app/playwright/20-basic-features/use-tools.spec.ts

@@ -19,45 +19,13 @@ const openPageItemControl = async(page: Page): Promise<void> => {
   await button.click();
   await button.click();
 };
 };
 
 
-const openPutBackPageModal = async(page: Page): Promise<void> => {
-  const alert = page.getByTestId('trash-page-alert');
-  const button = alert.getByTestId('put-back-button');
-
-  // Wait for alert element to be visible and attached
-  await expect(alert).toBeVisible();
-  await alert.waitFor({ state: 'visible' });
-
-  // Wait for button to be visible, enabled and attached
-  await expect(button).toBeVisible();
-  await expect(button).toBeEnabled();
-  await button.waitFor({ state: 'visible' });
-
-  // Scroll to the top of the page to prevent the subnav hide the button
-  await page.evaluate(() => {
-    document.documentElement.scrollTop = 0;
-    document.body.scrollTop = 0; // For Safari and older browsers
-  });
-
-  // Add a small delay to ensure scrolling is complete and the button is interactive
-  await page.waitForTimeout(200); // Increased delay
-
-  await button.click();
-  await expect(page.getByTestId('put-back-page-modal')).toBeVisible();
-};
-
-test('Page Deletion and PutBack is executed successfully', async({ page }) => {
-  await page.goto('/Sandbox/Bootstrap5');
+test('PageDeleteModal is shown successfully', async({ page }) => {
+  await page.goto('/Sandbox');
 
 
-  // Delete
   await openPageItemControl(page);
   await openPageItemControl(page);
   await page.getByTestId('open-page-delete-modal-btn').click();
   await page.getByTestId('open-page-delete-modal-btn').click();
-  await expect(page.getByTestId('page-delete-modal')).toBeVisible();
-  await page.getByTestId('delete-page-button').click();
 
 
-  // PutBack
-  await openPutBackPageModal(page);
-  await page.getByTestId('put-back-execution-button').click();
-  await expect(page.getByTestId('trash-page-alert')).not.toBeVisible();
+  await expect(page.getByTestId('page-delete-modal')).toBeVisible();
 });
 });
 
 
 test('PageDuplicateModal is shown successfully', async({ page }) => {
 test('PageDuplicateModal is shown successfully', async({ page }) => {

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

@@ -515,6 +515,7 @@
     "accept": "Accept",
     "accept": "Accept",
     "use_assistant": "Use Assistant",
     "use_assistant": "Use Assistant",
     "remove_assistant": "Deselect the selected assistant",
     "remove_assistant": "Deselect the selected assistant",
+    "text_generation_by_editor_assistant_label": "Editor Assistant is generating text",
     "preset_menu": {
     "preset_menu": {
       "summarize": {
       "summarize": {
         "title": "Summarize this article",
         "title": "Summarize this article",

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

@@ -509,6 +509,7 @@
     "accept": "Accepter",
     "accept": "Accepter",
     "use_assistant": "Utiliser l'assistant",
     "use_assistant": "Utiliser l'assistant",
     "remove_assistant": "Désélectionner l'assistant sélectionné",
     "remove_assistant": "Désélectionner l'assistant sélectionné",
+    "text_generation_by_editor_assistant_label": "L'assistant de rédaction génère du texte",
     "preset_menu": {
     "preset_menu": {
       "summarize": {
       "summarize": {
         "title": "Résumer cet article'",
         "title": "Résumer cet article'",

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

@@ -547,6 +547,7 @@
     "accept": "採用",
     "accept": "採用",
     "use_assistant": "アシスタントを使用する",
     "use_assistant": "アシスタントを使用する",
     "remove_assistant": "選択されているアシスタントの解除",
     "remove_assistant": "選択されているアシスタントの解除",
+    "text_generation_by_editor_assistant_label": "エディターアシスタントが文章を生成中",
     "preset_menu": {
     "preset_menu": {
       "summarize": {
       "summarize": {
         "title": "この記事の要約をつくる",
         "title": "この記事の要約をつくる",

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

@@ -504,6 +504,7 @@
     "accept": "接受",
     "accept": "接受",
     "use_assistant": "使用助手",
     "use_assistant": "使用助手",
     "remove_assistant": "取消选定的助手",
     "remove_assistant": "取消选定的助手",
+    "text_generation_by_editor_assistant_label": "编辑助理正在生成文本",
     "preset_menu": {
     "preset_menu": {
       "summarize": {
       "summarize": {
         "title": "为此文章创建摘要",
         "title": "为此文章创建摘要",

+ 1 - 1
apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx

@@ -30,7 +30,7 @@ const ShareLinkTr = (props: ShareLinkTrProps): JSX.Element => {
         { isRelatedPageExists && (
         { isRelatedPageExists && (
           <CopyDropdown
           <CopyDropdown
             pagePath={relatedPage.path}
             pagePath={relatedPage.path}
-            dropdownToggleId={`copydropdown-${shareLinkId}`}
+            dropdownToggleId={`copydropdown-for-share-link-list-${shareLinkId}`}
             pageId={shareLinkId}
             pageId={shareLinkId}
             isShareLinkMode
             isShareLinkMode
           >
           >

+ 5 - 3
apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx

@@ -1,8 +1,8 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
 
 
-import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
+import { useIsAiEnabled } from '~/stores-universal/context';
 import { useDrawerOpened } from '~/stores/ui';
 import { useDrawerOpened } from '~/stores/ui';
 
 
 import { EditorAssistantToggleButton } from './EditorAssistantToggleButton';
 import { EditorAssistantToggleButton } from './EditorAssistantToggleButton';
@@ -16,7 +16,7 @@ const SavePageControls = dynamic(() => import('./SavePageControls').then(mod =>
 const OptionsSelector = dynamic(() => import('./OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
 const OptionsSelector = dynamic(() => import('./OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
 
 
 export const EditorNavbarBottom = (): JSX.Element => {
 export const EditorNavbarBottom = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { data: isAiEnabled } = useIsAiEnabled();
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
 
 
   return (
   return (
@@ -31,7 +31,9 @@ export const EditorNavbarBottom = (): JSX.Element => {
         </a>
         </a>
         <form className="me-auto d-flex gap-2">
         <form className="me-auto d-flex gap-2">
           <OptionsSelector />
           <OptionsSelector />
-          <EditorAssistantToggleButton />
+          {isAiEnabled && (
+            <EditorAssistantToggleButton />
+          )}
         </form>
         </form>
         <form>
         <form>
           <SavePageControls />
           <SavePageControls />

+ 4 - 2
apps/app/src/client/components/PageHeader/PageHeader.tsx

@@ -26,8 +26,10 @@ export const PageHeader = (): JSX.Element => {
       setMaxWidth(300);
       setMaxWidth(300);
       return;
       return;
     }
     }
-    // At least 10px space between PageHeader and PageControls
-    const maxWidth = pageControlsX - pageHeaderRef.current.getBoundingClientRect().x - 10;
+
+    // PageControls.x - PageHeader.x
+    const maxWidth = pageControlsX - pageHeaderRef.current.getBoundingClientRect().x;
+
     setMaxWidth(maxWidth);
     setMaxWidth(maxWidth);
   }, [pageControlsX]);
   }, [pageControlsX]);
 
 

+ 28 - 8
apps/app/src/client/components/PageHeader/PageTitleHeader.tsx

@@ -1,5 +1,7 @@
 import type { ChangeEvent, JSX } from 'react';
 import type { ChangeEvent, JSX } from 'react';
-import { useState, useCallback, useEffect } from 'react';
+import {
+  useState, useCallback, useEffect, useMemo,
+} from 'react';
 
 
 import nodePath from 'path';
 import nodePath from 'path';
 
 
@@ -101,9 +103,27 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
 
   const isInvalid = validationResult != null;
   const isInvalid = validationResult != null;
 
 
-  const inputMaxWidth = maxWidth != null
-    ? getAdjustedMaxWidthForAutosizeInput(maxWidth, 'md', validationResult != null ? false : undefined) - 16
-    : undefined;
+  // calculate inputMaxWidth as the maximum width of AutoSizeInput minus the width of WIP badge and CopyDropdown
+  const inputMaxWidth = useMemo(() => {
+    if (maxWidth == null) {
+      return undefined;
+    }
+
+    const wipBadgeAndCopyDropdownWidth = 4 // me-1
+      + (currentPage.wip ? 49 : 0) // WIP badge + gap
+      + 24; // CopyDropdown
+
+    return getAdjustedMaxWidthForAutosizeInput(maxWidth, 'md', validationResult != null ? false : undefined) - wipBadgeAndCopyDropdownWidth;
+  }, [currentPage.wip, maxWidth, validationResult]);
+
+  // calculate h1MaxWidth as the inputMaxWidth plus padding
+  const h1MaxWidth = useMemo(() => {
+    if (inputMaxWidth == null) {
+      return undefined;
+    }
+
+    return inputMaxWidth + 16; // plus the padding of px-2 because AutosizeInput has "box-sizing: content-box;"
+  }, [inputMaxWidth]);
 
 
   return (
   return (
     <div className={`d-flex ${moduleClass} ${props.className ?? ''} position-relative`}>
     <div className={`d-flex ${moduleClass} ${props.className ?? ''} position-relative`}>
@@ -129,22 +149,22 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
             ${isRenameInputShown ? 'invisible' : ''} text-truncate
             ${isRenameInputShown ? 'invisible' : ''} text-truncate
             ${isMovable ? 'border border-2 rounded-2' : ''} ${borderColorClass}
             ${isMovable ? 'border border-2 rounded-2' : ''} ${borderColorClass}
           `}
           `}
-          style={{ maxWidth: inputMaxWidth }}
+          style={{ maxWidth: h1MaxWidth }}
           onClick={onClickPageTitle}
           onClick={onClickPageTitle}
         >
         >
           {pageTitle}
           {pageTitle}
         </h1>
         </h1>
       </div>
       </div>
 
 
-      <div className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center`}>
+      <div className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center gap-2`}>
         { currentPage.wip && (
         { currentPage.wip && (
-          <span className="badge rounded-pill text-bg-secondary ms-2">WIP</span>
+          <span className="badge rounded-pill text-bg-secondary">WIP</span>
         )}
         )}
 
 
         <CopyDropdown
         <CopyDropdown
           pageId={currentPage._id}
           pageId={currentPage._id}
           pagePath={currentPage.path}
           pagePath={currentPage.path}
-          dropdownToggleId={`copydropdown-${currentPage._id}`}
+          dropdownToggleId={`copydropdown-in-pagetitleheader-${currentPage._id}`}
           dropdownToggleClassName="p-1"
           dropdownToggleClassName="p-1"
         >
         >
           <span className="material-symbols-outlined fs-6">content_paste</span>
           <span className="material-symbols-outlined fs-6">content_paste</span>

+ 19 - 11
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx

@@ -84,22 +84,30 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
     <div ref={pagePathNavRef}>
     <div ref={pagePathNavRef}>
       <Sticky className={`${moduleClass} mb-4`} innerClass="pe-none" innerActiveClass="active mt-1">
       <Sticky className={`${moduleClass} mb-4`} innerClass="pe-none" innerActiveClass="active mt-1">
         {({ status }) => {
         {({ status }) => {
-          const isCollapseParents = status === Sticky.STATUS_FIXED;
-          return (
-            // Controlling pointer-events
-            //  2. enable pointer-events with 'pe-auto' only against the children
-            //      which width is minimized by 'd-inline-block'
-            //
-            <div className="d-inline-block pe-auto">
-              { !isCollapseParents && <PagePathNav {...props} /> }
-              { isCollapseParents && (
+          const isParentsCollapsed = status === Sticky.STATUS_FIXED;
+
+          // Controlling pointer-events
+          //  2. enable pointer-events with 'pe-auto' only against the children
+          //      which width is minimized by 'd-inline-block'
+          //
+          if (isParentsCollapsed) {
+            return (
+              <div className="d-inline-block pe-auto">
                 <PagePathNavLayout
                 <PagePathNavLayout
                   {...props}
                   {...props}
                   latterLink={latterLink}
                   latterLink={latterLink}
                   latterLinkClassName="fs-3 text-truncate"
                   latterLinkClassName="fs-3 text-truncate"
-                  maxWidth={isCollapseParents ? navMaxWidth : undefined}
+                  maxWidth={navMaxWidth}
                 />
                 />
-              ) }
+              </div>
+            );
+          }
+
+          return (
+            // Use 'd-block' to make the children take the full width
+            // This is to improve UX when opening/closing CopyDropdown
+            <div className="d-block pe-auto">
+              <PagePathNav {...props} inline />
             </div>
             </div>
           );
           );
         }}
         }}

+ 1 - 0
apps/app/src/client/components/PageTags/TagsInput.tsx

@@ -43,6 +43,7 @@ export const TagsInput: FC<Props> = (props: Props) => {
       event.preventDefault();
       event.preventDefault();
 
 
       // fix: https://redmine.weseek.co.jp/issues/140689
       // fix: https://redmine.weseek.co.jp/issues/140689
+      // "event.isComposing" is not supported
       const isComposing = event.nativeEvent.isComposing;
       const isComposing = event.nativeEvent.isComposing;
       if (isComposing) {
       if (isComposing) {
         return;
         return;

+ 1 - 1
apps/app/src/client/components/SearchPage/SearchResultContent.tsx

@@ -216,7 +216,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       <RightComponent />
       <RightComponent />
 
 
       <div className="container-lg grw-container-convertible pt-2 pb-2">
       <div className="container-lg grw-container-convertible pt-2 pb-2">
-        <PagePathNav pageId={page._id} pagePath={page.path} formerLinkClassName="small" latterLinkClassName="fs-3 text-truncate" />
+        <PagePathNav pageId={page._id} pagePath={page.path} isWipPage={page.wip} formerLinkClassName="small" latterLinkClassName="fs-3 text-truncate" />
       </div>
       </div>
 
 
       <div
       <div

+ 5 - 0
apps/app/src/client/components/SearchTypeahead.tsx

@@ -148,6 +148,11 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
 
 
   const keyDownHandler = useCallback((event: KeyboardEvent) => {
   const keyDownHandler = useCallback((event: KeyboardEvent) => {
     if (event.key === 'Enter') {
     if (event.key === 'Enter') {
+      // do nothing while composing
+      // "event.isComposing" is not supported
+      if (event.nativeEvent.isComposing) {
+        return;
+      }
       if (onSubmit != null && input != null && input.length > 0) {
       if (onSubmit != null && input != null && input.length > 0) {
         // schedule to submit with 100ms delay
         // schedule to submit with 100ms delay
         timeoutIdRef.current = setTimeout(() => onSubmit(input), DELAY_FOR_SUBMISSION);
         timeoutIdRef.current = setTimeout(() => onSubmit(input), DELAY_FOR_SUBMISSION);

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

@@ -9,8 +9,9 @@ import styles from './PagePathNav.module.scss';
 const moduleClass = styles['grw-page-path-nav-layout'] ?? '';
 const moduleClass = styles['grw-page-path-nav-layout'] ?? '';
 
 
 export type PagePathNavLayoutProps = {
 export type PagePathNavLayoutProps = {
-  className?: string,
   pagePath: string,
   pagePath: string,
+  inline?: boolean,
+  className?: string,
   pageId?: string | null,
   pageId?: string | null,
   isWipPage?: boolean,
   isWipPage?: boolean,
   maxWidth?: number,
   maxWidth?: number,
@@ -28,6 +29,7 @@ const CopyDropdown = dynamic(() => import('~/client/components/Common/CopyDropdo
 export const PagePathNavLayout = (props: Props): JSX.Element => {
 export const PagePathNavLayout = (props: Props): JSX.Element => {
   const {
   const {
     className = '',
     className = '',
+    inline = false,
     pageId, pagePath, isWipPage,
     pageId, pagePath, isWipPage,
     formerLink,
     formerLink,
     formerLinkClassName = '',
     formerLinkClassName = '',
@@ -38,7 +40,9 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
 
 
   const { data: isNotFound } = useIsNotFound();
   const { data: isNotFound } = useIsNotFound();
 
 
-  const copyDropdownId = `copydropdown-${pageId}`;
+  const copyDropdownId = `copydropdown-in-pagepathnavlayout-${pageId}`;
+
+  const containerLayoutClass = inline ? '' : 'd-flex align-items-center';
 
 
   return (
   return (
     <div
     <div
@@ -46,21 +50,21 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
       style={{ maxWidth }}
       style={{ maxWidth }}
     >
     >
       <span className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']}`}>{formerLink}</span>
       <span className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']}`}>{formerLink}</span>
-      <div className="d-flex align-items-center">
-        <h1 className={`m-0 ${latterLinkClassName}`}>
+      <div className={containerLayoutClass}>
+        <h1 className={`m-0 d-inline align-bottom ${latterLinkClassName}`}>
           {latterLink}
           {latterLink}
         </h1>
         </h1>
         { pageId != null && !isNotFound && (
         { pageId != null && !isNotFound && (
-          <div className="d-flex align-items-center ms-2">
+          <span className="d-inline-flex align-items-center align-bottom ms-2 gap-2">
             { isWipPage && (
             { isWipPage && (
-              <span className="badge text-bg-secondary ms-1 me-1">WIP</span>
+              <span className="badge text-bg-secondary">WIP</span>
             )}
             )}
-            <span className=" grw-page-path-nav-copydropdown">
+            <span className="grw-page-path-nav-copydropdown">
               <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
               <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
                 <span className="material-symbols-outlined">content_paste</span>
                 <span className="material-symbols-outlined">content_paste</span>
               </CopyDropdown>
               </CopyDropdown>
             </span>
             </span>
-          </div>
+          </span>
         ) }
         ) }
       </div>
       </div>
     </div>
     </div>

+ 1 - 1
apps/app/src/components/PageView/PageView.tsx

@@ -101,7 +101,7 @@ export const PageView = (props: Props): JSX.Element => {
     }
     }
   }, [isForbidden, isIdenticalPathPage, isNotCreatable]);
   }, [isForbidden, isIdenticalPathPage, isNotCreatable]);
 
 
-  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} />;
+  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} isWipPage={page?.wip} />;
 
 
   const sideContents = !isNotFound && !isNotCreatable
   const sideContents = !isNotFound && !isNotCreatable
     ? (
     ? (

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

@@ -61,7 +61,7 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
     }
     }
   }, [disableLinkSharing, props.disableLinkSharing]);
   }, [disableLinkSharing, props.disableLinkSharing]);
 
 
-  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} />;
+  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} isWipPage={page?.wip} />;
 
 
   const sideContents = !isNotFound
   const sideContents = !isNotFound
     ? (
     ? (

+ 32 - 16
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, type MessageCardRole } from './MessageCard';
+import { MessageCard } from './MessageCard';
 import { ResizableTextarea } from './ResizableTextArea';
 import { ResizableTextarea } from './ResizableTextArea';
 
 
 import styles from './AiAssistantSidebar.module.scss';
 import styles from './AiAssistantSidebar.module.scss';
@@ -78,7 +78,6 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
 
 
     // Views
     // Views
     initialView: initialViewForKnowledgeAssistant,
     initialView: initialViewForKnowledgeAssistant,
-    generateMessageCard: generateMessageCardForKnowledgeAssistant,
     generateModeSwitchesDropdown: generateModeSwitchesDropdownForKnowledgeAssistant,
     generateModeSwitchesDropdown: generateModeSwitchesDropdownForKnowledgeAssistant,
     headerIcon: headerIconForKnowledgeAssistant,
     headerIcon: headerIconForKnowledgeAssistant,
     headerText: headerTextForKnowledgeAssistant,
     headerText: headerTextForKnowledgeAssistant,
@@ -95,7 +94,8 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
 
 
     // Views
     // Views
     generateInitialView: generateInitialViewForEditorAssistant,
     generateInitialView: generateInitialViewForEditorAssistant,
-    generateMessageCard: generateMessageCardForEditorAssistant,
+    generatingEditorTextLabel,
+    generateActionButtons,
     headerIcon: headerIconForEditorAssistant,
     headerIcon: headerIconForEditorAssistant,
     headerText: headerTextForEditorAssistant,
     headerText: headerTextForEditorAssistant,
     placeHolder: placeHolderForEditorAssistant,
     placeHolder: placeHolderForEditorAssistant,
@@ -354,18 +354,25 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     return initialViewForKnowledgeAssistant;
     return initialViewForKnowledgeAssistant;
   }, [generateInitialViewForEditorAssistant, initialViewForKnowledgeAssistant, isEditorAssistant, submit]);
   }, [generateInitialViewForEditorAssistant, initialViewForKnowledgeAssistant, isEditorAssistant, submit]);
 
 
-  const messageCard = useCallback(
-    (role: MessageCardRole, children: string, messageId?: string, messageLogs?: MessageLog[], generatingAnswerMessage?: MessageLog) => {
-      if (isEditorAssistant) {
-        if (messageId == null || messageLogs == null) {
-          return <></>;
-        }
-        return generateMessageCardForEditorAssistant(role, children, messageId, messageLogs, generatingAnswerMessage);
+  const messageCardAdditionalItemForGeneratingMessage = useMemo(() => {
+    if (isEditorAssistant) {
+      return generatingEditorTextLabel;
+    }
+
+    return <></>;
+  }, [generatingEditorTextLabel, isEditorAssistant]);
+
+
+  const messageCardAdditionalItemForGeneratedMessage = useCallback((messageId?: string) => {
+    if (isEditorAssistant) {
+      if (messageId == null || messageLogs == null) {
+        return <></>;
       }
       }
+      return generateActionButtons(messageId, messageLogs, generatingAnswerMessage);
+    }
 
 
-      return generateMessageCardForKnowledgeAssistant(role, children);
-    }, [generateMessageCardForEditorAssistant, generateMessageCardForKnowledgeAssistant, isEditorAssistant],
-  );
+    return undefined;
+  }, [generateActionButtons, generatingAnswerMessage, isEditorAssistant, messageLogs]);
 
 
   return (
   return (
     <>
     <>
@@ -390,11 +397,21 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
               <div className="vstack gap-4 pb-2">
               <div className="vstack gap-4 pb-2">
                 { messageLogs.map(message => (
                 { messageLogs.map(message => (
                   <>
                   <>
-                    {messageCard(message.isUserMessage ? 'user' : 'assistant', message.content, message.id, messageLogs, generatingAnswerMessage)}
+                    <MessageCard
+                      role={message.isUserMessage ? 'user' : 'assistant'}
+                      additionalItem={messageCardAdditionalItemForGeneratedMessage(message.id)}
+                    >
+                      {message.content}
+                    </MessageCard>
                   </>
                   </>
                 )) }
                 )) }
                 { generatingAnswerMessage != null && (
                 { generatingAnswerMessage != null && (
-                  <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
+                  <MessageCard
+                    role="assistant"
+                    additionalItem={messageCardAdditionalItemForGeneratingMessage}
+                  >
+                    {generatingAnswerMessage.content}
+                  </MessageCard>
                 )}
                 )}
                 { messageLogs.length > 0 && (
                 { messageLogs.length > 0 && (
                   <div className="d-flex justify-content-center">
                   <div className="d-flex justify-content-center">
@@ -471,7 +488,6 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
                 </Collapse>
                 </Collapse>
               </div>
               </div>
             )}
             )}
-
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>

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

@@ -1,4 +1,4 @@
-import { useCallback, useState, type JSX } from 'react';
+import { type JSX } from 'react';
 
 
 import type { LinkProps } from 'next/link';
 import type { LinkProps } from 'next/link';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -33,27 +33,14 @@ const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): J
 };
 };
 
 
 const AssistantMessageCard = ({
 const AssistantMessageCard = ({
-  children, showActionButtons, onAccept, onDiscard,
+  children,
+  additionalItem,
 }: {
 }: {
   children: string,
   children: string,
-  showActionButtons?: boolean
-  onAccept?: () => void,
-  onDiscard?: () => void,
+  additionalItem?: JSX.Element,
 }): JSX.Element => {
 }): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const [isActionButtonClicked, setIsActionButtonClicked] = useState(false);
-
-  const clickActionButtonHandler = useCallback((action: 'accept' | 'discard') => {
-    setIsActionButtonClicked(true);
-    if (action === 'accept') {
-      onAccept?.();
-      return;
-    }
-
-    onDiscard?.();
-  }, [onAccept, onDiscard]);
-
   return (
   return (
     <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
     <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
       <div className="card-body d-flex">
       <div className="card-body d-flex">
@@ -65,25 +52,7 @@ const AssistantMessageCard = ({
             ? (
             ? (
               <>
               <>
                 <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
                 <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
-
-                {showActionButtons && !isActionButtonClicked && (
-                  <div className="d-flex mt-2 justify-content-start">
-                    <button
-                      type="button"
-                      className="btn btn-outline-secondary me-2"
-                      onClick={() => clickActionButtonHandler('discard')}
-                    >
-                      {t('sidebar_ai_assistant.discard')}
-                    </button>
-                    <button
-                      type="button"
-                      className="btn btn-success"
-                      onClick={() => clickActionButtonHandler('accept')}
-                    >
-                      {t('sidebar_ai_assistant.accept')}
-                    </button>
-                  </div>
-                )}
+                { additionalItem }
               </>
               </>
             )
             )
             : (
             : (
@@ -98,28 +67,25 @@ const AssistantMessageCard = ({
   );
   );
 };
 };
 
 
-export type MessageCardRole = 'user' | 'assistant';
+
+type MessageCardRole = 'user' | 'assistant';
 
 
 type Props = {
 type Props = {
   role: MessageCardRole,
   role: MessageCardRole,
   children: string,
   children: string,
-  showActionButtons?: boolean,
-  onDiscard?: () => void,
-  onAccept?: () => void,
+  additionalItem?: JSX.Element,
 }
 }
 
 
 export const MessageCard = (props: Props): JSX.Element => {
 export const MessageCard = (props: Props): JSX.Element => {
   const {
   const {
-    role, children, showActionButtons, onAccept, onDiscard,
+    role, children, additionalItem,
   } = props;
   } = props;
 
 
   return role === 'user'
   return role === 'user'
     ? <UserMessageCard>{children}</UserMessageCard>
     ? <UserMessageCard>{children}</UserMessageCard>
     : (
     : (
       <AssistantMessageCard
       <AssistantMessageCard
-        showActionButtons={showActionButtons}
-        onAccept={onAccept}
-        onDiscard={onDiscard}
+        additionalItem={additionalItem}
       >{children}
       >{children}
       </AssistantMessageCard>
       </AssistantMessageCard>
     );
     );

+ 84 - 56
apps/app/src/features/openai/client/services/editor-assistant.tsx

@@ -1,5 +1,5 @@
 import {
 import {
-  useCallback, useEffect, useState, useRef, useMemo,
+  useCallback, useEffect, useState, useRef, useMemo, type FC,
 } from 'react';
 } from 'react';
 
 
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
@@ -18,16 +18,12 @@ import {
   SseDetectedDiffSchema,
   SseDetectedDiffSchema,
   SseFinalizedSchema,
   SseFinalizedSchema,
   isReplaceDiff,
   isReplaceDiff,
-  // isInsertDiff,
-  // isDeleteDiff,
-  // isRetainDiff,
   type SseMessage,
   type SseMessage,
   type SseDetectedDiff,
   type SseDetectedDiff,
   type SseFinalized,
   type SseFinalized,
 } from '~/features/openai/interfaces/editor-assistant/sse-schemas';
 } from '~/features/openai/interfaces/editor-assistant/sse-schemas';
 import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
 import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
 import { useIsEnableUnifiedMergeView } from '~/stores-universal/context';
 import { useIsEnableUnifiedMergeView } from '~/stores-universal/context';
-import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import { useCurrentPageId } from '~/stores/page';
 import { useCurrentPageId } from '~/stores/page';
 
 
 import type { AiAssistantHasId } from '../../interfaces/ai-assistant';
 import type { AiAssistantHasId } from '../../interfaces/ai-assistant';
@@ -35,8 +31,6 @@ import type { MessageLog } from '../../interfaces/message';
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
 import { ThreadType } from '../../interfaces/thread-relation';
 import { ThreadType } from '../../interfaces/thread-relation';
 import { AiAssistantDropdown } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown';
 import { AiAssistantDropdown } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown';
-// import { type FormData } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar';
-import { MessageCard, type MessageCardRole } from '../components/AiAssistant/AiAssistantSidebar/MessageCard';
 import { QuickMenuList } from '../components/AiAssistant/AiAssistantSidebar/QuickMenuList';
 import { QuickMenuList } from '../components/AiAssistant/AiAssistantSidebar/QuickMenuList';
 import { useAiAssistantSidebar } from '../stores/ai-assistant';
 import { useAiAssistantSidebar } from '../stores/ai-assistant';
 
 
@@ -57,8 +51,8 @@ interface ProcessMessage {
 interface GenerateInitialView {
 interface GenerateInitialView {
   (onSubmit: (data: FormData) => Promise<void>): JSX.Element;
   (onSubmit: (data: FormData) => Promise<void>): JSX.Element;
 }
 }
-interface GenerateMessageCard {
-  (role: MessageCardRole, children: string, messageId: string, messageLogs: MessageLog[], generatingAnswerMessage?: MessageLog): JSX.Element;
+interface GenerateActionButtons {
+  (messageId: string, messageLogs: MessageLog[], generatingAnswerMessage?: MessageLog): JSX.Element;
 }
 }
 export interface FormData {
 export interface FormData {
   input: string,
   input: string,
@@ -78,10 +72,12 @@ type UseEditorAssistant = () => {
   form: UseFormReturn<FormData>
   form: UseFormReturn<FormData>
   resetForm: () => void
   resetForm: () => void
   isTextSelected: boolean,
   isTextSelected: boolean,
+  isGeneratingEditorText: boolean,
 
 
   // Views
   // Views
   generateInitialView: GenerateInitialView,
   generateInitialView: GenerateInitialView,
-  generateMessageCard: GenerateMessageCard,
+  generatingEditorTextLabel?: JSX.Element,
+  generateActionButtons: GenerateActionButtons,
   headerIcon: JSX.Element,
   headerIcon: JSX.Element,
   headerText: JSX.Element,
   headerText: JSX.Element,
   placeHolder: string,
   placeHolder: string,
@@ -142,13 +138,14 @@ const getLineInfo = (yText: YText, lineNumber: number): { text: string, startInd
 
 
 export const useEditorAssistant: UseEditorAssistant = () => {
 export const useEditorAssistant: UseEditorAssistant = () => {
   // Refs
   // Refs
-  // const positionRef = useRef<number>(0);
   const lineRef = useRef<number>(0);
   const lineRef = useRef<number>(0);
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
 
   // States
   // States
   const [detectedDiff, setDetectedDiff] = useState<DetectedDiff>();
   const [detectedDiff, setDetectedDiff] = useState<DetectedDiff>();
   const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
   const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
   const [selectedText, setSelectedText] = useState<string>();
   const [selectedText, setSelectedText] = useState<string>();
+  const [isGeneratingEditorText, setIsGeneratingEditorText] = useState<boolean>(false);
 
 
   const isTextSelected = useMemo(() => selectedText != null && selectedText.length !== 0, [selectedText]);
   const isTextSelected = useMemo(() => selectedText != null && selectedText.length !== 0, [selectedText]);
 
 
@@ -194,6 +191,9 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       }
       }
     };
     };
 
 
+    // Disable UnifiedMergeView when a Form is submitted with UnifiedMergeView enabled
+    mutateIsEnableUnifiedMergeView(false);
+
     const response = await fetch('/_api/v3/openai/edit', {
     const response = await fetch('/_api/v3/openai/edit', {
       method: 'POST',
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       headers: { 'Content-Type': 'application/json' },
@@ -205,13 +205,33 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     });
     });
 
 
     return response;
     return response;
-  }, [codeMirrorEditor, selectedText]);
+  }, [codeMirrorEditor, mutateIsEnableUnifiedMergeView, selectedText]);
 
 
   const processMessage: ProcessMessage = useCallback((data, handler) => {
   const processMessage: ProcessMessage = useCallback((data, handler) => {
+    // Reset timer whenever data is received
+    const handleDataReceived = () => {
+    // Clear existing timer
+      if (timerRef.current != null) {
+        clearTimeout(timerRef.current);
+      }
+
+      // Hide spinner since data is flowing
+      if (isGeneratingEditorText) {
+        setIsGeneratingEditorText(false);
+      }
+
+      // Set new timer
+      timerRef.current = setTimeout(() => {
+        setIsGeneratingEditorText(true);
+      }, 500);
+    };
+
     handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
     handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
+      handleDataReceived();
       handler.onMessage(data);
       handler.onMessage(data);
     });
     });
     handleIfSuccessfullyParsed(data, SseDetectedDiffSchema, (data: SseDetectedDiff) => {
     handleIfSuccessfullyParsed(data, SseDetectedDiffSchema, (data: SseDetectedDiff) => {
+      handleDataReceived();
       mutateIsEnableUnifiedMergeView(true);
       mutateIsEnableUnifiedMergeView(true);
       setDetectedDiff((prev) => {
       setDetectedDiff((prev) => {
         const newData = { data, applied: false, id: crypto.randomUUID() };
         const newData = { data, applied: false, id: crypto.randomUUID() };
@@ -225,39 +245,20 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     handleIfSuccessfullyParsed(data, SseFinalizedSchema, (data: SseFinalized) => {
     handleIfSuccessfullyParsed(data, SseFinalizedSchema, (data: SseFinalized) => {
       handler.onFinalized(data);
       handler.onFinalized(data);
     });
     });
-  }, [mutateIsEnableUnifiedMergeView]);
+  }, [isGeneratingEditorText, mutateIsEnableUnifiedMergeView]);
 
 
   const selectTextHandler = useCallback((selectedText: string, selectedTextFirstLineNumber: number) => {
   const selectTextHandler = useCallback((selectedText: string, selectedTextFirstLineNumber: number) => {
     setSelectedText(selectedText);
     setSelectedText(selectedText);
     lineRef.current = selectedTextFirstLineNumber;
     lineRef.current = selectedTextFirstLineNumber;
   }, []);
   }, []);
 
 
+
   // Effects
   // Effects
   useTextSelectionEffect(codeMirrorEditor, selectTextHandler);
   useTextSelectionEffect(codeMirrorEditor, selectTextHandler);
 
 
   useEffect(() => {
   useEffect(() => {
     const pendingDetectedDiff: DetectedDiff | undefined = detectedDiff?.filter(diff => diff.applied === false);
     const pendingDetectedDiff: DetectedDiff | undefined = detectedDiff?.filter(diff => diff.applied === false);
     if (yDocs?.secondaryDoc != null && pendingDetectedDiff != null && pendingDetectedDiff.length > 0) {
     if (yDocs?.secondaryDoc != null && pendingDetectedDiff != null && pendingDetectedDiff.length > 0) {
-
-      // For debug
-      // const testDetectedDiff = [
-      //   {
-      //     data: { diff: { retain: 9 } },
-      //     applied: false,
-      //     id: crypto.randomUUID(),
-      //   },
-      //   {
-      //     data: { diff: { delete: 5 } },
-      //     applied: false,
-      //     id: crypto.randomUUID(),
-      //   },
-      //   {
-      //     data: { diff: { insert: 'growi' } },
-      //     applied: false,
-      //     id: crypto.randomUUID(),
-      //   },
-      // ];
-
       const yText = yDocs.secondaryDoc.getText('codemirror');
       const yText = yDocs.secondaryDoc.getText('codemirror');
       yDocs.secondaryDoc.transact(() => {
       yDocs.secondaryDoc.transact(() => {
         pendingDetectedDiff.forEach((detectedDiff) => {
         pendingDetectedDiff.forEach((detectedDiff) => {
@@ -276,15 +277,6 @@ export const useEditorAssistant: UseEditorAssistant = () => {
               appendTextLastLine(yText, detectedDiff.data.diff.replace);
               appendTextLastLine(yText, detectedDiff.data.diff.replace);
             }
             }
           }
           }
-          // if (isInsertDiff(detectedDiff.data)) {
-          //   yText.insert(positionRef.current, detectedDiff.data.diff.insert);
-          // }
-          // if (isDeleteDiff(detectedDiff.data)) {
-          //   yText.delete(positionRef.current, detectedDiff.data.diff.delete);
-          // }
-          // if (isRetainDiff(detectedDiff.data)) {
-          //   positionRef.current += detectedDiff.data.diff.retain;
-          // }
         });
         });
       });
       });
 
 
@@ -308,10 +300,18 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       setSelectedText(undefined);
       setSelectedText(undefined);
       setDetectedDiff(undefined);
       setDetectedDiff(undefined);
       lineRef.current = 0;
       lineRef.current = 0;
-      // positionRef.current = 0;
     }
     }
   }, [detectedDiff]);
   }, [detectedDiff]);
 
 
+  useEffect(() => {
+    return () => {
+      if (timerRef.current != null) {
+        clearTimeout(timerRef.current);
+        timerRef.current = null;
+      }
+    };
+  }, []);
+
 
 
   // Views
   // Views
   const headerIcon = useMemo(() => {
   const headerIcon = useMemo(() => {
@@ -348,13 +348,16 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     );
     );
   }, [selectedAiAssistant]);
   }, [selectedAiAssistant]);
 
 
-
-  const generateMessageCard: GenerateMessageCard = useCallback((role, children, messageId, messageLogs, generatingAnswerMessage) => {
+  const generateActionButtons: GenerateActionButtons = useCallback((messageId, messageLogs, generatingAnswerMessage) => {
     const isActionButtonShown = (() => {
     const isActionButtonShown = (() => {
       if (!aiAssistantSidebarData?.isEditorAssistant) {
       if (!aiAssistantSidebarData?.isEditorAssistant) {
         return false;
         return false;
       }
       }
 
 
+      if (!isEnableUnifiedMergeView) {
+        return false;
+      }
+
       if (generatingAnswerMessage != null) {
       if (generatingAnswerMessage != null) {
         return false;
         return false;
       }
       }
@@ -370,7 +373,6 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       return false;
       return false;
     })();
     })();
 
 
-
     const accept = () => {
     const accept = () => {
       if (codeMirrorEditor?.view == null) {
       if (codeMirrorEditor?.view == null) {
         return;
         return;
@@ -384,17 +386,41 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       mutateIsEnableUnifiedMergeView(false);
       mutateIsEnableUnifiedMergeView(false);
     };
     };
 
 
+    if (!isActionButtonShown) {
+      return <></>;
+    }
+
+    return (
+      <div className="d-flex mt-2 justify-content-start">
+        <button
+          type="button"
+          className="btn btn-outline-secondary me-2"
+          onClick={reject}
+        >
+          {t('sidebar_ai_assistant.discard')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-success"
+          onClick={accept}
+        >
+          {t('sidebar_ai_assistant.accept')}
+        </button>
+      </div>
+    );
+  }, [aiAssistantSidebarData?.isEditorAssistant, codeMirrorEditor?.view, isEnableUnifiedMergeView, mutateIsEnableUnifiedMergeView, t]);
+
+  const generatingEditorTextLabel = useMemo(() => {
     return (
     return (
-      <MessageCard
-        role={role}
-        showActionButtons={isActionButtonShown}
-        onAccept={accept}
-        onDiscard={reject}
-      >
-        {children}
-      </MessageCard>
+      <>
+        {isGeneratingEditorText && (
+          <span className="text-thinking">
+            {t('sidebar_ai_assistant.text_generation_by_editor_assistant_label')}
+          </span>
+        )}
+      </>
     );
     );
-  }, [aiAssistantSidebarData?.isEditorAssistant, codeMirrorEditor?.view, mutateIsEnableUnifiedMergeView]);
+  }, [isGeneratingEditorText, t]);
 
 
   return {
   return {
     createThread,
     createThread,
@@ -403,10 +429,12 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     form,
     form,
     resetForm,
     resetForm,
     isTextSelected,
     isTextSelected,
+    isGeneratingEditorText,
 
 
     // Views
     // Views
     generateInitialView,
     generateInitialView,
-    generateMessageCard,
+    generatingEditorTextLabel,
+    generateActionButtons,
     headerIcon,
     headerIcon,
     headerText,
     headerText,
     placeHolder,
     placeHolder,

+ 6 - 18
apps/app/src/features/openai/client/services/knowledge-assistant.tsx

@@ -17,7 +17,6 @@ import type { MessageLog, MessageWithCustomMetaData } from '../../interfaces/mes
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
 import { ThreadType } from '../../interfaces/thread-relation';
 import { ThreadType } from '../../interfaces/thread-relation';
 import { AiAssistantChatInitialView } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView';
 import { AiAssistantChatInitialView } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView';
-import { MessageCard, type MessageCardRole } from '../components/AiAssistant/AiAssistantSidebar/MessageCard';
 import { useAiAssistantSidebar } from '../stores/ai-assistant';
 import { useAiAssistantSidebar } from '../stores/ai-assistant';
 import { useSWRMUTxMessages } from '../stores/message';
 import { useSWRMUTxMessages } from '../stores/message';
 import { useSWRMUTxThreads } from '../stores/thread';
 import { useSWRMUTxThreads } from '../stores/thread';
@@ -36,10 +35,6 @@ interface ProcessMessage {
   ): void;
   ): void;
 }
 }
 
 
-interface GenerateMessageCard {
-  (role: MessageCardRole, children: string): JSX.Element;
-}
-
 export interface FormData {
 export interface FormData {
   input: string
   input: string
   summaryMode?: boolean
   summaryMode?: boolean
@@ -59,7 +54,6 @@ type UseKnowledgeAssistant = () => {
 
 
   // Views
   // Views
   initialView: JSX.Element
   initialView: JSX.Element
-  generateMessageCard: GenerateMessageCard
   generateModeSwitchesDropdown: GenerateModeSwitchesDropdown
   generateModeSwitchesDropdown: GenerateModeSwitchesDropdown
   headerIcon: JSX.Element
   headerIcon: JSX.Element
   headerText: JSX.Element
   headerText: JSX.Element
@@ -153,16 +147,6 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
     );
     );
   }, [aiAssistantSidebarData?.aiAssistantData]);
   }, [aiAssistantSidebarData?.aiAssistantData]);
 
 
-  const generateMessageCard: GenerateMessageCard = useCallback((role, children) => {
-    return (
-      <MessageCard
-        role={role}
-      >
-        {children}
-      </MessageCard>
-    );
-  }, []);
-
   const [dropdownOpen, setDropdownOpen] = useState(false);
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
 
   const toggleDropdown = useCallback(() => {
   const toggleDropdown = useCallback(() => {
@@ -244,7 +228,7 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
 
 
     // Views
     // Views
     initialView,
     initialView,
-    generateMessageCard,
+    // generateMessageCard,
     generateModeSwitchesDropdown,
     generateModeSwitchesDropdown,
     headerIcon,
     headerIcon,
     headerText,
     headerText,
@@ -295,6 +279,10 @@ export const useFetchAndSetMessageDataEffect = (
   );
   );
 
 
   useEffect(() => {
   useEffect(() => {
+    if (aiAssistantSidebarData?.isEditorAssistant) {
+      return;
+    }
+
     if (threadId == null) {
     if (threadId == null) {
       setMessageLogs([]);
       setMessageLogs([]);
       return; // Early return if no threadId
       return; // Early return if no threadId
@@ -324,5 +312,5 @@ export const useFetchAndSetMessageDataEffect = (
     };
     };
 
 
     fetchAndSetLogs();
     fetchAndSetLogs();
-  }, [threadId, mutateMessageData, setMessageLogs]); // Dependencies
+  }, [threadId, mutateMessageData, setMessageLogs, aiAssistantSidebarData?.isEditorAssistant]); // Dependencies
 };
 };

+ 55 - 17
apps/app/src/features/openai/server/models/vector-store-file-relation.ts

@@ -7,6 +7,7 @@ import { getOrCreateModel } from '~/server/util/mongoose-utils';
 export interface VectorStoreFileRelation {
 export interface VectorStoreFileRelation {
   vectorStoreRelationId: mongoose.Types.ObjectId;
   vectorStoreRelationId: mongoose.Types.ObjectId;
   page: mongoose.Types.ObjectId;
   page: mongoose.Types.ObjectId;
+  attachment?: mongoose.Types.ObjectId;
   fileIds: string[];
   fileIds: string[];
   isAttachedToVectorStore: boolean;
   isAttachedToVectorStore: boolean;
 }
 }
@@ -19,10 +20,21 @@ interface VectorStoreFileRelationModel extends Model<VectorStoreFileRelation> {
 }
 }
 
 
 export const prepareVectorStoreFileRelations = (
 export const prepareVectorStoreFileRelations = (
-    vectorStoreRelationId: Types.ObjectId, page: Types.ObjectId, fileId: string, relationsMap: Map<string, VectorStoreFileRelation>,
+    vectorStoreRelationId: Types.ObjectId,
+    page: Types.ObjectId,
+    fileId: string,
+    relationsMap: Map<string, VectorStoreFileRelation>,
+    attachment?: Types.ObjectId,
 ): Map<string, VectorStoreFileRelation> => {
 ): Map<string, VectorStoreFileRelation> => {
-  const pageIdStr = page.toHexString();
-  const existingData = relationsMap.get(pageIdStr);
+
+  const key = (() => {
+    if (attachment == null) {
+      return page.toHexString();
+    }
+    return page.toHexString() + attachment.toHexString();
+  })();
+
+  const existingData = relationsMap.get(key);
 
 
   // If the data exists, add the fileId to the fileIds array
   // If the data exists, add the fileId to the fileIds array
   if (existingData != null) {
   if (existingData != null) {
@@ -30,11 +42,12 @@ export const prepareVectorStoreFileRelations = (
   }
   }
   // If the data doesn't exist, create a new one and add it to the map
   // If the data doesn't exist, create a new one and add it to the map
   else {
   else {
-    relationsMap.set(pageIdStr, {
+    relationsMap.set(key, {
       vectorStoreRelationId,
       vectorStoreRelationId,
       page,
       page,
       fileIds: [fileId],
       fileIds: [fileId],
       isAttachedToVectorStore: false,
       isAttachedToVectorStore: false,
+      attachment,
     });
     });
   }
   }
 
 
@@ -52,6 +65,10 @@ const schema = new Schema<VectorStoreFileRelationDocument, VectorStoreFileRelati
     ref: 'Page',
     ref: 'Page',
     required: true,
     required: true,
   },
   },
+  attachment: {
+    type: Schema.Types.ObjectId,
+    ref: 'Attachment',
+  },
   fileIds: [{
   fileIds: [{
     type: String,
     type: String,
     required: true,
     required: true,
@@ -64,22 +81,43 @@ const schema = new Schema<VectorStoreFileRelationDocument, VectorStoreFileRelati
 });
 });
 
 
 // define unique compound index
 // define unique compound index
-schema.index({ vectorStoreRelationId: 1, page: 1 }, { unique: true });
+schema.index({ vectorStoreRelationId: 1, page: 1, attachment: 1 }, { unique: true });
 
 
 schema.statics.upsertVectorStoreFileRelations = async function(vectorStoreFileRelations: VectorStoreFileRelation[]): Promise<void> {
 schema.statics.upsertVectorStoreFileRelations = async function(vectorStoreFileRelations: VectorStoreFileRelation[]): Promise<void> {
-  await this.bulkWrite(
-    vectorStoreFileRelations.map((data) => {
-      return {
-        updateOne: {
-          filter: { page: data.page, vectorStoreRelationId: data.vectorStoreRelationId },
-          update: {
-            $addToSet: { fileIds: { $each: data.fileIds } },
-          },
-          upsert: true,
+  const upsertOps = vectorStoreFileRelations
+    .filter(data => data.attachment == null)
+    .map(data => ({
+      updateOne: {
+        filter: {
+          page: data.page,
+          vectorStoreRelationId: data.vectorStoreRelationId,
+          attachment: { $exists: false },
         },
         },
-      };
-    }),
-  );
+        update: {
+          $addToSet: { fileIds: { $each: data.fileIds } },
+        },
+        upsert: true,
+      },
+    }));
+
+  const insertOps = vectorStoreFileRelations
+    .filter(data => data.attachment != null)
+    .map(data => ({
+      insertOne: {
+        document: {
+          vectorStoreRelationId: data.vectorStoreRelationId,
+          page: data.page,
+          attachment: data.attachment,
+          fileIds: data.fileIds,
+          isAttachedToVectorStore: data.isAttachedToVectorStore,
+        },
+      },
+    }));
+
+  const bulkOps = [...upsertOps, ...insertOps];
+  if (bulkOps.length > 0) {
+    await this.bulkWrite(bulkOps);
+  }
 };
 };
 
 
 // Used when attached to VectorStore
 // Used when attached to VectorStore

+ 1 - 1
apps/app/src/features/openai/server/routes/delete-thread.ts

@@ -45,7 +45,7 @@ export const deleteThreadFactory: DeleteThreadFactory = (crowi) => {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
       }
       }
 
 
-      const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, user);
+      const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, user);
       if (!isAiAssistantUsable) {
       if (!isAiAssistantUsable) {
         return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
         return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
       }
       }

+ 1 - 1
apps/app/src/features/openai/server/routes/get-threads.ts

@@ -43,7 +43,7 @@ export const getThreadsFactory: GetThreadsFactory = (crowi) => {
       try {
       try {
         const { aiAssistantId } = req.params;
         const { aiAssistantId } = req.params;
 
 
-        const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
         if (!isAiAssistantUsable) {
         if (!isAiAssistantUsable) {
           return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
           return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
         }
         }

+ 4 - 0
apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts

@@ -78,6 +78,10 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.files.create({ file, purpose: 'assistants' });
     return this.client.files.create({ file, purpose: 'assistants' });
   }
   }
 
 
+  async createVectorStoreFile(vectorStoreId: string, fileId: string): Promise<OpenAI.VectorStores.Files.VectorStoreFile> {
+    return this.client.vectorStores.files.create(vectorStoreId, { file_id: fileId });
+  }
+
   async createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.VectorStores.FileBatches.VectorStoreFileBatch> {
   async createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.VectorStores.FileBatches.VectorStoreFileBatch> {
     return this.client.vectorStores.fileBatches.create(vectorStoreId, { file_ids: fileIds });
     return this.client.vectorStores.fileBatches.create(vectorStoreId, { file_ids: fileIds });
   }
   }

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

@@ -13,6 +13,7 @@ export interface IOpenaiClientDelegator {
   createVectorStore(name: string): Promise<OpenAI.VectorStores.VectorStore>
   createVectorStore(name: string): Promise<OpenAI.VectorStores.VectorStore>
   deleteVectorStore(vectorStoreId: string): Promise<OpenAI.VectorStores.VectorStoreDeleted>
   deleteVectorStore(vectorStoreId: string): Promise<OpenAI.VectorStores.VectorStoreDeleted>
   uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject>
   uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject>
+  createVectorStoreFile(vectorStoreId: string, fileId: string): Promise<OpenAI.VectorStores.Files.VectorStoreFile>
   createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.VectorStores.FileBatches.VectorStoreFileBatch>
   createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.VectorStores.FileBatches.VectorStoreFileBatch>
   deleteFile(fileId: string): Promise<OpenAI.Files.FileDeleted>;
   deleteFile(fileId: string): Promise<OpenAI.Files.FileDeleted>;
   chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion>
   chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion>

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

@@ -79,6 +79,10 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.files.create({ file, purpose: 'assistants' });
     return this.client.files.create({ file, purpose: 'assistants' });
   }
   }
 
 
+  async createVectorStoreFile(vectorStoreId: string, fileId: string): Promise<OpenAI.VectorStores.Files.VectorStoreFile> {
+    return this.client.vectorStores.files.create(vectorStoreId, { file_id: fileId });
+  }
+
   async createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.VectorStores.FileBatches.VectorStoreFileBatch> {
   async createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.VectorStores.FileBatches.VectorStoreFileBatch> {
     return this.client.vectorStores.fileBatches.create(vectorStoreId, { file_ids: fileIds });
     return this.client.vectorStores.fileBatches.create(vectorStoreId, { file_ids: fileIds });
   }
   }

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

@@ -1,5 +1,6 @@
+import fs from 'fs';
 import assert from 'node:assert';
 import assert from 'node:assert';
-import { Readable, Transform } from 'stream';
+import { Readable, Transform, Writable } from 'stream';
 import { pipeline } from 'stream/promises';
 import { pipeline } from 'stream/promises';
 
 
 import type {
 import type {
@@ -22,6 +23,8 @@ import VectorStoreFileRelationModel, {
   type VectorStoreFileRelation,
   type VectorStoreFileRelation,
   prepareVectorStoreFileRelations,
   prepareVectorStoreFileRelations,
 } from '~/features/openai/server/models/vector-store-file-relation';
 } from '~/features/openai/server/models/vector-store-file-relation';
+import type Crowi from '~/server/crowi';
+import type { IAttachmentDocument, IAttachmentModel } from '~/server/models/attachment';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
@@ -40,6 +43,7 @@ import { removeGlobPath } from '../../utils/remove-glob-path';
 import AiAssistantModel, { type AiAssistantDocument } from '../models/ai-assistant';
 import AiAssistantModel, { type AiAssistantDocument } from '../models/ai-assistant';
 import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
 import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
 import { generateGlobPatterns } from '../utils/generate-glob-patterns';
 import { generateGlobPatterns } from '../utils/generate-glob-patterns';
+import { isVectorStoreCompatible } from '../utils/is-vector-store-compatible';
 
 
 import { getClient } from './client-delegator';
 import { getClient } from './client-delegator';
 import { openaiApiErrorHandler } from './openai-api-error-handler';
 import { openaiApiErrorHandler } from './openai-api-error-handler';
@@ -75,10 +79,8 @@ export interface IOpenaiService {
   deleteObsoletedVectorStoreRelations(): Promise<void> // for CronJob
   deleteObsoletedVectorStoreRelations(): Promise<void> // for CronJob
   deleteVectorStore(vectorStoreRelationId: string): Promise<void>;
   deleteVectorStore(vectorStoreRelationId: string): Promise<void>;
   getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>;
   getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>;
-  createVectorStoreFile(vectorStoreRelation: VectorStoreDocument, pages: PageDocument[]): Promise<void>;
   createVectorStoreFileOnPageCreate(pages: PageDocument[]): Promise<void>;
   createVectorStoreFileOnPageCreate(pages: PageDocument[]): Promise<void>;
   updateVectorStoreFileOnPageUpdate(page: HydratedDocument<PageDocument>): Promise<void>;
   updateVectorStoreFileOnPageUpdate(page: HydratedDocument<PageDocument>): Promise<void>;
-  deleteVectorStoreFile(vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId): Promise<void>;
   deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void>;
   deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void>;
   deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   isAiAssistantUsable(aiAssistantId: string, user: IUserHasId): Promise<boolean>;
   isAiAssistantUsable(aiAssistantId: string, user: IUserHasId): Promise<boolean>;
@@ -89,12 +91,24 @@ export interface IOpenaiService {
 }
 }
 class OpenaiService implements IOpenaiService {
 class OpenaiService implements IOpenaiService {
 
 
+  private crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    this.crowi = crowi;
+
+    this.createVectorStoreFileOnUploadAttachment = this.createVectorStoreFileOnUploadAttachment.bind(this);
+    crowi.attachmentService.addAttachHandler(this.createVectorStoreFileOnUploadAttachment);
+
+    this.deleteVectorStoreFileOnDeleteAttachment = this.deleteVectorStoreFileOnDeleteAttachment.bind(this);
+    crowi.attachmentService.addDetachHandler(this.deleteVectorStoreFileOnDeleteAttachment);
+  }
+
   private get client() {
   private get client() {
     const openaiServiceType = configManager.getConfig('openai:serviceType');
     const openaiServiceType = configManager.getConfig('openai:serviceType');
     return getClient({ openaiServiceType });
     return getClient({ openaiServiceType });
   }
   }
 
 
-  async generateThreadTitle(message: string): Promise<string | null> {
+  private async generateThreadTitle(message: string): Promise<string | null> {
     const systemMessage = [
     const systemMessage = [
       'Create a brief title (max 5 words) from your message.',
       'Create a brief title (max 5 words) from your message.',
       'Respond in the same language the user uses in their input.',
       'Respond in the same language the user uses in their input.',
@@ -155,7 +169,7 @@ class OpenaiService implements IOpenaiService {
     }
     }
   }
   }
 
 
-  async updateThreads(aiAssistantId: string, vectorStoreId: string): Promise<void> {
+  private async updateThreads(aiAssistantId: string, vectorStoreId: string): Promise<void> {
     const threadRelations = await this.getThreadsByAiAssistantId(aiAssistantId);
     const threadRelations = await this.getThreadsByAiAssistantId(aiAssistantId);
     for await (const threadRelation of threadRelations) {
     for await (const threadRelation of threadRelations) {
       try {
       try {
@@ -231,7 +245,7 @@ class OpenaiService implements IOpenaiService {
   }
   }
 
 
 
 
-  async getVectorStoreRelationsByPageIds(pageIds: Types.ObjectId[]): Promise<VectorStoreDocument[]> {
+  private async getVectorStoreRelationsByPageIds(pageIds: Types.ObjectId[]): Promise<VectorStoreDocument[]> {
     const pipeline = [
     const pipeline = [
       // Stage 1: Match documents with the given pageId
       // Stage 1: Match documents with the given pageId
       {
       {
@@ -309,6 +323,28 @@ class OpenaiService implements IOpenaiService {
     return uploadedFile;
     return uploadedFile;
   }
   }
 
 
+  private async uploadFileForAttachment(fileName: string, readStream?: NodeJS.ReadableStream, filePath?: string): Promise<OpenAI.Files.FileObject> {
+    const streamSource: NodeJS.ReadableStream = (() => {
+      if (readStream != null) {
+        return readStream;
+      }
+
+      if (filePath != null) {
+        return fs.createReadStream(filePath);
+      }
+
+      throw new Error('readStream and filePath are both null');
+    })();
+
+    const uploadableFile = await toFile(
+      streamSource,
+      fileName,
+    );
+
+    const uploadedFile = await this.client.uploadFile(uploadableFile);
+    return uploadedFile;
+  }
+
   async deleteVectorStore(vectorStoreRelationId: string): Promise<void> {
   async deleteVectorStore(vectorStoreRelationId: string): Promise<void> {
     const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ _id: vectorStoreRelationId, isDeleted: false });
     const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ _id: vectorStoreRelationId, isDeleted: false });
     if (vectorStoreDocument == null) {
     if (vectorStoreDocument == null) {
@@ -326,14 +362,56 @@ class OpenaiService implements IOpenaiService {
     }
     }
   }
   }
 
 
-  async createVectorStoreFile(vectorStoreRelation: VectorStoreDocument, pages: Array<HydratedDocument<PageDocument>>): Promise<void> {
-    // const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
+  private async createVectorStoreFileWithStreamForAttachment(
+      pageId: Types.ObjectId, vectorStoreRelationId: Types.ObjectId, vectorStoreFileRelationsMap: VectorStoreFileRelationsMap,
+  ): Promise<void> {
+
+    const Attachment = mongoose.model<HydratedDocument<IAttachmentDocument>, IAttachmentModel>('Attachment');
+    const attachmentsCursor = Attachment.find({ page: pageId }).cursor();
+    const batchStream = createBatchStream(BATCH_SIZE);
+
+    const uploadFileStreamForAttachment = new Writable({
+      objectMode: true,
+      write: async(attachments: HydratedDocument<IAttachmentDocument>[], _encoding, callback) => {
+        for await (const attachment of attachments) {
+          try {
+            if (!isVectorStoreCompatible(attachment.originalName, attachment.fileFormat)) {
+              continue;
+            }
+            const readStream = await this.crowi.fileUploadService.findDeliveryFile(attachment);
+            const uploadedFileForAttachment = await this.uploadFileForAttachment(attachment.originalName, readStream);
+            prepareVectorStoreFileRelations(
+              vectorStoreRelationId, pageId, uploadedFileForAttachment.id, vectorStoreFileRelationsMap, attachment._id,
+            );
+          }
+          catch (err) {
+            logger.error(err);
+          }
+        }
+        callback();
+      },
+      final: (callback) => {
+        logger.debug('Finished uploading attachments');
+        callback();
+      },
+    });
+
+    await pipeline(attachmentsCursor, batchStream, uploadFileStreamForAttachment);
+  }
+
+  private async createVectorStoreFile(
+      vectorStoreRelation: VectorStoreDocument, pages: Array<HydratedDocument<PageDocument>>, ignoreAttachments = false,
+  ): Promise<void> {
     const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map();
     const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map();
     const processUploadFile = async(page: HydratedDocument<PageDocument>) => {
     const processUploadFile = async(page: HydratedDocument<PageDocument>) => {
       if (page._id != null && page.revision != null) {
       if (page._id != null && page.revision != null) {
         if (isPopulated(page.revision) && page.revision.body.length > 0) {
         if (isPopulated(page.revision) && page.revision.body.length > 0) {
           const uploadedFile = await this.uploadFile(page.revision.body, page);
           const uploadedFile = await this.uploadFile(page.revision.body, page);
           prepareVectorStoreFileRelations(vectorStoreRelation._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
           prepareVectorStoreFileRelations(vectorStoreRelation._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
+
+          if (!ignoreAttachments) {
+            await this.createVectorStoreFileWithStreamForAttachment(page._id, vectorStoreRelation._id, vectorStoreFileRelationsMap);
+          }
           return;
           return;
         }
         }
 
 
@@ -341,6 +419,10 @@ class OpenaiService implements IOpenaiService {
         if (pagePopulatedToShowRevision.revision != null && pagePopulatedToShowRevision.revision.body.length > 0) {
         if (pagePopulatedToShowRevision.revision != null && pagePopulatedToShowRevision.revision.body.length > 0) {
           const uploadedFile = await this.uploadFile(pagePopulatedToShowRevision.revision.body, page);
           const uploadedFile = await this.uploadFile(pagePopulatedToShowRevision.revision.body, page);
           prepareVectorStoreFileRelations(vectorStoreRelation._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
           prepareVectorStoreFileRelations(vectorStoreRelation._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
+
+          if (!ignoreAttachments) {
+            await this.createVectorStoreFileWithStreamForAttachment(page._id, vectorStoreRelation._id, vectorStoreFileRelationsMap);
+          }
         }
         }
       }
       }
     };
     };
@@ -413,7 +495,57 @@ class OpenaiService implements IOpenaiService {
     await VectorStoreModel.deleteMany({ _id: { $nin: currentVectorStoreRelationIds }, isDeleted: true });
     await VectorStoreModel.deleteMany({ _id: { $nin: currentVectorStoreRelationIds }, isDeleted: true });
   }
   }
 
 
-  async deleteVectorStoreFile(vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId, apiCallInterval?: number): Promise<void> {
+  private async deleteVectorStoreFileForAttachment(vectorStoreFileRelation: VectorStoreFileRelation): Promise<void> {
+    if (vectorStoreFileRelation.attachment == null) {
+      return;
+    }
+
+    const deleteAllAttachmentVectorStoreFileRelations = async() => {
+      await VectorStoreFileRelationModel.deleteMany({ attachment: vectorStoreFileRelation.attachment });
+    };
+
+    try {
+      // Delete entities in VectorStoreFile
+      const fileId = vectorStoreFileRelation.fileIds[0];
+      const deleteFileResponse = await this.client.deleteFile(fileId);
+      logger.debug('Delete vector store file (attachment) ', deleteFileResponse);
+
+      // Delete related VectorStoreFileRelation document
+      const attachmentId = vectorStoreFileRelation.attachment;
+      if (attachmentId != null) {
+        await deleteAllAttachmentVectorStoreFileRelations();
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      await openaiApiErrorHandler(err, {
+        notFoundError: () => deleteAllAttachmentVectorStoreFileRelations(),
+      });
+    }
+  }
+
+  private async deleteVectorStoreFile(
+      vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId, ignoreAttachments = false, apiCallInterval?: number,
+  ): Promise<void> {
+
+    if (!ignoreAttachments) {
+      // Get all VectorStoreFIleDocument (attachments) associated with the page
+      const vectorStoreFileRelationsForAttachment = await VectorStoreFileRelationModel.find({
+        vectorStoreRelationId, page: pageId, attachment: { $exists: true },
+      });
+
+      if (vectorStoreFileRelationsForAttachment.length !== 0) {
+        for await (const vectorStoreFileRelation of vectorStoreFileRelationsForAttachment) {
+          try {
+            await this.deleteVectorStoreFileForAttachment(vectorStoreFileRelation);
+          }
+          catch (err) {
+            logger.error(err);
+          }
+        }
+      }
+    }
+
     // Delete vector store file and delete vector store file relation
     // Delete vector store file and delete vector store file relation
     const vectorStoreFileRelation = await VectorStoreFileRelationModel.findOne({ vectorStoreRelationId, page: pageId });
     const vectorStoreFileRelation = await VectorStoreFileRelationModel.findOne({ vectorStoreRelationId, page: pageId });
     if (vectorStoreFileRelation == null) {
     if (vectorStoreFileRelation == null) {
@@ -476,7 +608,7 @@ class OpenaiService implements IOpenaiService {
     // Delete obsolete VectorStoreFile
     // Delete obsolete VectorStoreFile
     for await (const vectorStoreFileRelation of obsoleteVectorStoreFileRelations) {
     for await (const vectorStoreFileRelation of obsoleteVectorStoreFileRelations) {
       try {
       try {
-        await this.deleteVectorStoreFile(vectorStoreFileRelation.vectorStoreRelationId, vectorStoreFileRelation.page, apiCallInterval);
+        await this.deleteVectorStoreFile(vectorStoreFileRelation.vectorStoreRelationId, vectorStoreFileRelation.page, false, apiCallInterval);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
@@ -484,10 +616,25 @@ class OpenaiService implements IOpenaiService {
     }
     }
   }
   }
 
 
-  async filterPagesByAccessScope(aiAssistant: AiAssistantDocument, pages: HydratedDocument<PageDocument>[]) {
-    const isPublicPage = (page :HydratedDocument<PageDocument>) => page.grant === PageGrant.GRANT_PUBLIC;
+  private async deleteVectorStoreFileOnDeleteAttachment(attachmentId: string) {
+    const vectorStoreFileRelation = await VectorStoreFileRelationModel.findOne({ attachment: attachmentId });
+    if (vectorStoreFileRelation == null) {
+      return;
+    }
+
+    try {
+      await this.deleteVectorStoreFileForAttachment(vectorStoreFileRelation);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }
+
+
+  private async filterPagesByAccessScope(aiAssistant: AiAssistantDocument, pages: HydratedDocument<PageDocument>[]) {
+    const isPublicPage = (page: HydratedDocument<PageDocument>) => page.grant === PageGrant.GRANT_PUBLIC;
 
 
-    const isUserGroupAccessible = (page :HydratedDocument<PageDocument>, ownerUserGroupIds: string[]) => {
+    const isUserGroupAccessible = (page: HydratedDocument<PageDocument>, ownerUserGroupIds: string[]) => {
       if (page.grant !== PageGrant.GRANT_USER_GROUP) return false;
       if (page.grant !== PageGrant.GRANT_USER_GROUP) return false;
       return page.grantedGroups.some(group => ownerUserGroupIds.includes(getIdStringForRef(group.item)));
       return page.grantedGroups.some(group => ownerUserGroupIds.includes(getIdStringForRef(group.item)));
     };
     };
@@ -576,8 +723,58 @@ class OpenaiService implements IOpenaiService {
       logger.debug('-----------------------------------------------------');
       logger.debug('-----------------------------------------------------');
 
 
       // Do not create a new VectorStoreFile if page is changed to a permission that AiAssistant does not have access to
       // Do not create a new VectorStoreFile if page is changed to a permission that AiAssistant does not have access to
-      await this.createVectorStoreFile(vectorStoreRelation as VectorStoreDocument, pagesToVectorize);
-      await this.deleteVectorStoreFile((vectorStoreRelation as VectorStoreDocument)._id, page._id);
+      await this.deleteVectorStoreFile(
+        (vectorStoreRelation as VectorStoreDocument)._id,
+        page._id,
+        true, // ignoreAttachments = true
+      );
+      await this.createVectorStoreFile(
+        vectorStoreRelation as VectorStoreDocument,
+        pagesToVectorize,
+        true, // ignoreAttachments = true
+      );
+    }
+  }
+
+  private async createVectorStoreFileOnUploadAttachment(
+      pageId: string, attachment: HydratedDocument<IAttachmentDocument>, file: Express.Multer.File,
+  ): Promise<void> {
+    if (!isVectorStoreCompatible(file.originalname, file.mimetype)) {
+      return;
+    }
+
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    const page = await Page.findById(pageId);
+    if (page == null) {
+      return;
+    }
+
+    const aiAssistants = await this.findAiAssistantByPagePath([page.path], { shouldPopulateVectorStore: true });
+    if (aiAssistants.length === 0) {
+      return;
+    }
+
+    const uploadedFile = await this.uploadFileForAttachment(file.originalname, undefined, file.path);
+    logger.debug('Uploaded file', uploadedFile);
+
+    for await (const aiAssistant of aiAssistants) {
+      const pagesToVectorize = await this.filterPagesByAccessScope(aiAssistant, [page]);
+      if (pagesToVectorize.length === 0) {
+        continue;
+      }
+
+      const vectorStoreRelation = aiAssistant.vectorStore;
+      if (vectorStoreRelation == null || !isPopulated(vectorStoreRelation)) {
+        continue;
+      }
+
+      await this.client.createVectorStoreFile(vectorStoreRelation.vectorStoreId, uploadedFile.id);
+
+      const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map();
+      prepareVectorStoreFileRelations(vectorStoreRelation._id as Types.ObjectId, page._id, uploadedFile.id, vectorStoreFileRelationsMap, attachment._id);
+      const vectorStoreFileRelations = Array.from(vectorStoreFileRelationsMap.values());
+
+      await VectorStoreFileRelationModel.upsertVectorStoreFileRelations(vectorStoreFileRelations);
     }
     }
   }
   }
 
 
@@ -594,7 +791,7 @@ class OpenaiService implements IOpenaiService {
       objectMode: true,
       objectMode: true,
       async transform(chunk: HydratedDocument<PageDocument>[], encoding, callback) {
       async transform(chunk: HydratedDocument<PageDocument>[], encoding, callback) {
         try {
         try {
-          logger.debug('Search results of page paths', chunk.map(page => page.path));
+          logger.debug('Target page path for VectorStoreFile generation: ', chunk.map(page => page.path));
           await createVectorStoreFile(vectorStoreRelation, chunk);
           await createVectorStoreFile(vectorStoreRelation, chunk);
           this.push(chunk);
           this.push(chunk);
           callback();
           callback();
@@ -905,7 +1102,7 @@ class OpenaiService implements IOpenaiService {
     return totalPageCount > limitLearnablePageCountPerAssistant;
     return totalPageCount > limitLearnablePageCountPerAssistant;
   }
   }
 
 
-  async findAiAssistantByPagePath(
+  private async findAiAssistantByPagePath(
       pagePaths: string[], options?: { shouldPopulateOwner?: boolean, shouldPopulateVectorStore?: boolean },
       pagePaths: string[], options?: { shouldPopulateOwner?: boolean, shouldPopulateVectorStore?: boolean },
   ): Promise<AiAssistantDocument[]> {
   ): Promise<AiAssistantDocument[]> {
 
 
@@ -935,15 +1132,16 @@ class OpenaiService implements IOpenaiService {
 }
 }
 
 
 let instance: OpenaiService;
 let instance: OpenaiService;
-export const getOpenaiService = (): IOpenaiService | undefined => {
-  if (instance != null) {
-    return instance;
-  }
-
+export const initializeOpenaiService = (crowi: Crowi): void => {
   const aiEnabled = configManager.getConfig('app:aiEnabled');
   const aiEnabled = configManager.getConfig('app:aiEnabled');
   const openaiServiceType = configManager.getConfig('openai:serviceType');
   const openaiServiceType = configManager.getConfig('openai:serviceType');
   if (aiEnabled && openaiServiceType != null && OpenaiServiceTypes.includes(openaiServiceType)) {
   if (aiEnabled && openaiServiceType != null && OpenaiServiceTypes.includes(openaiServiceType)) {
-    instance = new OpenaiService();
+    instance = new OpenaiService(crowi);
+  }
+};
+
+export const getOpenaiService = (): IOpenaiService | undefined => {
+  if (instance != null) {
     return instance;
     return instance;
   }
   }
 
 

+ 46 - 0
apps/app/src/features/openai/server/utils/is-vector-store-compatible.ts

@@ -0,0 +1,46 @@
+import path from 'path';
+
+// See: https://platform.openai.com/docs/assistants/tools/file-search#supported-files
+const supportedFormats = {
+  '.c': 'text/x-c',
+  '.cpp': 'text/x-c++',
+  '.cs': 'text/x-csharp',
+  '.css': 'text/css',
+  '.doc': 'application/msword',
+  '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+  '.go': 'text/x-golang',
+  '.html': 'text/html',
+  '.java': 'text/x-java',
+  '.js': 'text/javascript',
+  '.json': 'application/json',
+  '.md': 'text/markdown',
+  '.pdf': 'application/pdf',
+  '.php': 'text/x-php',
+  '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+  '.py': ['text/x-python', 'text/x-script.python'],
+  '.rb': 'text/x-ruby',
+  '.sh': 'application/x-sh',
+  '.tex': 'text/x-tex',
+  '.ts': 'application/typescript',
+  '.txt': 'text/plain',
+} as const;
+
+type SupportedExtension = keyof typeof supportedFormats;
+
+export const isVectorStoreCompatible = (originalName: string, mimeType: string): boolean => {
+  // Get extension
+  const extension = path.extname(originalName).toLowerCase();
+
+  // Check if the file extension is supported
+  if (!(extension in supportedFormats)) {
+    return false;
+  }
+
+  // Get Mime Type (At this point, file extension is confirmed to be supported, so type-safe access is possible)
+  const supportedMimeType = supportedFormats[extension as SupportedExtension];
+
+  // Check if the mimeType is supported
+  return Array.isArray(supportedMimeType)
+    ? supportedMimeType.includes(mimeType)
+    : supportedMimeType === mimeType;
+};

+ 12 - 6
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts

@@ -62,7 +62,6 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
   compressExtension = 'tar.gz';
   compressExtension = 'tar.gz';
 
 
   // temporal path of local fs to output page files before upload
   // temporal path of local fs to output page files before upload
-  // TODO: If necessary, change to a proper path in https://redmine.weseek.co.jp/issues/149512
   tmpOutputRootDir = '/tmp/page-bulk-export';
   tmpOutputRootDir = '/tmp/page-bulk-export';
 
 
   // Keep track of the stream executed for PageBulkExportJob to destroy it on job failure.
   // Keep track of the stream executed for PageBulkExportJob to destroy it on job failure.
@@ -104,10 +103,18 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * @param isHtmlPath whether the tmp output path is for html files
    * @param isHtmlPath whether the tmp output path is for html files
    */
    */
   getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath = false): string {
   getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath = false): string {
-    if (isHtmlPath) {
-      return path.join(this.tmpOutputRootDir, 'html', pageBulkExportJob._id.toString());
+    const isGrowiCloud = configManager.getConfig('app:growiCloudUri') != null;
+    const appId = configManager.getConfig('app:growiAppIdForCloud')?.toString();
+    const jobId = pageBulkExportJob._id.toString();
+
+    if (isGrowiCloud) {
+      if (appId == null) {
+        throw new Error('appId is required for bulk export on GROWI.cloud');
+      }
     }
     }
-    return path.join(this.tmpOutputRootDir, pageBulkExportJob._id.toString());
+
+    const basePath = isHtmlPath ? path.join(this.tmpOutputRootDir, 'html') : this.tmpOutputRootDir;
+    return path.join(basePath, appId ?? '', jobId);
   }
   }
 
 
   /**
   /**
@@ -242,13 +249,12 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
 
 
     const promises = [
     const promises = [
       PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),
       PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),
-      // delete /tmp/page-bulk-export/{jobId} dir
       fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), { recursive: true, force: true }),
       fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), { recursive: true, force: true }),
     ];
     ];
 
 
+    // clean up html files exported for PDF conversion
     if (pageBulkExportJob.format === PageBulkExportFormat.pdf) {
     if (pageBulkExportJob.format === PageBulkExportFormat.pdf) {
       promises.push(
       promises.push(
-        // delete /tmp/page-bulk-export/html/{jobId} dir
         fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob, true), { recursive: true, force: true }),
         fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob, true), { recursive: true, force: true }),
       );
       );
     }
     }

+ 10 - 1
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts

@@ -18,6 +18,12 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
     throw new Error('createdAt is not set');
     throw new Error('createdAt is not set');
   }
   }
 
 
+  const isGrowiCloud = configManager.getConfig('app:growiCloudUri') != null;
+  const appId = configManager.getConfig('app:growiAppIdForCloud');
+  if (isGrowiCloud && (appId == null)) {
+    throw new Error('appId is required for bulk export on GROWI.cloud');
+  }
+
   const exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
   const exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
   const bulkExportJobExpirationDate = new Date(jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000);
   const bulkExportJobExpirationDate = new Date(jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000);
   let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus = PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
   let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus = PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
@@ -41,7 +47,10 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
     }
     }
 
 
     const res = await pdfCtrlSyncJobStatus({
     const res = await pdfCtrlSyncJobStatus({
-      jobId: pageBulkExportJob._id.toString(), expirationDate: bulkExportJobExpirationDate.toISOString(), status: pdfConvertStatus,
+      appId,
+      jobId: pageBulkExportJob._id.toString(),
+      expirationDate: bulkExportJobExpirationDate.toISOString(),
+      status: pdfConvertStatus,
     }, { baseURL: configManager.getConfig('app:pageBulkExportPdfConverterUri') });
     }, { baseURL: configManager.getConfig('app:pageBulkExportPdfConverterUri') });
 
 
     if (res.data.status === PdfCtrlSyncJobStatus202Status.PDF_EXPORT_DONE) {
     if (res.data.status === PdfCtrlSyncJobStatus202Status.PDF_EXPORT_DONE) {

+ 12 - 6
apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts

@@ -68,7 +68,8 @@ module.exports = (crowi: Crowi): Router => {
    *   get:
    *   get:
    *     tags: [Questionnaire]
    *     tags: [Questionnaire]
    *     security:
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/orders
    *     summary: /questionnaire/orders
    *     description: Get questionnaire orders
    *     description: Get questionnaire orders
    *     responses:
    *     responses:
@@ -106,7 +107,8 @@ module.exports = (crowi: Crowi): Router => {
    *   get:
    *   get:
    *     tags: [Questionnaire]
    *     tags: [Questionnaire]
    *     security:
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/is-enabled
    *     summary: /questionnaire/is-enabled
    *     description: Get questionnaire is enabled
    *     description: Get questionnaire is enabled
    *     responses:
    *     responses:
@@ -132,7 +134,8 @@ module.exports = (crowi: Crowi): Router => {
    *   post:
    *   post:
    *     tags: [Questionnaire]
    *     tags: [Questionnaire]
    *     security:
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/proactive/answer
    *     summary: /questionnaire/proactive/answer
    *     description: Post proactive questionnaire answer
    *     description: Post proactive questionnaire answer
    *     requestBody:
    *     requestBody:
@@ -205,7 +208,8 @@ module.exports = (crowi: Crowi): Router => {
    *   put:
    *   put:
    *     tags: [Questionnaire]
    *     tags: [Questionnaire]
    *     security:
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/answer
    *     summary: /questionnaire/answer
    *     description: Post questionnaire answer
    *     description: Post questionnaire answer
    *     requestBody:
    *     requestBody:
@@ -284,7 +288,8 @@ module.exports = (crowi: Crowi): Router => {
    *   put:
    *   put:
    *     tags: [Questionnaire]
    *     tags: [Questionnaire]
    *     security:
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/skip
    *     summary: /questionnaire/skip
    *     description: Skip questionnaire
    *     description: Skip questionnaire
    *     requestBody:
    *     requestBody:
@@ -332,7 +337,8 @@ module.exports = (crowi: Crowi): Router => {
    *   put:
    *   put:
    *     tags: [Questionnaire]
    *     tags: [Questionnaire]
    *     security:
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/deny
    *     summary: /questionnaire/deny
    *     description: Deny questionnaire
    *     description: Deny questionnaire
    *     requestBody:
    *     requestBody:

+ 47 - 0
apps/app/src/migrations/20250522105040-delete-old-index-for-vector-store-file-relation.js

@@ -0,0 +1,47 @@
+import mongoose from 'mongoose';
+
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:vector-store-file-relation-index-migration');
+
+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 old index
+    await dropIndexIfExists(db, 'vectorstorefilerelations', 'vectorStoreRelationId_1_page_1');
+
+    // Create index
+    const collection = mongoose.connection.collection('vectorstorefilerelations');
+    await collection.createIndex({ vectorStoreRelationId: 1, page: 1, attachment: 1 }, { unique: true });
+
+  },
+  async down(db) {
+    logger.info('Rollback migration');
+
+    await mongoose.connect(getMongoUri(), mongoOptions);
+
+    // Drop new index
+    await dropIndexIfExists(db, 'vectorstorefilerelations', 'vectorStoreRelationId_1_page_1_attachment_1');
+
+    // Recreate old index
+    const collection = mongoose.connection.collection('vectorstorefilerelations');
+    await collection.createIndex({ vectorStoreRelationId: 1, page: 1 }, { unique: true });
+  },
+};

+ 10 - 1
apps/app/src/server/crowi/index.js

@@ -11,6 +11,7 @@ import next from 'next';
 import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
 import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
 import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import { startCronIfEnabled as startOpenaiCronIfEnabled } from '~/features/openai/server/services/cron';
 import { startCronIfEnabled as startOpenaiCronIfEnabled } from '~/features/openai/server/services/cron';
+import { initializeOpenaiService } from '~/features/openai/server/services/openai';
 import { checkPageBulkExportJobInProgressCronService } from '~/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron';
 import { checkPageBulkExportJobInProgressCronService } from '~/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron';
 import instanciatePageBulkExportJobCleanUpCronService, {
 import instanciatePageBulkExportJobCleanUpCronService, {
   pageBulkExportJobCleanUpCronService,
   pageBulkExportJobCleanUpCronService,
@@ -177,7 +178,6 @@ Crowi.prototype.init = async function() {
   this.models = await setupModelsDependentOnCrowi(this);
   this.models = await setupModelsDependentOnCrowi(this);
   await this.setupConfigManager();
   await this.setupConfigManager();
   await this.setupSessionConfig();
   await this.setupSessionConfig();
-  this.setupCron();
 
 
   // setup messaging services
   // setup messaging services
   await this.setupS2sMessagingService();
   await this.setupS2sMessagingService();
@@ -223,8 +223,13 @@ Crowi.prototype.init = async function() {
     // depends on passport service
     // depends on passport service
     this.setupExternalAccountService(),
     this.setupExternalAccountService(),
     this.setupExternalUserGroupSyncService(),
     this.setupExternalUserGroupSyncService(),
+
+    // depends on AttachmentService
+    this.setupOpenaiService(),
   ]);
   ]);
 
 
+  this.setupCron();
+
   await normalizeData();
   await normalizeData();
 };
 };
 
 
@@ -809,4 +814,8 @@ Crowi.prototype.setupExternalUserGroupSyncService = function() {
   this.keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(this.s2sMessagingService, this.socketIoService);
   this.keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(this.s2sMessagingService, this.socketIoService);
 };
 };
 
 
+Crowi.prototype.setupOpenaiService = function() {
+  initializeOpenaiService(this);
+};
+
 export default Crowi;
 export default Crowi;

+ 59 - 0
apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts

@@ -132,4 +132,63 @@ describe('access-token-parser middleware', () => {
     expect(nextMock).toHaveBeenCalled();
     expect(nextMock).toHaveBeenCalled();
   });
   });
 
 
+  it('should set req.user with a valid Bearer token in Authorization header', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+      headers: {
+        authorization: undefined,
+      },
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user with an access token
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+      apiToken: faker.internet.password(),
+    });
+
+    // act
+    reqMock.headers.authorization = `Bearer ${targetUser.apiToken}`;
+    await accessTokenParser(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+  it('should ignore non-Bearer Authorization header', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+      headers: {
+        authorization: undefined,
+      },
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // Generate random string that is guaranteed to be invalid for Basic auth (1024 chars)
+    const randomString = faker.string.alpha(1024);
+
+    // act
+    reqMock.headers.authorization = `Basic ${randomString}`; // Basic auth header with random string
+    await accessTokenParser(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeUndefined();
+    expect(serializeUserSecurely).not.toHaveBeenCalled();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
 });
 });

+ 18 - 2
apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts

@@ -11,9 +11,25 @@ import type { AccessTokenParserReq } from './interfaces';
 const logger = loggerFactory('growi:middleware:access-token-parser');
 const logger = loggerFactory('growi:middleware:access-token-parser');
 
 
 
 
+const extractBearerToken = (authHeader: string | undefined): string | null => {
+  if (authHeader == null) {
+    return null;
+  }
+
+  if (!authHeader.startsWith('Bearer ')) {
+    return null;
+  }
+
+  return authHeader.substring(7); // Remove 'Bearer ' prefix
+};
+
 export const accessTokenParser = async(req: AccessTokenParserReq, res: Response, next: NextFunction): Promise<void> => {
 export const accessTokenParser = async(req: AccessTokenParserReq, res: Response, next: NextFunction): Promise<void> => {
-  // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
-  const accessToken = req.query.access_token ?? req.body.access_token;
+  // Extract token from Authorization header first
+  const bearerToken = extractBearerToken(req.headers.authorization);
+
+  // Try all possible token sources in order of priority
+  const accessToken = bearerToken ?? req.query.access_token ?? req.body.access_token;
+
   if (accessToken == null || typeof accessToken !== 'string') {
   if (accessToken == null || typeof accessToken !== 'string') {
     return next();
     return next();
   }
   }

+ 2 - 1
apps/app/src/server/routes/apiv3/activity.ts

@@ -185,7 +185,8 @@ module.exports = (crowi: Crowi): Router => {
    *     summary: /activity
    *     summary: /activity
    *     tags: [Activity]
    *     tags: [Activity]
    *     security:
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     parameters:
    *     parameters:
    *       - name: limit
    *       - name: limit
    *         in: query
    *         in: query

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

@@ -420,7 +420,8 @@ module.exports = (crowi) => {
    *        tags: [AppSettings]
    *        tags: [AppSettings]
    *        operationId: getAppSettings
    *        operationId: getAppSettings
    *        security:
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: /app-settings
    *        summary: /app-settings
    *        description: get app setting params
    *        description: get app setting params
    *        responses:
    *        responses:
@@ -1065,7 +1066,8 @@ module.exports = (crowi) => {
    *        tags: [AppSettings]
    *        tags: [AppSettings]
    *        operationId: updateAppSettingV5SchemaMigration
    *        operationId: updateAppSettingV5SchemaMigration
    *        security:
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: AccessToken supported.
    *        summary: AccessToken supported.
    *        description: Update V5SchemaMigration
    *        description: Update V5SchemaMigration
    *        responses:
    *        responses:
@@ -1110,7 +1112,8 @@ module.exports = (crowi) => {
    *        tags: [AppSettings]
    *        tags: [AppSettings]
    *        operationId: updateAppSettingMaintenanceMode
    *        operationId: updateAppSettingMaintenanceMode
    *        security:
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: AccessToken supported.
    *        summary: AccessToken supported.
    *        description: Update MaintenanceMode
    *        description: Update MaintenanceMode
    *        requestBody:
    *        requestBody:

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

@@ -339,8 +339,9 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            $ref: '#/components/responses/500'
    *            $ref: '#/components/responses/500'
    */
    */
-  router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, uploads.single('file'), autoReap,
+  router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, uploads.single('file'),
     validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
     validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
+    // Removed autoReap middleware to use file data in asynchronous processes. Instead, implemented file deletion after asynchronous processes complete
     async(req, res) => {
     async(req, res) => {
 
 
       const pageId = req.body.page_id;
       const pageId = req.body.page_id;
@@ -360,7 +361,7 @@ module.exports = (crowi) => {
           return res.apiv3Err(`Forbidden to access to the page '${page.id}'`);
           return res.apiv3Err(`Forbidden to access to the page '${page.id}'`);
         }
         }
 
 
-        const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE);
+        const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE, () => autoReap(req, res, () => {}));
 
 
         const result = {
         const result = {
           page: serializePageSecurely(page),
           page: serializePageSecurely(page),

+ 12 - 6
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -130,7 +130,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        tags: [BookmarkFolders]
    *        operationId: createBookmarkFolder
    *        operationId: createBookmarkFolder
    *        security:
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Create bookmark folder
    *        summary: Create bookmark folder
    *        description: Create a new bookmark folder
    *        description: Create a new bookmark folder
    *        requestBody:
    *        requestBody:
@@ -185,7 +186,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        tags: [BookmarkFolders]
    *        operationId: listBookmarkFolders
    *        operationId: listBookmarkFolders
    *        security:
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: List bookmark folders of a user
    *        summary: List bookmark folders of a user
    *        description: List bookmark folders of a user
    *        description: List bookmark folders of a user
    *        parameters:
    *        parameters:
@@ -274,7 +276,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        tags: [BookmarkFolders]
    *        operationId: deleteBookmarkFolder
    *        operationId: deleteBookmarkFolder
    *        security:
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Delete bookmark folder
    *        summary: Delete bookmark folder
    *        description: Delete a bookmark folder and its children
    *        description: Delete a bookmark folder and its children
    *        parameters:
    *        parameters:
@@ -317,7 +320,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        tags: [BookmarkFolders]
    *        operationId: updateBookmarkFolder
    *        operationId: updateBookmarkFolder
    *        security:
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Update bookmark folder
    *        summary: Update bookmark folder
    *        description: Update a bookmark folder
    *        description: Update a bookmark folder
    *        requestBody:
    *        requestBody:
@@ -374,7 +378,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        tags: [BookmarkFolders]
    *        operationId: addBookmarkToFolder
    *        operationId: addBookmarkToFolder
    *        security:
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Update bookmark folder
    *        summary: Update bookmark folder
    *        description: Update a bookmark folder
    *        description: Update a bookmark folder
    *        requestBody:
    *        requestBody:
@@ -424,7 +429,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        tags: [BookmarkFolders]
    *        operationId: updateBookmarkInFolder
    *        operationId: updateBookmarkInFolder
    *        security:
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Update bookmark in folder
    *        summary: Update bookmark in folder
    *        description: Update a bookmark in a folder
    *        description: Update a bookmark in a folder
    *        requestBody:
    *        requestBody:

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

@@ -441,7 +441,8 @@ module.exports = (crowi: Crowi): Router => {
    *      summary: /g2g-transfer/generate-key
    *      summary: /g2g-transfer/generate-key
    *      tags: [GROWI to GROWI Transfer]
    *      tags: [GROWI to GROWI Transfer]
    *      security:
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      requestBody:
    *      requestBody:
    *        required: true
    *        required: true
    *        content:
    *        content:
@@ -498,7 +499,8 @@ module.exports = (crowi: Crowi): Router => {
    *      summary: /g2g-transfer/transfer
    *      summary: /g2g-transfer/transfer
    *      tags: [GROWI to GROWI Transfer]
    *      tags: [GROWI to GROWI Transfer]
    *      security:
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      requestBody:
    *      requestBody:
    *        required: true
    *        required: true
    *        content:
    *        content:

+ 10 - 5
apps/app/src/server/routes/apiv3/import.js

@@ -169,7 +169,8 @@ export default function route(crowi) {
    *    get:
    *    get:
    *      tags: [Import]
    *      tags: [Import]
    *      security:
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getImportSettingsParams
    *      operationId: getImportSettingsParams
    *      summary: /import
    *      summary: /import
    *      description: Get import settings params
    *      description: Get import settings params
@@ -221,7 +222,8 @@ export default function route(crowi) {
    *    get:
    *    get:
    *      tags: [Import]
    *      tags: [Import]
    *      security:
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getImportStatus
    *      operationId: getImportStatus
    *      summary: /import/status
    *      summary: /import/status
    *      description: Get properties of stored zip files for import
    *      description: Get properties of stored zip files for import
@@ -252,7 +254,8 @@ export default function route(crowi) {
    *    post:
    *    post:
    *      tags: [Import]
    *      tags: [Import]
    *      security:
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: executeImport
    *      operationId: executeImport
    *      summary: /import
    *      summary: /import
    *      description: import a collection from a zipped json
    *      description: import a collection from a zipped json
@@ -384,7 +387,8 @@ export default function route(crowi) {
    *    post:
    *    post:
    *      tags: [Import]
    *      tags: [Import]
    *      security:
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: uploadImport
    *      operationId: uploadImport
    *      summary: /import/upload
    *      summary: /import/upload
    *      description: upload a zip file
    *      description: upload a zip file
@@ -440,7 +444,8 @@ export default function route(crowi) {
    *    delete:
    *    delete:
    *      tags: [Import]
    *      tags: [Import]
    *      security:
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: deleteImportAll
    *      operationId: deleteImportAll
    *      summary: /import/all
    *      summary: /import/all
    *      description: Delete all zip files
    *      description: Delete all zip files

+ 8 - 4
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -103,7 +103,8 @@ module.exports = (crowi) => {
    *    get:
    *    get:
    *      tags: [NotificationSetting]
    *      tags: [NotificationSetting]
    *      security:
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getInAppNotificationList
    *      operationId: getInAppNotificationList
    *      summary: /in-app-notification/list
    *      summary: /in-app-notification/list
    *      description: Get the list of in-app notifications
    *      description: Get the list of in-app notifications
@@ -193,7 +194,8 @@ module.exports = (crowi) => {
    *    get:
    *    get:
    *      tags: [NotificationSetting]
    *      tags: [NotificationSetting]
    *      security:
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getInAppNotificationStatus
    *      operationId: getInAppNotificationStatus
    *      summary: /in-app-notification/status
    *      summary: /in-app-notification/status
    *      description: Get the status of in-app notifications
    *      description: Get the status of in-app notifications
@@ -229,7 +231,8 @@ module.exports = (crowi) => {
    *    post:
    *    post:
    *      tags: [NotificationSetting]
    *      tags: [NotificationSetting]
    *      security:
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: openInAppNotification
    *      operationId: openInAppNotification
    *      summary: /in-app-notification/open
    *      summary: /in-app-notification/open
    *      description: Open the in-app notification
    *      description: Open the in-app notification
@@ -275,7 +278,8 @@ module.exports = (crowi) => {
    *    put:
    *    put:
    *      tags: [NotificationSetting]
    *      tags: [NotificationSetting]
    *      security:
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: openAllInAppNotification
    *      operationId: openAllInAppNotification
    *      summary: /in-app-notification/all-statuses-open
    *      summary: /in-app-notification/all-statuses-open
    *      description: Open all in-app notifications
    *      description: Open all in-app notifications

+ 9 - 5
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -72,7 +72,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *   get:
    *     tags: [PageListing]
    *     tags: [PageListing]
    *     security:
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/root
    *     summary: /page-listing/root
    *     description: Get the root page
    *     description: Get the root page
    *     responses:
    *     responses:
@@ -107,7 +108,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *   get:
    *     tags: [PageListing]
    *     tags: [PageListing]
    *     security:
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/ancestors-children
    *     summary: /page-listing/ancestors-children
    *     description: Get the ancestors and children of a page
    *     description: Get the ancestors and children of a page
    *     parameters:
    *     parameters:
@@ -172,7 +174,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *   get:
    *     tags: [PageListing]
    *     tags: [PageListing]
    *     security:
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/children
    *     summary: /page-listing/children
    *     description: Get the children of a page
    *     description: Get the children of a page
    *     parameters:
    *     parameters:
@@ -211,7 +214,7 @@ const routerFactory = (crowi: Crowi): Router => {
 
 
     try {
     try {
       const pages = await pageService.findChildrenByParentPathOrIdAndViewer(
       const pages = await pageService.findChildrenByParentPathOrIdAndViewer(
-        (id || path)as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
+        (id || path) as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
       );
       );
       return res.apiv3({ children: pages });
       return res.apiv3({ children: pages });
     }
     }
@@ -228,7 +231,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *   get:
    *     tags: [PageListing]
    *     tags: [PageListing]
    *     security:
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/info
    *     summary: /page-listing/info
    *     description: Get the information of a page
    *     description: Get the information of a page
    *     parameters:
    *     parameters:

+ 2 - 5
apps/app/src/server/routes/apiv3/page/index.ts

@@ -318,17 +318,14 @@ module.exports = (crowi) => {
    *                    $ref: '#/components/schemas/Page/properties/path'
    *                    $ref: '#/components/schemas/Page/properties/path'
    *                  grant:
    *                  grant:
    *                    $ref: '#/components/schemas/Page/properties/grant'
    *                    $ref: '#/components/schemas/Page/properties/grant'
-   *                  grantUserGroupId:
+   *                  grantUserGroupIds:
    *                    type: string
    *                    type: string
    *                    description: UserGroup ID
    *                    description: UserGroup ID
    *                    example: 5ae5fccfc5577b0004dbd8ab
    *                    example: 5ae5fccfc5577b0004dbd8ab
    *                  pageTags:
    *                  pageTags:
    *                    type: array
    *                    type: array
    *                    items:
    *                    items:
-   *                      $ref: '#/components/schemas/Tag'
-   *                  shouldGeneratePath:
-   *                    type: boolean
-   *                    description: Determine whether a new path should be generated
+   *                      type: string
    *                required:
    *                required:
    *                  - body
    *                  - body
    *                  - path
    *                  - path

+ 0 - 5
apps/app/src/server/routes/apiv3/pages/index.js

@@ -86,7 +86,6 @@ module.exports = (crowi) => {
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('updateMetadata').if(value => value != null).isBoolean().withMessage('updateMetadata must be boolean'),
       body('updateMetadata').if(value => value != null).isBoolean().withMessage('updateMetadata must be boolean'),
-      body('isMoveMode').if(value => value != null).isBoolean().withMessage('isMoveMode must be boolean'),
     ],
     ],
     resumeRenamePage: [
     resumeRenamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageId').isMongoId().withMessage('pageId is required'),
@@ -256,9 +255,6 @@ module.exports = (crowi) => {
    *                  isRecursively:
    *                  isRecursively:
    *                    type: boolean
    *                    type: boolean
    *                    description: whether rename page with descendants
    *                    description: whether rename page with descendants
-   *                  isMoveMode:
-   *                    type: boolean
-   *                    description: whether rename page with moving
    *                required:
    *                required:
    *                  - pageId
    *                  - pageId
    *                  - revisionId
    *                  - revisionId
@@ -285,7 +281,6 @@ module.exports = (crowi) => {
       isRecursively: req.body.isRecursively,
       isRecursively: req.body.isRecursively,
       createRedirectPage: req.body.isRenameRedirect,
       createRedirectPage: req.body.isRenameRedirect,
       updateMetadata: req.body.updateMetadata,
       updateMetadata: req.body.updateMetadata,
-      isMoveMode: req.body.isMoveMode,
     };
     };
 
 
     const activityParameters = {
     const activityParameters = {

+ 19 - 9
apps/app/src/server/routes/apiv3/slack-integration.js

@@ -5,25 +5,25 @@ import { InvalidGrowiCommandError } from '@growi/slack/dist/models';
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
 import { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
 import { generateRespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
 import { generateRespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
+import { isValidResponseUrl } from '@growi/slack/dist/utils/response-url-validator';
 import { parseSlashCommand } from '@growi/slack/dist/utils/slash-command-parser';
 import { parseSlashCommand } from '@growi/slack/dist/utils/slash-command-parser';
+import express from 'express';
+import { body } from 'express-validator';
 import createError from 'http-errors';
 import createError from 'http-errors';
+import mongoose from 'mongoose';
 
 
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import { growiInfoService } from '~/server/service/growi-info';
 import { growiInfoService } from '~/server/service/growi-info';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
-const express = require('express');
-const { body } = require('express-validator');
-const mongoose = require('mongoose');
+import { handleError } from '../../service/slack-command-handler/error-handler';
+import { checkPermission } from '../../util/slack-integration';
 
 
 
 
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
-const { handleError } = require('../../service/slack-command-handler/error-handler');
-const { checkPermission } = require('../../util/slack-integration');
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
@@ -221,7 +221,7 @@ module.exports = (crowi) => {
       throw SlackCommandHandlerError('App site url must exist.');
       throw SlackCommandHandlerError('App site url must exist.');
     }
     }
 
 
-    return generateRespondUtil(responseUrl, proxyUri, appSiteUrl);
+    return generateRespondUtil({ responseUrl, proxyUri, appSiteUrl });
   }
   }
 
 
   function getGrowiCommand(body) {
   function getGrowiCommand(body) {
@@ -286,6 +286,7 @@ module.exports = (crowi) => {
       await slackIntegrationService.handleCommandRequest(growiCommand, client, body, respondUtil);
       await slackIntegrationService.handleCommandRequest(growiCommand, client, body, respondUtil);
     }
     }
     catch (err) {
     catch (err) {
+      logger.error(err.message);
       return handleError(err, responseUrl);
       return handleError(err, responseUrl);
     }
     }
 
 
@@ -294,10 +295,18 @@ module.exports = (crowi) => {
   // TODO: this method will be a middleware when typescriptize in the future
   // TODO: this method will be a middleware when typescriptize in the future
   function getResponseUrl(req) {
   function getResponseUrl(req) {
     const { body } = req;
     const { body } = req;
-    const responseUrl = body?.growiCommand?.responseUrl;
+    const responseUrl = body?.growiCommand?.responseUrl ?? body.response_url;
+
     if (responseUrl == null) {
     if (responseUrl == null) {
-      return body.response_url; // may be null
+      return null;
+    }
+
+    const proxyUri = slackIntegrationService.proxyUriForCurrentType;
+
+    if (!isValidResponseUrl(responseUrl, proxyUri)) {
+      throw createError(400, 'Invalid response_url');
     }
     }
+
     return responseUrl;
     return responseUrl;
   }
   }
 
 
@@ -421,6 +430,7 @@ module.exports = (crowi) => {
       client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
       client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
     }
     }
     catch (err) {
     catch (err) {
+      logger.error(err.message);
       return handleError(err, responseUrl);
       return handleError(err, responseUrl);
     }
     }
 
 

+ 60 - 8
apps/app/src/server/service/attachment.js

@@ -10,11 +10,23 @@ const mongoose = require('mongoose');
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:service:AttachmentService');
 const logger = loggerFactory('growi:service:AttachmentService');
 
 
+const createReadStream = (filePath) => {
+  return fs.createReadStream(filePath, {
+    flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true,
+  });
+};
+
 /**
 /**
  * the service class for Attachment and file-uploader
  * the service class for Attachment and file-uploader
  */
  */
 class AttachmentService {
 class AttachmentService {
 
 
+  /** @type {Array<(pageId: string, attachment: Attachment, file: Express.Multer.File) => Promise<void>>} */
+  attachHandlers = [];
+
+  /** @type {Array<(attachmentId: string) => Promise<void>>} */
+  detachHandlers = [];
+
   /** @type {import('~/server/crowi').default} Crowi instance */
   /** @type {import('~/server/crowi').default} Crowi instance */
   crowi;
   crowi;
 
 
@@ -23,7 +35,7 @@ class AttachmentService {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  async createAttachment(file, user, pageId = null, attachmentType) {
+  async createAttachment(file, user, pageId = null, attachmentType, disposeTmpFileCallback) {
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
 
 
     // check limit
     // check limit
@@ -32,22 +44,36 @@ class AttachmentService {
       throw new Error(res.errorMessage);
       throw new Error(res.errorMessage);
     }
     }
 
 
-    const fileStream = fs.createReadStream(file.path, {
-      flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true,
-    });
-
     // create an Attachment document and upload file
     // create an Attachment document and upload file
     let attachment;
     let attachment;
+    let readStreamForCreateAttachmentDocument;
     try {
     try {
+      readStreamForCreateAttachmentDocument = createReadStream(file.path);
       attachment = Attachment.createWithoutSave(pageId, user, file.originalname, file.mimetype, file.size, attachmentType);
       attachment = Attachment.createWithoutSave(pageId, user, file.originalname, file.mimetype, file.size, attachmentType);
-      await fileUploadService.uploadAttachment(fileStream, attachment);
+      await fileUploadService.uploadAttachment(readStreamForCreateAttachmentDocument, attachment);
       await attachment.save();
       await attachment.save();
+
+      const attachHandlerPromises = this.attachHandlers.map((handler) => {
+        return handler(pageId, attachment, file);
+      });
+
+      // Do not await, run in background
+      Promise.all(attachHandlerPromises)
+        .catch((err) => {
+          logger.error('Error while executing attach handler', err);
+        })
+        .finally(() => {
+          disposeTmpFileCallback?.(file);
+        });
     }
     }
     catch (err) {
     catch (err) {
-      // delete temporary file
-      fs.unlink(file.path, (err) => { if (err) { logger.error('Error while deleting tmp file.') } });
+      logger.error('Error while creating attachment', err);
+      disposeTmpFileCallback?.(file);
       throw err;
       throw err;
     }
     }
+    finally {
+      readStreamForCreateAttachmentDocument.destroy();
+    }
 
 
     return attachment;
     return attachment;
   }
   }
@@ -78,6 +104,16 @@ class AttachmentService {
     await fileUploadService.deleteFile(attachment);
     await fileUploadService.deleteFile(attachment);
     await attachment.remove();
     await attachment.remove();
 
 
+    const detachedHandlerPromises = this.detachHandlers.map((handler) => {
+      return handler(attachment._id);
+    });
+
+    // Do not await, run in background
+    Promise.all(detachedHandlerPromises)
+      .catch((err) => {
+        logger.error('Error while executing detached handler', err);
+      });
+
     return;
     return;
   }
   }
 
 
@@ -88,6 +124,22 @@ class AttachmentService {
     return count >= 1;
     return count >= 1;
   }
   }
 
 
+  /**
+   * Register a handler that will be called after attachment creation
+   * @param {(pageId: string, attachment: Attachment, file: Express.Multer.File) => Promise<void>} handler
+   */
+  addAttachHandler(handler) {
+    this.attachHandlers.push(handler);
+  }
+
+  /**
+   * Register a handler that will be called before attachment deletion
+   * @param {(attachmentId: string) => Promise<void>} handler
+   */
+  addDetachHandler(handler) {
+    this.detachHandlers.push(handler);
+  }
+
 }
 }
 
 
 module.exports = AttachmentService;
 module.exports = AttachmentService;

+ 1 - 11
apps/app/src/server/service/page/index.ts

@@ -82,7 +82,7 @@ export * from './page-service';
 const logger = loggerFactory('growi:services:page');
 const logger = loggerFactory('growi:services:page');
 const {
 const {
   isTrashPage, isTopPage, omitDuplicateAreaPageFromPages, getUsernameByPath,
   isTrashPage, isTopPage, omitDuplicateAreaPageFromPages, getUsernameByPath,
-  canMoveByPath, isUsersTopPage, isMovablePage, isUsersHomepage, hasSlash, generateChildrenRegExp,
+  isUsersTopPage, isMovablePage, isUsersHomepage, hasSlash, generateChildrenRegExp,
 } = pagePathUtils;
 } = pagePathUtils;
 
 
 const { addTrailingSlash } = pathUtils;
 const { addTrailingSlash } = pathUtils;
@@ -551,16 +551,6 @@ class PageService implements IPageService {
       return this.renamePageV4(page, newPagePath, user, options);
       return this.renamePageV4(page, newPagePath, user, options);
     }
     }
 
 
-    if (options.isMoveMode) {
-      const fromPath = page.path;
-      const toPath = newPagePath;
-      const canMove = canMoveByPath(fromPath, toPath) && await Page.exists({ path: newPagePath });
-
-      if (!canMove) {
-        throw Error('Cannot move to this path.');
-      }
-    }
-
     const canOperate = await this.crowi.pageOperationService.canOperate(true, page.path, newPagePath);
     const canOperate = await this.crowi.pageOperationService.canOperate(true, page.path, newPagePath);
     if (!canOperate) {
     if (!canOperate) {
       throw Error(`Cannot operate rename to path "${newPagePath}" right now.`);
       throw Error(`Cannot operate rename to path "${newPagePath}" right now.`);

+ 1 - 2
apps/app/src/stores/page-listing.tsx

@@ -7,7 +7,6 @@ import type {
 import useSWR, {
 import useSWR, {
   mutate, type SWRConfiguration, type SWRResponse, type Arguments,
   mutate, type SWRConfiguration, type SWRResponse, type Arguments,
 } from 'swr';
 } from 'swr';
-import { cache } from 'swr/_internal';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 import type { SWRInfiniteResponse } from 'swr/infinite';
 import type { SWRInfiniteResponse } from 'swr/infinite';
 import useSWRInfinite, { unstable_serialize } from 'swr/infinite'; // eslint-disable-line camelcase
 import useSWRInfinite, { unstable_serialize } from 'swr/infinite'; // eslint-disable-line camelcase
@@ -16,7 +15,7 @@ import type { IPagingResult } from '~/interfaces/paging-result';
 
 
 import { apiv3Get } from '../client/util/apiv3-client';
 import { apiv3Get } from '../client/util/apiv3-client';
 import type {
 import type {
-  AncestorsChildrenResult, ChildrenResult, V5MigrationStatus, RootPageResult,
+  ChildrenResult, V5MigrationStatus, RootPageResult,
 } from '../interfaces/page-listing-results';
 } from '../interfaces/page-listing-results';
 
 
 
 

+ 10 - 6
apps/pdf-converter/package.json

@@ -12,10 +12,11 @@
     "start:prod:ci": "pnpm start:prod --ci",
     "start:prod:ci": "pnpm start:prod --ci",
     "start:prod": "node dist/index.js",
     "start:prod": "node dist/index.js",
     "lint": "pnpm eslint **/*.{js,ts}",
     "lint": "pnpm eslint **/*.{js,ts}",
-    "gen:swagger-spec": "SWAGGER_GENERATION=true node --import @swc-node/register/esm-register src/bin/index.ts generate-swagger --output ./specs",
+    "gen:swagger-spec": "SKIP_PUPPETEER_INIT=true node --import @swc-node/register/esm-register src/bin/index.ts generate-swagger --output ./specs",
     "build": "pnpm tsc -p tsconfig.build.json",
     "build": "pnpm tsc -p tsconfig.build.json",
     "version:prerelease": "pnpm version prerelease --preid=RC",
     "version:prerelease": "pnpm version prerelease --preid=RC",
-    "version:prepatch": "pnpm version prepatch --preid=RC"
+    "version:prepatch": "pnpm version prepatch --preid=RC",
+    "test": "SKIP_PUPPETEER_INIT=true vitest run"
   },
   },
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.12.1",
     "@godaddy/terminus": "^4.12.1",
@@ -24,17 +25,17 @@
     "@tsed/common": "=8.5.0",
     "@tsed/common": "=8.5.0",
     "@tsed/components-scan": "=8.5.0",
     "@tsed/components-scan": "=8.5.0",
     "@tsed/core": "=8.5.0",
     "@tsed/core": "=8.5.0",
-    "@tsed/engines": "=8.5.0",
     "@tsed/di": "=8.5.0",
     "@tsed/di": "=8.5.0",
+    "@tsed/engines": "=8.5.0",
     "@tsed/exceptions": "=8.5.0",
     "@tsed/exceptions": "=8.5.0",
     "@tsed/json-mapper": "=8.5.0",
     "@tsed/json-mapper": "=8.5.0",
     "@tsed/logger": ">=7.0.1",
     "@tsed/logger": ">=7.0.1",
     "@tsed/platform-express": "=8.5.0",
     "@tsed/platform-express": "=8.5.0",
+    "@tsed/platform-http": "=8.5.0",
     "@tsed/platform-views": "=8.5.0",
     "@tsed/platform-views": "=8.5.0",
     "@tsed/schema": "=8.5.0",
     "@tsed/schema": "=8.5.0",
     "@tsed/swagger": "=8.5.0",
     "@tsed/swagger": "=8.5.0",
     "@tsed/terminus": "=8.5.0",
     "@tsed/terminus": "=8.5.0",
-    "@tsed/platform-http": "=8.5.0",
     "axios": "^0.24.0",
     "axios": "^0.24.0",
     "express": "^4.19.2",
     "express": "^4.19.2",
     "puppeteer": "^23.1.1",
     "puppeteer": "^23.1.1",
@@ -42,11 +43,14 @@
     "tslib": "^2.8.0"
     "tslib": "^2.8.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@swc-node/register": "^1.10.9",
+    "@swc/core": "^1.9.2",
     "@types/connect": "^3.4.38",
     "@types/connect": "^3.4.38",
     "@types/express": "^4.17.21",
     "@types/express": "^4.17.21",
     "@types/multer": "^1.4.12",
     "@types/multer": "^1.4.12",
     "@types/node": "^22.5.4",
     "@types/node": "^22.5.4",
-    "@swc-node/register": "^1.10.9",
-    "@swc/core": "^1.9.2"
+    "@types/supertest": "^6.0.3",
+    "supertest": "^7.1.1",
+    "unplugin-swc": "^1.5.3"
   }
   }
 }
 }

+ 55 - 0
apps/pdf-converter/src/controllers/pdf.spec.ts

@@ -0,0 +1,55 @@
+import { PlatformTest } from '@tsed/platform-http/testing';
+import SuperTest from 'supertest';
+
+import Server from '../server';
+
+import { JobStatus, JobStatusSharedWithGrowi } from 'src/service/pdf-convert';
+
+describe('PdfCtrl', () => {
+  beforeAll(PlatformTest.bootstrap(Server));
+  afterAll(PlatformTest.reset);
+
+  it('should return 500 for invalid appId', async() => {
+    const request = SuperTest(PlatformTest.callback());
+    await request
+      .post('/pdf/sync-job')
+      .send({
+        jobId: '64d2fa8b2f9c1e4a9b5e3d77',
+        expirationDate: '2024-01-01T00:00:00Z',
+        status: JobStatusSharedWithGrowi.HTML_EXPORT_IN_PROGRESS,
+        appId: '../../../admin/secret-dir',
+      })
+      .expect(500);
+  });
+
+  it('should return 400 for invalid jobId', async() => {
+    const request = SuperTest(PlatformTest.callback());
+    const res = await request
+      .post('/pdf/sync-job')
+      .send({
+        jobId: '../../../admin/secret-dir',
+        expirationDate: '2024-01-01T00:00:00Z',
+        status: JobStatusSharedWithGrowi.HTML_EXPORT_IN_PROGRESS,
+        appId: 1,
+      })
+      .expect(400);
+
+    expect(res.body.message).toContain('jobId must be a valid MongoDB ObjectId');
+  });
+
+  it('should return 202 and status for valid request', async() => {
+    const request = SuperTest(PlatformTest.callback());
+    const res = await request
+      .post('/pdf/sync-job')
+      .send({
+        jobId: '64d2fa8b2f9c1e4a9b5e3d77',
+        expirationDate: '2024-01-01T00:00:00Z',
+        status: JobStatusSharedWithGrowi.HTML_EXPORT_IN_PROGRESS,
+        appId: 1,
+      })
+      .expect(202);
+
+    expect(res.body).toHaveProperty('status');
+    expect(Object.values(JobStatus)).toContain(res.body.status);
+  });
+});

+ 16 - 8
apps/pdf-converter/src/controllers/pdf.ts

@@ -1,9 +1,9 @@
 import { BodyParams } from '@tsed/common';
 import { BodyParams } from '@tsed/common';
 import { Controller } from '@tsed/di';
 import { Controller } from '@tsed/di';
-import { InternalServerError } from '@tsed/exceptions';
+import { InternalServerError, BadRequest } from '@tsed/exceptions';
 import { Logger } from '@tsed/logger';
 import { Logger } from '@tsed/logger';
 import {
 import {
-  Post, Returns, Enum, Description,
+  Post, Returns, Enum, Description, Required, Integer,
 } from '@tsed/schema';
 } from '@tsed/schema';
 
 
 import PdfConvertService, { JobStatusSharedWithGrowi, JobStatus } from '../service/pdf-convert.js';
 import PdfConvertService, { JobStatusSharedWithGrowi, JobStatus } from '../service/pdf-convert.js';
@@ -28,20 +28,28 @@ class PdfCtrl {
     Return resulting status of job to GROWI.
     Return resulting status of job to GROWI.
   `)
   `)
   async syncJobStatus(
   async syncJobStatus(
-    @BodyParams('jobId') jobId: string,
-    @BodyParams('expirationDate') expirationDateStr: string,
-    @BodyParams('status') @Enum(Object.values(JobStatusSharedWithGrowi)) growiJobStatus: JobStatusSharedWithGrowi,
-  ): Promise<{ status: JobStatus }> {
+    @Required() @BodyParams('jobId') jobId: string,
+    @Required() @BodyParams('expirationDate') expirationDateStr: string,
+    @Required() @BodyParams('status') @Enum(Object.values(JobStatusSharedWithGrowi)) growiJobStatus: JobStatusSharedWithGrowi,
+    @Integer() @BodyParams('appId') appId?: number, // prevent path traversal attack
+  ): Promise<{ status: JobStatus } | undefined> {
+    // prevent path traversal attack
+    if (!/^[a-f\d]{24}$/i.test(jobId)) {
+      throw new BadRequest('jobId must be a valid MongoDB ObjectId');
+    }
+
     const expirationDate = new Date(expirationDateStr);
     const expirationDate = new Date(expirationDateStr);
     try {
     try {
-      await this.pdfConvertService.registerOrUpdateJob(jobId, expirationDate, growiJobStatus);
+      await this.pdfConvertService.registerOrUpdateJob(jobId, expirationDate, growiJobStatus, appId);
       const status = this.pdfConvertService.getJobStatus(jobId); // get status before cleanup
       const status = this.pdfConvertService.getJobStatus(jobId); // get status before cleanup
       this.pdfConvertService.cleanUpJobList();
       this.pdfConvertService.cleanUpJobList();
       return { status };
       return { status };
     }
     }
     catch (err) {
     catch (err) {
       this.logger.error('Failed to register or update job', err);
       this.logger.error('Failed to register or update job', err);
-      throw new InternalServerError(err);
+      if (err instanceof Error) {
+        throw new InternalServerError(err.message);
+      }
     }
     }
   }
   }
 
 

+ 1 - 1
apps/pdf-converter/src/server.ts

@@ -34,7 +34,7 @@ const PORT = Number(process.env.PORT || 3010);
 class Server {
 class Server {
 
 
   @Inject()
   @Inject()
-    app: PlatformApplication;
+    app: PlatformApplication | undefined;
 
 
 }
 }
 
 

+ 24 - 14
apps/pdf-converter/src/service/pdf-convert.ts

@@ -59,11 +59,17 @@ class PdfConvertService implements OnInit {
   /**
   /**
    * Register or update job inside jobList with given jobId, expirationDate, and status.
    * Register or update job inside jobList with given jobId, expirationDate, and status.
    * If job is new, start reading html files and convert them to pdf.
    * If job is new, start reading html files and convert them to pdf.
-   * @param jobId id of PageBulkExportJob
+   * @param jobId PageBulkExportJob ID
    * @param expirationDate expiration date of job
    * @param expirationDate expiration date of job
    * @param status status of job
    * @param status status of job
+   * @param appId application ID for GROWI.cloud
    */
    */
-  async registerOrUpdateJob(jobId: string, expirationDate: Date, status: JobStatusSharedWithGrowi): Promise<void> {
+  async registerOrUpdateJob(
+      jobId: string,
+      expirationDate: Date,
+      status: JobStatusSharedWithGrowi,
+      appId?: number,
+  ): Promise<void> {
     const isJobNew = !(jobId in this.jobList);
     const isJobNew = !(jobId in this.jobList);
 
 
     if (isJobNew) {
     if (isJobNew) {
@@ -83,7 +89,7 @@ class PdfConvertService implements OnInit {
     }
     }
 
 
     if (isJobNew && status !== JobStatus.FAILED) {
     if (isJobNew && status !== JobStatus.FAILED) {
-      this.readHtmlAndConvertToPdfUntilFinish(jobId);
+      this.readHtmlAndConvertToPdfUntilFinish(jobId, appId);
     }
     }
   }
   }
 
 
@@ -134,9 +140,10 @@ class PdfConvertService implements OnInit {
   /**
   /**
    * Read html files from shared fs path, convert them to pdf, and save them to shared fs path.
    * Read html files from shared fs path, convert them to pdf, and save them to shared fs path.
    * Repeat this until all html files are converted to pdf or job fails.
    * Repeat this until all html files are converted to pdf or job fails.
-   * @param jobId id of PageBulkExportJob
+   * @param jobId PageBulkExportJob ID
+   * @param appId application ID for GROWI.cloud
    */
    */
-  private async readHtmlAndConvertToPdfUntilFinish(jobId: string): Promise<void> {
+  private async readHtmlAndConvertToPdfUntilFinish(jobId: string, appId?: number): Promise<void> {
     while (!this.isJobCompleted(jobId)) {
     while (!this.isJobCompleted(jobId)) {
       // eslint-disable-next-line no-await-in-loop
       // eslint-disable-next-line no-await-in-loop
       await new Promise(resolve => setTimeout(resolve, 10 * 1000));
       await new Promise(resolve => setTimeout(resolve, 10 * 1000));
@@ -146,7 +153,7 @@ class PdfConvertService implements OnInit {
           throw new Error('Job expired');
           throw new Error('Job expired');
         }
         }
 
 
-        const htmlReadable = this.getHtmlReadable(jobId);
+        const htmlReadable = this.getHtmlReadable(jobId, appId);
         const pdfWritable = this.getPdfWritable();
         const pdfWritable = this.getPdfWritable();
         this.jobList[jobId].currentStream = htmlReadable;
         this.jobList[jobId].currentStream = htmlReadable;
 
 
@@ -165,11 +172,13 @@ class PdfConvertService implements OnInit {
 
 
   /**
   /**
    * Get readable stream that reads html files from shared fs path
    * Get readable stream that reads html files from shared fs path
-   * @param jobId id of PageBulkExportJob
+   * @param jobId PageBulkExportJob ID
+   * @param appId application ID for GROWI.cloud
    * @returns readable stream
    * @returns readable stream
    */
    */
-  private getHtmlReadable(jobId: string): Readable {
-    const htmlFileEntries = fs.readdirSync(path.join(this.tmpHtmlDir, jobId), { recursive: true, withFileTypes: true }).filter(entry => entry.isFile());
+  private getHtmlReadable(jobId: string, appId?: number): Readable {
+    const jobHtmlDir = path.join(this.tmpHtmlDir, appId?.toString() ?? '', jobId);
+    const htmlFileEntries = fs.readdirSync(jobHtmlDir, { recursive: true, withFileTypes: true }).filter(entry => entry.isFile());
     let index = 0;
     let index = 0;
 
 
     const jobList = this.jobList;
     const jobList = this.jobList;
@@ -215,7 +224,9 @@ class PdfConvertService implements OnInit {
           await fs.promises.rm(pageInfo.htmlFilePath, { force: true });
           await fs.promises.rm(pageInfo.htmlFilePath, { force: true });
         }
         }
         catch (err) {
         catch (err) {
-          callback(err);
+          if (err instanceof Error) {
+            callback(err);
+          }
           return;
           return;
         }
         }
         callback();
         callback();
@@ -229,9 +240,9 @@ class PdfConvertService implements OnInit {
    * @returns converted pdf
    * @returns converted pdf
    */
    */
   private async convertHtmlToPdf(htmlString: string): Promise<Buffer> {
   private async convertHtmlToPdf(htmlString: string): Promise<Buffer> {
-    const executeConvert = async(retries: number) => {
+    const executeConvert = async(retries: number): Promise<Buffer> => {
       try {
       try {
-        return this.puppeteerCluster.execute(htmlString);
+        return this.puppeteerCluster?.execute(htmlString);
       }
       }
       catch (err) {
       catch (err) {
         if (retries > 0) {
         if (retries > 0) {
@@ -251,8 +262,7 @@ class PdfConvertService implements OnInit {
    * Initialize puppeteer cluster
    * Initialize puppeteer cluster
    */
    */
   private async initPuppeteerCluster(): Promise<void> {
   private async initPuppeteerCluster(): Promise<void> {
-    // puppeteer is unnecessary for swagger schema generation
-    if (process.env.SWAGGER_GENERATION === 'true') return;
+    if (process.env.SKIP_PUPPETEER_INIT === 'true') return;
 
 
     this.puppeteerCluster = await Cluster.launch({
     this.puppeteerCluster = await Cluster.launch({
       concurrency: Cluster.CONCURRENCY_PAGE,
       concurrency: Cluster.CONCURRENCY_PAGE,

+ 2 - 1
apps/pdf-converter/tsconfig.json

@@ -8,7 +8,8 @@
     "esModuleInterop": true,
     "esModuleInterop": true,
     "experimentalDecorators": true,
     "experimentalDecorators": true,
     "emitDecoratorMetadata": true,
     "emitDecoratorMetadata": true,
-    "strict": false
+    "strict": true,
+    "types": ["vitest/globals"]
   },
   },
   "include": ["./src/**/*", "./test/**/*"],
   "include": ["./src/**/*", "./test/**/*"],
   "exclude": ["node_modules", "dist"]
   "exclude": ["node_modules", "dist"]

+ 12 - 0
apps/pdf-converter/vitest.config.ts

@@ -0,0 +1,12 @@
+import swc from 'unplugin-swc';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    globals: true,
+    root: './',
+  },
+  plugins: [
+    swc.vite(),
+  ],
+});

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "7.2.5-slackbot-proxy.0",
+  "version": "7.2.6-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
@@ -36,7 +36,7 @@
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@growi/slack": "workspace:^",
     "@growi/slack": "workspace:^",
-    "@slack/oauth": "^2.0.1",
+    "@slack/oauth": "^3.0.3",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "=6.43.0",
     "@tsed/common": "=6.43.0",
     "@tsed/di": "=6.43.0",
     "@tsed/di": "=6.43.0",

+ 1 - 0
apps/slackbot-proxy/src/services/InstallerService.ts

@@ -32,6 +32,7 @@ export class InstallerService {
       clientId,
       clientId,
       clientSecret,
       clientSecret,
       stateSecret,
       stateSecret,
+      legacyStateVerification: true,
       installationStore: {
       installationStore: {
         // upsert
         // upsert
         storeInstallation: async(slackInstallation: SlackInstallation<'v1' | 'v2', boolean>) => {
         storeInstallation: async(slackInstallation: SlackInstallation<'v1' | 'v2', boolean>) => {

+ 2 - 2
biome.json

@@ -7,6 +7,7 @@
       "coverage/**",
       "coverage/**",
       "vite.config.ts.timestamp-*",
       "vite.config.ts.timestamp-*",
       "vite.server.config.ts.timestamp-*",
       "vite.server.config.ts.timestamp-*",
+      "vite.client.config.ts.timestamp-*",
       ".pnpm-store/**",
       ".pnpm-store/**",
       ".turbo/**",
       ".turbo/**",
       ".vscode/**",
       ".vscode/**",
@@ -17,7 +18,6 @@
       ".eslintrc.js",
       ".eslintrc.js",
       ".stylelintrc.json",
       ".stylelintrc.json",
       "package.json",
       "package.json",
-
       "./apps/**",
       "./apps/**",
       "./packages/core/**",
       "./packages/core/**",
       "./packages/core-styles/**",
       "./packages/core-styles/**",
@@ -51,4 +51,4 @@
       "quoteStyle": "single"
       "quoteStyle": "single"
     }
     }
   }
   }
-}
+}

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "7.2.5-RC.0",
+  "version": "7.2.6-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
@@ -96,7 +96,7 @@
     "turbo": "^2.1.3",
     "turbo": "^2.1.3",
     "typescript": "~5.0.0",
     "typescript": "~5.0.0",
     "typescript-transform-paths": "^3.4.7",
     "typescript-transform-paths": "^3.4.7",
-    "vite": "^5.4.17",
+    "vite": "^5.4.19",
     "vite-plugin-dts": "^3.9.1",
     "vite-plugin-dts": "^3.9.1",
     "vite-tsconfig-paths": "^5.0.1",
     "vite-tsconfig-paths": "^5.0.1",
     "vitest": "^2.1.1",
     "vitest": "^2.1.1",

+ 0 - 162
packages/core/src/utils/path-utils.js

@@ -1,162 +0,0 @@
-/**
- * @private
- *
- *
- * @param {string} path
- * @returns {RegExpMatchArray}
- * @memberof pathUtils
- */
-function matchSlashes(path) {
-  // https://regex101.com/r/Z21fEd/5
-  return path.match(/^((\/+)?(.+?))(\/+)?$/);
-}
-
-/**
- *
- * @param {string} path
- * @returns {boolean}
- * @memberof pathUtils
- */
-export function hasHeadingSlash(path) {
-  if (path === '') {
-    return false;
-  }
-  const match = matchSlashes(path);
-  return (match[2] != null);
-}
-
-/**
- *
- * @param {string} path
- * @returns {boolean}
- * @memberof pathUtils
- */
-export function hasTrailingSlash(path) {
-  if (path === '') {
-    return false;
-  }
-  const match = matchSlashes(path);
-  return (match[4] != null);
-}
-
-/**
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function addHeadingSlash(path) {
-  if (path === '/') {
-    return path;
-  }
-
-  if (!hasHeadingSlash(path)) {
-    return `/${path}`;
-  }
-  return path;
-}
-
-/**
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function addTrailingSlash(path) {
-  if (path === '/') {
-    return path;
-  }
-
-  if (!hasTrailingSlash(path)) {
-    return `${path}/`;
-  }
-  return path;
-}
-
-/**
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function removeHeadingSlash(path) {
-  if (path === '/') {
-    return path;
-  }
-
-  return hasHeadingSlash(path)
-    ? path.substring(1)
-    : path;
-}
-
-/**
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function removeTrailingSlash(path) {
-  if (path === '/') {
-    return path;
-  }
-
-  const match = matchSlashes(path);
-  return match[1];
-}
-
-/**
- * A short-hand method to add heading slash and remove trailing slash.
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function normalizePath(path) {
-  if (path === '' || path === '/') {
-    return '/';
-  }
-
-  const match = matchSlashes(path);
-  if (match == null) {
-    return '/';
-  }
-  return `/${match[3]}`;
-}
-
-
-/**
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function attachTitleHeader(path) {
-  return `# ${path}`;
-}
-
-/**
- * If the pagePath is top page path, eliminate the pageId from the url path.
- *
- * @param {string} path
- * @param {string} id
- * @returns {string}
- * @memberof pathUtils
- */
-export function returnPathForURL(path, id) {
-  if (path === '/') {
-    return path;
-  }
-
-  return addHeadingSlash(id);
-}
-
-/**
- * Get the parent path of the specified path.
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function getParentPath(path) {
-  return normalizePath(path.split('/').slice(0, -1).join('/'));
-}

+ 162 - 11
packages/core/src/utils/path-utils.spec.ts

@@ -1,26 +1,177 @@
 import * as pathUtils from './path-utils';
 import * as pathUtils from './path-utils';
 
 
-
 describe('page-utils', () => {
 describe('page-utils', () => {
+
   describe('.normalizePath', () => {
   describe('.normalizePath', () => {
-    test.concurrent('should return the root path with empty string', () => {
-      expect(pathUtils.normalizePath('')).toBe('/');
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${'/'}
+      ${'path'}             | ${'/path'}
+      ${'/path'}            | ${'/path'}
+      ${'path/'}            | ${'/path'}
+      ${'/path/'}           | ${'/path'}
+      ${'path1/path2'}      | ${'/path1/path2'}
+      ${'/path1/path2'}     | ${'/path1/path2'}
+      ${'path1/path2/'}     | ${'/path1/path2'}
+      ${'/path1/path2/'}    | ${'/path1/path2'}
+      ${'//path1/path2//'}  | ${'/path1/path2'}
+      ${'https://example.com'} | ${'/https://example.com'}
+      ${'https://example.com/'} | ${'/https://example.com'}
+    `('should normalize \'$path\' to \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.normalizePath(path)).toBe(expected);
+    });
+  });
+
+  describe('.hasHeadingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${true}
+      ${''}                 | ${false}
+      ${'path'}             | ${false}
+      ${'/path'}            | ${true}
+      ${'path/'}            | ${false}
+      ${'/path/'}           | ${true}
+      ${'path1/path2'}      | ${false}
+      ${'/path1/path2'}     | ${true}
+      ${'path1/path2/'}     | ${false}
+      ${'/path1/path2/'}    | ${true}
+      ${'//path1/path2//'}  | ${true}
+      ${'https://example.com'} | ${false}
+      ${'https://example.com/'} | ${false}
+    `('should return $expected when checking heading slash for \'$path\'', ({ path, expected }) => {
+      expect(pathUtils.hasHeadingSlash(path)).toBe(expected);
     });
     });
+  });
 
 
-    test.concurrent('should return the root path as is', () => {
-      expect(pathUtils.normalizePath('/')).toBe('/');
+  describe('.hasTrailingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${true}
+      ${''}                 | ${false}
+      ${'path'}             | ${false}
+      ${'/path'}            | ${false}
+      ${'path/'}            | ${true}
+      ${'/path/'}           | ${true}
+      ${'path1/path2'}      | ${false}
+      ${'/path1/path2'}     | ${false}
+      ${'path1/path2/'}     | ${true}
+      ${'/path1/path2/'}    | ${true}
+      ${'//path1/path2//'}  | ${true}
+      ${'https://example.com'} | ${false}
+      ${'https://example.com/'} | ${true}
+    `('should return $expected when checking trailing slash for \'$path\'', ({ path, expected }) => {
+      expect(pathUtils.hasTrailingSlash(path)).toBe(expected);
     });
     });
+  });
 
 
-    test.concurrent('should add heading slash', () => {
-      expect(pathUtils.normalizePath('hoge/fuga')).toBe('/hoge/fuga');
+  describe('.addHeadingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${'/'}
+      ${'path'}             | ${'/path'}
+      ${'/path'}            | ${'/path'}
+      ${'path/'}            | ${'/path/'}
+      ${'/path/'}           | ${'/path/'}
+      ${'path1/path2'}      | ${'/path1/path2'}
+      ${'/path1/path2'}     | ${'/path1/path2'}
+      ${'path1/path2/'}     | ${'/path1/path2/'}
+      ${'/path1/path2/'}    | ${'/path1/path2/'}
+      ${'//path1/path2//'}  | ${'//path1/path2//'}
+      ${'https://example.com'} | ${'/https://example.com'}
+      ${'https://example.com/'} | ${'/https://example.com/'}
+    `('should add heading slash to \'$path\' resulting in \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.addHeadingSlash(path)).toBe(expected);
     });
     });
+  });
 
 
-    test.concurrent('should remove trailing slash', () => {
-      expect(pathUtils.normalizePath('/hoge/fuga/')).toBe('/hoge/fuga');
+  describe('.addTrailingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${'/'}
+      ${'path'}             | ${'path/'}
+      ${'/path'}            | ${'/path/'}
+      ${'path/'}            | ${'path/'}
+      ${'/path/'}           | ${'/path/'}
+      ${'path1/path2'}      | ${'path1/path2/'}
+      ${'/path1/path2'}     | ${'/path1/path2/'}
+      ${'path1/path2/'}     | ${'path1/path2/'}
+      ${'/path1/path2/'}    | ${'/path1/path2/'}
+      ${'//path1/path2//'}  | ${'//path1/path2//'}
+      ${'https://example.com'} | ${'https://example.com/'}
+      ${'https://example.com/'} | ${'https://example.com/'}
+    `('should add trailing slash to \'$path\' resulting in \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.addTrailingSlash(path)).toBe(expected);
     });
     });
+  });
+
+  describe('.removeHeadingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${''}
+      ${'path'}             | ${'path'}
+      ${'/path'}            | ${'path'}
+      ${'path/'}            | ${'path/'}
+      ${'/path/'}           | ${'path/'}
+      ${'path1/path2'}      | ${'path1/path2'}
+      ${'/path1/path2'}     | ${'path1/path2'}
+      ${'path1/path2/'}     | ${'path1/path2/'}
+      ${'/path1/path2/'}    | ${'path1/path2/'}
+      ${'//path1/path2//'}  | ${'path1/path2//'}
+      ${'https://example.com'} | ${'https://example.com'}
+      ${'https://example.com/'} | ${'https://example.com/'}
+      ${'//'}               | ${'/'}                  // from former specific test
+    `('should remove heading slash from \'$path\' resulting in \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.removeHeadingSlash(path)).toBe(expected);
+    });
+  });
+
+  describe('.removeTrailingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${''}
+      ${'path'}             | ${'path'}
+      ${'/path'}            | ${'/path'}
+      ${'path/'}            | ${'path'}
+      ${'/path/'}           | ${'/path'}
+      ${'path1/path2'}      | ${'path1/path2'}
+      ${'/path1/path2'}     | ${'/path1/path2'}
+      ${'path1/path2/'}     | ${'path1/path2'}
+      ${'/path1/path2/'}    | ${'/path1/path2'}
+      ${'//path1/path2//'}  | ${'//path1/path2'}
+      ${'https://example.com'} | ${'https://example.com'}
+      ${'https://example.com/'} | ${'https://example.com'}
+    `('should remove trailing slash from \'$path\' resulting in \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.removeTrailingSlash(path)).toBe(expected);
+    });
+  });
 
 
-    test.concurrent('should remove unnecessary slashes', () => {
-      expect(pathUtils.normalizePath('//hoge/fuga//')).toBe('/hoge/fuga');
+  describe('.getParentPath', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${'/'}
+      ${'path'}             | ${'/'}
+      ${'/path'}            | ${'/'}
+      ${'path/'}            | ${'/path'}
+      ${'/path/'}           | ${'/path'}
+      ${'path1/path2'}      | ${'/path1'}
+      ${'/path1/path2'}     | ${'/path1'}
+      ${'path1/path2/'}     | ${'/path1/path2'}
+      ${'/path1/path2/'}    | ${'/path1/path2'}
+      ${'//path1/path2//'}  | ${'/path1/path2'}
+      ${'https://example.com'} | ${'/https:'}
+      ${'https://example.com/'} | ${'/https://example.com'}
+      ${'/page'}            | ${'/'}                  // from former specific test
+      // Note: getParentPath('page') is covered by 'path' -> '/'
+      // Note: getParentPath('/path1/path2') is covered by '/path1/path2' -> '/path1'
+      // Note: getParentPath('/path1/path2/') is covered by '/path1/path2/' -> '/path1/path2'
+    `('should get parent path of \'$path\' as \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.getParentPath(path)).toBe(expected);
     });
     });
   });
   });
 });
 });

+ 146 - 0
packages/core/src/utils/path-utils.ts

@@ -0,0 +1,146 @@
+interface PathParts {
+  readonly headingSlashes: string;
+  readonly content: string;
+  readonly trailingSlashes: string;
+  readonly hasHeadingSlash: boolean;
+  readonly hasTrailingSlash: boolean;
+}
+
+function parsePath(path: string): PathParts | null {
+  if (typeof path !== 'string' || !path || path === '') return null;
+
+  // Special case for root path
+  if (path === '/') {
+    return {
+      headingSlashes: '/',
+      content: '',
+      trailingSlashes: '',
+      hasHeadingSlash: true,
+      hasTrailingSlash: true,
+    };
+  }
+
+  let startIndex = 0;
+  let endIndex = path.length;
+
+  // Find leading slashes
+  while (startIndex < path.length && path[startIndex] === '/') {
+    startIndex++;
+  }
+
+  // Find trailing slashes
+  while (endIndex > startIndex && path[endIndex - 1] === '/') {
+    endIndex--;
+  }
+
+  const headingSlashes = path.substring(0, startIndex);
+  const content = path.substring(startIndex, endIndex);
+  const trailingSlashes = path.substring(endIndex);
+
+  return {
+    headingSlashes,
+    content,
+    trailingSlashes,
+    hasHeadingSlash: headingSlashes.length > 0,
+    hasTrailingSlash: trailingSlashes.length > 0,
+  };
+}
+
+export function hasHeadingSlash(path: string): boolean {
+  if (path === '/') return true;
+
+  const parts = parsePath(path);
+  return parts?.hasHeadingSlash ?? false;
+}
+
+export function hasTrailingSlash(path: string): boolean {
+  if (path === '/') return true;
+
+  const parts = parsePath(path);
+  return parts?.hasTrailingSlash ?? false;
+}
+
+export function addHeadingSlash(path: string): string {
+  if (path === '/') return path;
+  if (path === '') return '/';
+
+  const parts = parsePath(path);
+  if (!parts?.hasHeadingSlash) {
+    return `/${path}`;
+  }
+  return path;
+}
+
+export function addTrailingSlash(path: string): string {
+  if (path === '/') return path;
+  if (path === '') return '/';
+
+  const parts = parsePath(path);
+  if (!parts?.hasTrailingSlash) {
+    return `${path}/`;
+  }
+  return path;
+}
+
+export function removeHeadingSlash(path: string): string {
+  if (path === '/') return path;
+  if (path === '') return path;
+
+  const parts = parsePath(path);
+  if (!parts?.hasHeadingSlash) return path;
+
+  // Special case for '//' -> '/'
+  if (path === '//') return '/';
+
+  // Remove heading slashes and return content + trailing slashes
+  return parts.content + parts.trailingSlashes;
+}
+
+export function removeTrailingSlash(path: string): string {
+  if (path === '/') return path;
+  if (path === '') return path;
+
+  const parts = parsePath(path);
+  if (parts == null) return path;
+
+  // Return heading slashes + content (without trailing slashes)
+  return parts.headingSlashes + parts.content;
+}
+
+/**
+ * A short-hand method to add heading slash and remove trailing slash.
+ */
+export function normalizePath(path: string): string {
+  if (typeof path !== 'string' || path === '' || path === '/') {
+    return '/';
+  }
+
+  const parts = parsePath(path);
+  if (parts == null) {
+    return '/';
+  }
+  return `/${parts.content}`;
+}
+
+
+export function attachTitleHeader(path: string): string {
+  return `# ${path}`;
+}
+
+/**
+ * If the pagePath is top page path, eliminate the pageId from the url path.
+ */
+export function returnPathForURL(path: string, id: string): string {
+  if (path === '/') {
+    return path;
+  }
+
+  return addHeadingSlash(id);
+}
+
+/**
+ * Get the parent path of the specified path.
+ */
+export function getParentPath(path: string): string {
+  return normalizePath(path.split('/').slice(0, -1).join('/'));
+}

+ 11 - 4
packages/pdf-converter-client/src/index.ts

@@ -24,6 +24,9 @@ export type PdfCtrlSyncJobStatus202 = {
   status: PdfCtrlSyncJobStatus202Status;
   status: PdfCtrlSyncJobStatus202Status;
 };
 };
 
 
+/**
+ * @minLength 1
+ */
 export type PdfCtrlSyncJobStatusBodyStatus = typeof PdfCtrlSyncJobStatusBodyStatus[keyof typeof PdfCtrlSyncJobStatusBodyStatus];
 export type PdfCtrlSyncJobStatusBodyStatus = typeof PdfCtrlSyncJobStatusBodyStatus[keyof typeof PdfCtrlSyncJobStatusBodyStatus];
 
 
 
 
@@ -35,9 +38,13 @@ export const PdfCtrlSyncJobStatusBodyStatus = {
 } as const;
 } as const;
 
 
 export type PdfCtrlSyncJobStatusBody = {
 export type PdfCtrlSyncJobStatusBody = {
-  expirationDate?: string;
-  jobId?: string;
-  status?: PdfCtrlSyncJobStatusBodyStatus;
+  appId?: number;
+  /** @minLength 1 */
+  expirationDate: string;
+  /** @minLength 1 */
+  jobId: string;
+  /** @minLength 1 */
+  status: PdfCtrlSyncJobStatusBodyStatus;
 };
 };
 
 
 export interface GenericError {
 export interface GenericError {
@@ -85,7 +92,7 @@ export interface InternalServerError {
   
   
  */
  */
 export const pdfCtrlSyncJobStatus = <TData = AxiosResponse<PdfCtrlSyncJobStatus202>>(
 export const pdfCtrlSyncJobStatus = <TData = AxiosResponse<PdfCtrlSyncJobStatus202>>(
-    pdfCtrlSyncJobStatusBody?: PdfCtrlSyncJobStatusBody, options?: AxiosRequestConfig
+    pdfCtrlSyncJobStatusBody: PdfCtrlSyncJobStatusBody, options?: AxiosRequestConfig
  ): Promise<TData> => {
  ): Promise<TData> => {
     return axios.post(
     return axios.post(
       `/pdf/sync-job`,
       `/pdf/sync-job`,

+ 7 - 5
packages/remark-lsx/src/client/services/renderer/lsx.ts

@@ -45,6 +45,7 @@ export const remarkPlugin: Plugin = () => (tree) => {
 
 
       const data = node.data ?? {};
       const data = node.data ?? {};
       node.data = data;
       node.data = data;
+      // Create a new object for attributes to avoid mutation issues
       const attributes = (node.attributes as DirectiveAttributes) || {};
       const attributes = (node.attributes as DirectiveAttributes) || {};
 
 
       // set 'prefix' attribute if the first attribute is only value
       // set 'prefix' attribute if the first attribute is only value
@@ -60,7 +61,7 @@ export const remarkPlugin: Plugin = () => (tree) => {
 
 
           if (
           if (
             firstAttrValue === '' &&
             firstAttrValue === '' &&
-            !SUPPORTED_ATTRIBUTES.includes(firstAttrValue)
+            !SUPPORTED_ATTRIBUTES.includes(firstAttrKey)
           ) {
           ) {
             attributes.prefix = firstAttrKey;
             attributes.prefix = firstAttrKey;
           }
           }
@@ -107,7 +108,8 @@ export const rehypePlugin: Plugin<[LsxRehypePluginParams]> = (options = {}) => {
 
 
     for (const lsxElem of elements) {
     for (const lsxElem of elements) {
       if (lsxElem.properties == null) {
       if (lsxElem.properties == null) {
-        return;
+        // Initialize properties if null to avoid errors down the line
+        lsxElem.properties = {};
       }
       }
 
 
       const isSharedPage = lsxElem.properties.isSharedPage;
       const isSharedPage = lsxElem.properties.isSharedPage;
@@ -120,12 +122,12 @@ export const rehypePlugin: Plugin<[LsxRehypePluginParams]> = (options = {}) => {
       // set basePagePath when prefix is undefined or invalid
       // set basePagePath when prefix is undefined or invalid
       if (prefix == null || typeof prefix !== 'string') {
       if (prefix == null || typeof prefix !== 'string') {
         lsxElem.properties.prefix = basePagePath;
         lsxElem.properties.prefix = basePagePath;
-        return;
+        continue;
       }
       }
 
 
-      // return when prefix is already determined and aboslute path
+      // continue when prefix is already determined and aboslute path
       if (hasHeadingSlash(prefix)) {
       if (hasHeadingSlash(prefix)) {
-        return;
+        continue;
       }
       }
 
 
       // resolve relative path
       // resolve relative path

+ 3 - 1
packages/remark-lsx/src/server/routes/list-pages/index.spec.ts

@@ -128,7 +128,9 @@ describe('listPages', () => {
       expect(mocks.addNumConditionMock).toHaveBeenCalledOnce(); // throw an error
       expect(mocks.addNumConditionMock).toHaveBeenCalledOnce(); // throw an error
       expect(mocks.addSortConditionMock).not.toHaveBeenCalledOnce(); // does not called
       expect(mocks.addSortConditionMock).not.toHaveBeenCalledOnce(); // does not called
       expect(resMock.status).toHaveBeenCalledOnce();
       expect(resMock.status).toHaveBeenCalledOnce();
-      expect(resStatusMock.send).toHaveBeenCalledWith('error for test');
+      expect(resStatusMock.send).toHaveBeenCalledWith(
+        'An internal server error occurred.',
+      );
     });
     });
 
 
     it('returns 400 HTTP response when the value is invalid', async () => {
     it('returns 400 HTTP response when the value is invalid', async () => {

+ 4 - 2
packages/remark-lsx/src/server/routes/list-pages/index.ts

@@ -92,7 +92,8 @@ export const listPages = async (
   try {
   try {
     toppageViewersCount = await getToppageViewersCount();
     toppageViewersCount = await getToppageViewersCount();
   } catch (error) {
   } catch (error) {
-    return res.status(500).send(error);
+    console.error('Error occurred in getToppageViewersCount:', error);
+    return res.status(500).send('An internal server error occurred.');
   }
   }
 
 
   let query = builder.query;
   let query = builder.query;
@@ -132,9 +133,10 @@ export const listPages = async (
     };
     };
     return res.status(200).send(responseData);
     return res.status(200).send(responseData);
   } catch (error) {
   } catch (error) {
+    console.error('Error occurred while processing listPages request:', error);
     if (isHttpError(error)) {
     if (isHttpError(error)) {
       return res.status(error.status).send(error.message);
       return res.status(error.status).send(error.message);
     }
     }
-    return res.status(500).send(error.message);
+    return res.status(500).send('An internal server error occurred.');
   }
   }
 };
 };

+ 1 - 1
packages/slack/package.json

@@ -49,7 +49,7 @@
     "test": "vitest run --coverage"
     "test": "vitest run --coverage"
   },
   },
   "dependencies": {
   "dependencies": {
-    "@slack/oauth": "^2.0.1",
+    "@slack/oauth": "^3.0.3",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@types/bunyan": "^1.8.10",
     "@types/bunyan": "^1.8.10",
     "@types/http-errors": "^2.0.3",
     "@types/http-errors": "^2.0.3",

+ 25 - 13
packages/slack/src/utils/respond-util-factory.ts

@@ -3,6 +3,7 @@ import urljoin from 'url-join';
 
 
 import type { IRespondUtil } from '../interfaces/respond-util';
 import type { IRespondUtil } from '../interfaces/respond-util';
 import type { RespondBodyForResponseUrl } from '../interfaces/response-url';
 import type { RespondBodyForResponseUrl } from '../interfaces/response-url';
+import { isValidResponseUrl } from './response-url-validator';
 
 
 type AxiosOptions = {
 type AxiosOptions = {
   headers?: {
   headers?: {
@@ -14,22 +15,35 @@ function getResponseUrlForProxy(proxyUri: string, responseUrl: string): string {
   return urljoin(proxyUri, `/g2s/respond?response_url=${responseUrl}`);
   return urljoin(proxyUri, `/g2s/respond?response_url=${responseUrl}`);
 }
 }
 
 
-function getUrl(responseUrl: string, proxyUri: string | null): string {
-  return proxyUri == null
-    ? responseUrl
-    : getResponseUrlForProxy(proxyUri, responseUrl);
+function getUrl(responseUrl: string, proxyUri?: string): string {
+  const finalUrl =
+    proxyUri === undefined
+      ? responseUrl
+      : getResponseUrlForProxy(proxyUri, responseUrl);
+
+  if (!isValidResponseUrl(responseUrl, proxyUri)) {
+    throw new Error('Invalid final response URL');
+  }
+
+  return finalUrl;
 }
 }
 
 
+type RespondUtilConstructorArgs = {
+  responseUrl: string;
+  appSiteUrl: string;
+  proxyUri?: string;
+};
+
 export class RespondUtil implements IRespondUtil {
 export class RespondUtil implements IRespondUtil {
   url!: string;
   url!: string;
 
 
   options!: AxiosOptions;
   options!: AxiosOptions;
 
 
-  constructor(
-    responseUrl: string,
-    proxyUri: string | null,
-    appSiteUrl: string,
-  ) {
+  constructor({
+    responseUrl,
+    appSiteUrl,
+    proxyUri,
+  }: RespondUtilConstructorArgs) {
     this.url = getUrl(responseUrl, proxyUri);
     this.url = getUrl(responseUrl, proxyUri);
 
 
     this.options = {
     this.options = {
@@ -88,9 +102,7 @@ export class RespondUtil implements IRespondUtil {
 }
 }
 
 
 export function generateRespondUtil(
 export function generateRespondUtil(
-  responseUrl: string,
-  proxyUri: string | null,
-  appSiteUrl: string,
+  args: RespondUtilConstructorArgs,
 ): RespondUtil {
 ): RespondUtil {
-  return new RespondUtil(responseUrl, proxyUri, appSiteUrl);
+  return new RespondUtil(args);
 }
 }

+ 43 - 0
packages/slack/src/utils/response-url-validator.ts

@@ -0,0 +1,43 @@
+import { URL } from 'node:url';
+
+const ALLOWED_SLACK_HOST = 'hooks.slack.com';
+
+export function isValidResponseUrl(
+  responseUrl: string,
+  slackbotProxyUri?: string,
+): boolean {
+  try {
+    const parsedUrl = new URL(responseUrl);
+
+    // Case 1: Direct to Slack
+    if (
+      parsedUrl.protocol === 'https:' &&
+      parsedUrl.hostname === ALLOWED_SLACK_HOST
+    ) {
+      return true;
+    }
+
+    // Case 2: Via slackbot-proxy
+    if (slackbotProxyUri) {
+      const parsedProxyUri = new URL(slackbotProxyUri);
+
+      if (
+        (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') &&
+        parsedUrl.hostname === parsedProxyUri.hostname &&
+        parsedUrl.pathname === '/g2s/respond'
+      ) {
+        const slackResponseUrlParam =
+          parsedUrl.searchParams.get('response_url');
+        if (slackResponseUrlParam) {
+          // Recursively validate the response_url parameter
+          return isValidResponseUrl(slackResponseUrlParam); // No proxy URI for the inner check
+        }
+      }
+    }
+
+    return false;
+  } catch (error) {
+    // Invalid URL format
+    return false;
+  }
+}

Разница между файлами не показана из-за своего большого размера
+ 290 - 155
pnpm-lock.yaml


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