Jelajahi Sumber

Merge branch 'master' into feat/92028-darkthemes-snippet-color

cao 3 tahun lalu
induk
melakukan
44f556d3f8
95 mengubah file dengan 3135 tambahan dan 736 penghapusan
  1. 8 2
      .github/workflows/reusable-app-prod.yml
  2. 40 1
      CHANGELOG.md
  3. 1 12
      THIRD-PARTY-NOTICES.md
  4. 1 1
      lerna.json
  5. 1 1
      package.json
  6. 0 1
      packages/app/.eslintrc.js
  7. 1 0
      packages/app/config/ci/.env.local.for-auto-install-with-allowing-guest
  8. 3 3
      packages/app/config/webpack.common.js
  9. 2 2
      packages/app/docker/README.md
  10. 9 7
      packages/app/package.json
  11. 0 4
      packages/app/resource/Contributor.js
  12. 38 3
      packages/app/resource/locales/en_US/translation.json
  13. 38 3
      packages/app/resource/locales/ja_JP/translation.json
  14. 37 2
      packages/app/resource/locales/zh_CN/translation.json
  15. 3 4
      packages/app/src/client/app.jsx
  16. 3 1
      packages/app/src/client/services/ContextExtractor.tsx
  17. 1 22
      packages/app/src/client/services/PageContainer.js
  18. 1 1
      packages/app/src/client/util/interceptor/drawio-interceptor.js
  19. 16 16
      packages/app/src/client/util/markdown-it/emoji-mart-data.ts
  20. 4 7
      packages/app/src/client/util/markdown-it/emoji.js
  21. 13 7
      packages/app/src/client/util/markdown-it/toc-and-anchor.js
  22. 18 8
      packages/app/src/components/BookmarkButtons.tsx
  23. 11 3
      packages/app/src/components/DescendantsPageList.tsx
  24. 21 9
      packages/app/src/components/LikeButtons.tsx
  25. 8 9
      packages/app/src/components/Me/PasswordSettings.jsx
  26. 280 0
      packages/app/src/components/Page/FixPageGrantAlert.tsx
  27. 0 72
      packages/app/src/components/Page/NotFoundAlert.tsx
  28. 1 5
      packages/app/src/components/Page/RevisionRenderer.jsx
  29. 5 1
      packages/app/src/components/PageDeleteModal.tsx
  30. 1 1
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  31. 5 5
      packages/app/src/components/PageEditor/CommentMentionHelper.ts
  32. 4 1
      packages/app/src/components/PageEditor/EmojiPickerHelper.ts
  33. 1 1
      packages/app/src/components/PageEditor/MarkdownTableInterceptor.js
  34. 2 2
      packages/app/src/components/PageEditor/Preview.tsx
  35. 44 8
      packages/app/src/components/PrivateLegacyPages.tsx
  36. 11 2
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  37. 1 1
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  38. 1 1
      packages/app/src/components/Sidebar/Tag.tsx
  39. 16 6
      packages/app/src/components/SubscribeButton.tsx
  40. 6 8
      packages/app/src/components/TableOfContents.jsx
  41. 6 1
      packages/app/src/components/User/SeenUserInfo.tsx
  42. 1 0
      packages/app/src/interfaces/errors/v5-conversion-error.ts
  43. 20 0
      packages/app/src/interfaces/page-grant.ts
  44. 18 1
      packages/app/src/interfaces/page.ts
  45. 5 1
      packages/app/src/server/crowi/index.js
  46. 1 1
      packages/app/src/server/events/user.js
  47. 15 8
      packages/app/src/server/models/obsolete-page.js
  48. 122 261
      packages/app/src/server/models/page.ts
  49. 58 0
      packages/app/src/server/models/user-group-relation.js
  50. 16 2
      packages/app/src/server/models/user.js
  51. 5 2
      packages/app/src/server/routes/apiv3/forgot-password.js
  52. 168 2
      packages/app/src/server/routes/apiv3/page.js
  53. 23 16
      packages/app/src/server/routes/apiv3/pages.js
  54. 6 4
      packages/app/src/server/routes/apiv3/personal-setting.js
  55. 43 6
      packages/app/src/server/routes/apiv3/users.js
  56. 1 1
      packages/app/src/server/routes/attachment.js
  57. 1 1
      packages/app/src/server/routes/page.js
  58. 29 1
      packages/app/src/server/service/comment.ts
  59. 13 1
      packages/app/src/server/service/config-loader.ts
  60. 71 71
      packages/app/src/server/service/file-uploader/aws.ts
  61. 21 14
      packages/app/src/server/service/installer.ts
  62. 72 3
      packages/app/src/server/service/page-grant.ts
  63. 469 23
      packages/app/src/server/service/page.ts
  64. 1 1
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  65. 1 1
      packages/app/src/server/util/createGrowiPagesFromImports.js
  66. 7 2
      packages/app/src/server/util/middlewares.js
  67. 0 1
      packages/app/src/server/views/layout-growi/not_found.html
  68. 4 2
      packages/app/src/server/views/layout/layout.html
  69. 3 1
      packages/app/src/server/views/private-legacy-pages.html
  70. 3 1
      packages/app/src/server/views/search.html
  71. 2 0
      packages/app/src/server/views/widget/page_alerts.html
  72. 1 0
      packages/app/src/server/views/widget/page_content.html
  73. 4 0
      packages/app/src/stores/context.tsx
  74. 25 1
      packages/app/src/stores/page.tsx
  75. 2 2
      packages/app/src/styles/_layout.scss
  76. 3 3
      packages/app/src/styles/theme/_apply-colors-dark.scss
  77. 0 0
      packages/app/test/cypress/integration/10-install/install.spec.ts
  78. 0 1
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  79. 0 0
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  80. 81 0
      packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts
  81. 0 0
      packages/app/test/cypress/integration/30-search/search.spec.ts
  82. 0 0
      packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts
  83. 0 0
      packages/app/test/cypress/integration/50-switch-sidebar-mode/switching-sidebar-mode.spec.ts
  84. 0 0
      packages/app/test/cypress/integration/60-home/home.spec.ts
  85. 12 12
      packages/app/test/integration/models/v5.page.test.js
  86. 223 35
      packages/app/test/integration/service/v5.migration.test.js
  87. 1 1
      packages/codemirror-textlint/package.json
  88. 1 1
      packages/core/package.json
  89. 1 1
      packages/plugin-attachment-refs/package.json
  90. 1 1
      packages/plugin-lsx/package.json
  91. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  92. 1 1
      packages/slack/package.json
  93. 2 2
      packages/slackbot-proxy/package.json
  94. 1 1
      packages/ui/package.json
  95. 914 1
      yarn.lock

+ 8 - 2
.github/workflows/reusable-app-prod.yml

@@ -180,7 +180,7 @@ jobs:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2', '3', '4', '6']
+        spec-group: ['10', '20', '21', '30', '40', '60']
 
 
     services:
     services:
       mongodb:
       mongodb:
@@ -239,11 +239,17 @@ jobs:
         cat config/ci/.env.local.for-ci >> .env.production.local
         cat config/ci/.env.local.for-ci >> .env.production.local
 
 
     - name: Copy dotenv file for automatic installation
     - name: Copy dotenv file for automatic installation
-      if: ${{ matrix.spec-group != '1' }}
+      if: ${{ matrix.spec-group != '10' }}
       working-directory: ./packages/app
       working-directory: ./packages/app
       run: |
       run: |
         cat config/ci/.env.local.for-auto-install >> .env.production.local
         cat config/ci/.env.local.for-auto-install >> .env.production.local
 
 
+    - name: Copy dotenv file for automatic installation with allowing guest mode
+      if: ${{ matrix.spec-group == '21' }}
+      working-directory: ./packages/app
+      run: |
+        cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
+
     - name: Cypress Run
     - name: Cypress Run
       uses: cypress-io/github-action@v3
       uses: cypress-io/github-action@v3
       with:
       with:

+ 40 - 1
CHANGELOG.md

@@ -1,9 +1,48 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.5...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.7...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v5.0.7](https://github.com/weseek/growi/compare/v5.0.6...v5.0.7) - 2022-05-30
+
+### 💎 Features
+
+- feat: Set the min length of passwords by environment variable (#5899) @Shunm634-source
+- feat: API to find username (#5907) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Page is not rendered for guest (#5930) @yuki-takei
+- fix: Server error due to the canDeleteLogic method (#5927) @hakumizuki
+- fix: Show pagename on toastr when page deleted (#5772) @hirokei-camel
+- fix: Search result screen is broken under content 100% setting (#5917) @jam411
+
+## [v5.0.6](https://github.com/weseek/growi/compare/v5.0.5...v5.0.6) - 2022-05-27
+
+### 💎 Features
+
+- feat: Emoji - replace emojione to emojimart (#5668) @kaoritokashiki
+- feat: Show username suggestion for mention in comment (#5856) @mudana-grune
+- feat: Send in-app notification when containing username mention in comment  (#5906) @mudana-grune
+- feat: Customize menu in navbar for guest user (#5858) @yukendev
+- feat: Admin only page convert by path (#5902) @hakumizuki
+- feat: Fix grant alert (#5903) @hakumizuki
+
+### 🚀 Improvement
+
+- imprv: Automatic login after registration (#5860) @hiroki-hgs
+- imprv: Add tooltip to SubNavButtons (#5887) @miya
+- imprv: Mixin of argument-of-override-list-group-item-for-pagetree for dark theme (#5904) @shukmos
+- imprv: Move code to the appropriate place for fix browser auto-complete email wiith username (#5892) @Yohei-Shiina
+- imprv: Initial rendering when opening Custom Sidebar (#5880) @Kami-jo
+- imprv: Add contributors to staff credit (#5841) @hiroki-hgs
+
+### 🐛 Bug Fixes
+
+- fix: Can not toggle textlint function on v5.0.x (#5854) @kaoritokashiki
+- fix(google-oauth2): Automatically bind external accounts  does not work on v5.0.x (#5886) @kaoritokashiki
+
 ## [v5.0.5](https://github.com/weseek/growi/compare/v5.0.4...v5.0.5) - 2022-05-16
 ## [v5.0.5](https://github.com/weseek/growi/compare/v5.0.4...v5.0.5) - 2022-05-16
 
 
 ### 💎 Features
 ### 💎 Features

+ 1 - 12
THIRD-PARTY-NOTICES.md

@@ -16,8 +16,7 @@ https://github.com/weseek/growi.
 2. crowi/crowi (https://github.com/crowi/crowi)
 2. crowi/crowi (https://github.com/crowi/crowi)
 3. Microsoft/vscode (https://github.com/Microsoft/vscode)
 3. Microsoft/vscode (https://github.com/Microsoft/vscode)
 4. stephenhutchings/typicons.font (https://github.com/stephenhutchings/typicons.font)
 4. stephenhutchings/typicons.font (https://github.com/stephenhutchings/typicons.font)
-5. EmojiOne Version 3 (https://github.com/joypixels/emojione/tree/v3.1.1)
-6. Kuromoji.js (https://github.com/takuyaa/kuromoji.js)
+5. Kuromoji.js (https://github.com/takuyaa/kuromoji.js)
 
 
 
 
 License Notice for Apache License, Version 2.0 Derivative Works
 License Notice for Apache License, Version 2.0 Derivative Works
@@ -101,16 +100,6 @@ Copyright (c) 2018 Stephen Hutchings
 ```
 ```
 
 
 
 
-License Notice for EmojiOne
-------------------------
-
-https://creativecommons.org/licenses/by/4.0/
-
-```
-author: "EmojiOne <ryan@emojione.com> (http://emojione.com)"
-```
-
-
 License Notice for Kuromoji.js
 License Notice for Kuromoji.js
 ------------------------
 ------------------------
 
 

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
 {
   "npmClient": "yarn",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "useWorkspaces": true,
-  "version": "5.0.6-RC.0",
+  "version": "5.0.8-RC.0",
   "packages": [
   "packages": [
     "packages/*"
     "packages/*"
   ]
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "5.0.6-RC.0",
+  "version": "5.0.8-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",

+ 0 - 1
packages/app/.eslintrc.js

@@ -12,7 +12,6 @@ module.exports = {
   globals: {
   globals: {
     $: true,
     $: true,
     jquery: true,
     jquery: true,
-    emojione: true,
     hljs: true,
     hljs: true,
     ScrollPosStyler: true,
     ScrollPosStyler: true,
     window: true,
     window: true,

+ 1 - 0
packages/app/config/ci/.env.local.for-auto-install-with-allowing-guest

@@ -0,0 +1 @@
+AUTO_INSTALL_ALLOW_GUEST_MODE=true

+ 3 - 3
packages/app/config/webpack.common.js

@@ -2,14 +2,15 @@
  * @author: Yuki Takei <yuki@weseek.co.jp>
  * @author: Yuki Takei <yuki@weseek.co.jp>
  */
  */
 const path = require('path');
 const path = require('path');
+
+const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
 const webpack = require('webpack');
 const webpack = require('webpack');
 
 
 /*
 /*
   * Webpack Plugins
   * Webpack Plugins
   */
   */
 const WebpackAssetsManifest = require('webpack-assets-manifest');
 const WebpackAssetsManifest = require('webpack-assets-manifest');
-const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
-const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
 
 
 /*
 /*
   * Webpack configuration
   * Webpack configuration
@@ -60,7 +61,6 @@ module.exports = (options) => {
       // require("jquery") is external and available
       // require("jquery") is external and available
       //  on the global var jQuery
       //  on the global var jQuery
       jquery: 'jQuery',
       jquery: 'jQuery',
-      emojione: 'emojione',
       hljs: 'hljs',
       hljs: 'hljs',
       'dtrace-provider': 'dtrace-provider',
       'dtrace-provider': 'dtrace-provider',
     },
     },

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`5.0.5`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.5/docker/Dockerfile)
-* [`5.0.5-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.5/docker/Dockerfile)
+* [`5.0.7`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.7/docker/Dockerfile)
+* [`5.0.7-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.7/docker/Dockerfile)
 * [`4.5.15`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
 * [`4.5.15`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
 * [`4.5.15-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
 * [`4.5.15-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)

+ 9 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "5.0.6-RC.0",
+  "version": "5.0.8-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -57,16 +57,18 @@
     "string-width": "5.0.0 or above exports only ESM."
     "string-width": "5.0.0 or above exports only ESM."
   },
   },
   "dependencies": {
   "dependencies": {
+    "@aws-sdk/client-s3": "^3.58.0",
+    "@aws-sdk/s3-request-presigner": "^3.58.0",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.6-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.6-RC.0",
-    "@growi/plugin-lsx": "^5.0.6-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.6-RC.0",
-    "@growi/slack": "^5.0.6-RC.0",
+    "@growi/codemirror-textlint": "^5.0.8-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.8-RC.0",
+    "@growi/plugin-lsx": "^5.0.8-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.8-RC.0",
+    "@growi/slack": "^5.0.8-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +169,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.6-RC.0",
+    "@growi/ui": "^5.0.8-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",

+ 0 - 4
packages/app/resource/Contributor.js

@@ -24,7 +24,6 @@ const contributors = [
         members: [
         members: [
           { name: 'utsushiiro' },
           { name: 'utsushiiro' },
           { name: 'mayumorita' },
           { name: 'mayumorita' },
-          { name: 'TatsuyaIse' },
           { name: 'shinoka7' },
           { name: 'shinoka7' },
           { name: 'SeiyaTashiro' },
           { name: 'SeiyaTashiro' },
           { name: 'TsuyoshiSuzukief' },
           { name: 'TsuyoshiSuzukief' },
@@ -35,7 +34,6 @@ const contributors = [
           { name: 'kaishuu0123' },
           { name: 'kaishuu0123' },
           { name: 'kouki-o' },
           { name: 'kouki-o' },
           { name: 'Angola' },
           { name: 'Angola' },
-          { name: 'Yohei-Shiina' },
           { name: 'shukmos' },
           { name: 'shukmos' },
           { name: 'sooouh' },
           { name: 'sooouh' },
           { name: 'ryouhek' },
           { name: 'ryouhek' },
@@ -50,14 +48,12 @@ const contributors = [
           { name: 'makotoshiraishi' },
           { name: 'makotoshiraishi' },
           { name: 'yamagai' },
           { name: 'yamagai' },
           { name: 'stevenfukase' },
           { name: 'stevenfukase' },
-          { name: 'miya' },
           { name: 'kaho819' },
           { name: 'kaho819' },
           { name: 'yuto-oweseek' },
           { name: 'yuto-oweseek' },
           { name: 'maow89126' },
           { name: 'maow89126' },
           { name: 'kntowd' },
           { name: 'kntowd' },
           { name: 'yukendev' },
           { name: 'yukendev' },
           { name: 'asami-n' },
           { name: 'asami-n' },
-          { name: 'ryohi15' },
           { name: 'yoshiro-s' },
           { name: 'yoshiro-s' },
           { name: 'kuimac' },
           { name: 'kuimac' },
           { name: 'akira-sugiyama' },
           { name: 'akira-sugiyama' },

+ 38 - 3
packages/app/resource/locales/en_US/translation.json

@@ -15,8 +15,6 @@
   "Move/Rename": "Move/Rename",
   "Move/Rename": "Move/Rename",
   "Redirected": "Redirected",
   "Redirected": "Redirected",
   "Unlinked": "Unlinked",
   "Unlinked": "Unlinked",
-  "Like!": "Like!",
-  "Seen by": "Seen by",
   "Done": "Done",
   "Done": "Done",
   "Cancel": "Cancel",
   "Cancel": "Cancel",
   "Create": "Create",
   "Create": "Create",
@@ -214,7 +212,7 @@
     },
     },
     "form_help": {
     "form_help": {
       "email": "You must have email address which listed below to sign up to this wiki.",
       "email": "You must have email address which listed below to sign up to this wiki.",
-      "password": "Your password must be at least 8 characters long.",
+      "password": "Your password must be at least {{target}} characters long.",
       "user_id": "The URL of pages you create will contain your User ID. Your User ID can consist of letters, numbers, and some symbols."
       "user_id": "The URL of pages you create will contain your User ID. Your User ID can consist of letters, numbers, and some symbols."
     }
     }
   },
   },
@@ -439,6 +437,7 @@
     "recursively": "Delete pages under this path recursively.",
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
     "completely": "Delete completely instead of putting it into trash."
   },
   },
+  "deleted_page": "Moved to the trash",
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "renamed_pages": "{{path}} has been renamed",
   "renamed_pages": "{{path}} has been renamed",
@@ -664,6 +663,8 @@
     },
     },
     "by_path_modal": {
     "by_path_modal": {
       "title": "Convert to new v5 compatible format",
       "title": "Convert to new v5 compatible format",
+      "alert": "This operation cannot be undone, and pages that the user cannot view are also subject to processing.",
+      "checkbox_label": "Understood",
       "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",
       "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",
       "button_label": "Convert",
       "button_label": "Convert",
       "success": "Successfully requested conversion.",
       "success": "Successfully requested conversion.",
@@ -1075,5 +1076,39 @@
     "select_group": "Select group",
     "select_group": "Select group",
     "belonging_to_no_group": "Could not find the groups you belong to.",
     "belonging_to_no_group": "Could not find the groups you belong to.",
     "manage_user_groups": "Manage user groups"
     "manage_user_groups": "Manage user groups"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "The list of selectable permissions could not be found. Please modify the permissions on the parent page first and try again.",
+      "need_to_fix_grant": "The permissions associated with this page must be modified in order to use the functionality correctly. <br> Please select from the options below to make the change.",
+      "grant_label": {
+        "isForbidden": "Authority not allowed to view",
+        "currentPageGrantLabel": "Authorization for this page: ",
+        "parentPageGrantLabel": "Authority of parent page: ",
+        "docLink": "For more information on modifying permissions, please refer to <a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちらのリンク</a>"
+      },
+      "radio_btn": {
+        "restrected": "Only those who know the link",
+        "only_me": "only to oneself",
+        "grant_group": "Only specific groups"
+      },
+      "select_group_default_text": "Select Group",
+      "alert_message_select_group": "No group selected",
+      "btn_label": "Conversion",
+      "title": "Modify authority"
+    },
+    "alert": {
+      "description": "You need to modify the permission settings for this page.",
+      "btn_label": "Revision"
+    }
+  },
+  "tooltip": {
+    "like": "Like!",
+    "cancel_like": "Cancel Like",
+    "bookmark": "Bookmark",
+    "cancel_bookmark": "Cancel Bookmark",
+    "receive_notifications": "Receive Notifications",
+    "stop_notification": "Stop Notification",
+    "footprints": "Footprints"
   }
   }
 }
 }

+ 38 - 3
packages/app/resource/locales/ja_JP/translation.json

@@ -15,8 +15,6 @@
   "Move/Rename": "移動/名前変更",
   "Move/Rename": "移動/名前変更",
   "Redirected": "リダイレクトされました",
   "Redirected": "リダイレクトされました",
   "Unlinked": "リダイレクト削除",
   "Unlinked": "リダイレクト削除",
-  "Like!": "いいね!",
-  "Seen by": "見た人",
   "Done": "完了",
   "Done": "完了",
   "Cancel": "キャンセル",
   "Cancel": "キャンセル",
   "Create": "作成",
   "Create": "作成",
@@ -216,7 +214,7 @@
     },
     },
     "form_help": {
     "form_help": {
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
-      "password": "パスワードには、8文字以上の半角英数字または記号等を設定してください。",
+      "password": "パスワードには、{{target}}文字以上の半角英数字または記号等を設定してください。",
       "user_id": "ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。"
       "user_id": "ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。"
     }
     }
   },
   },
@@ -439,6 +437,7 @@
     "recursively": "配下のページも削除します",
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
   },
+  "deleted_page": "ゴミ箱に入れました",
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "renamed_pages": "{{path}} を移動/名前変更しました",
   "renamed_pages": "{{path}} を移動/名前変更しました",
@@ -664,6 +663,8 @@
     },
     },
     "by_path_modal": {
     "by_path_modal": {
       "title": "新しい v5 互換形式への変換",
       "title": "新しい v5 互換形式への変換",
+      "alert": "この操作は取り消すことができず、ユーザーが閲覧できないページも処理の対象になります。",
+      "checkbox_label": "理解しました",
       "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",
       "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",
       "button_label": "変換",
       "button_label": "変換",
       "success": "正常に変換を開始しました",
       "success": "正常に変換を開始しました",
@@ -1068,5 +1069,39 @@
     "select_group": "グループを選ぶ",
     "select_group": "グループを選ぶ",
     "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
     "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
     "manage_user_groups": "グループ管理"
     "manage_user_groups": "グループ管理"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "選択可能な権限のリストが見つかりませんでした。まず親ページの権限を修正したのちに再試行してください。",
+      "need_to_fix_grant": "正しく機能を使用するためにはこのページに紐づく権限を修正する必要があります。 <br> 下記の選択肢から選んで変更してください。",
+      "grant_label": {
+        "isForbidden": "権限の閲覧が許可されていません",
+        "currentPageGrantLabel": "このページの権限: ",
+        "parentPageGrantLabel": "親のページの権限: ",
+        "docLink": "権限の修正についての詳細は<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちらのリンク</a>を参照してください"
+      },
+      "radio_btn": {
+        "restrected": "リンクを知っている人のみ",
+        "only_me": "自分のみ",
+        "grant_group": "特定グループのみ"
+      },
+      "select_group_default_text": "グループを選択",
+      "alert_message_select_group": "グループが選択されていません",
+      "btn_label": "変換",
+      "title": "権限を修正"
+    },
+    "alert": {
+      "description": "このページの権限設定を修正する必要があります。",
+      "btn_label": "修正"
+    }
+  },
+  "tooltip": {
+    "like": "いいね!",
+    "cancel_like": "いいねを取り消す",
+    "bookmark": "ブックマーク",
+    "cancel_bookmark": "ブックマークを取り消す",
+    "receive_notifications": "通知を受け取る",
+    "stop_notification": "通知を止める",
+    "footprints": "足跡"
   }
   }
 }
 }

+ 37 - 2
packages/app/resource/locales/zh_CN/translation.json

@@ -16,8 +16,6 @@
 	"Move/Rename": "移动/重命名",
 	"Move/Rename": "移动/重命名",
 	"Redirected": "重定向",
 	"Redirected": "重定向",
 	"Unlinked": "Unlinked",
 	"Unlinked": "Unlinked",
-	"Like!": "Like!",
-	"Seen by": "Seen by",
   "Done": "Done",
   "Done": "Done",
   "Cancel": "取消",
   "Cancel": "取消",
 	"Create": "创建",
 	"Create": "创建",
@@ -418,6 +416,7 @@
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"completely": "Delete completely instead of putting it into trash."
 		"completely": "Delete completely instead of putting it into trash."
   },
   },
+  "deleted_page": "移到了垃圾箱。",
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "renamed_pages": "移动/重命名 {{path}}",
   "renamed_pages": "移动/重命名 {{path}}",
@@ -951,6 +950,8 @@
     },
     },
     "by_path_modal": {
     "by_path_modal": {
       "title": "转换为新的v5兼容格式",
       "title": "转换为新的v5兼容格式",
+      "alert": "这一操作不能被撤销,用户不能查看的页面也要进行处理。",
+      "checkbox_label": "明白了",
       "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",
       "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",
       "button_label": "转换",
       "button_label": "转换",
       "success": "成功地请求转换。",
       "success": "成功地请求转换。",
@@ -1078,5 +1079,39 @@
     "select_group": "选择组别",
     "select_group": "选择组别",
     "belonging_to_no_group": "无法找到你所属的团体。",
     "belonging_to_no_group": "无法找到你所属的团体。",
     "manage_user_groups": "管理用户组"
     "manage_user_groups": "管理用户组"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "无法找到可选择的权限列表。 请先修改父页的权限,然后再试一次。",
+      "need_to_fix_grant": "为了正确使用该功能,需要修改与该页面相关的权限。 <br> 请从以下选项中选择进行更改。",
+      "grant_label": {
+        "isForbidden": "无权查看的机构",
+        "currentPageGrantLabel": "本页的权限: ",
+        "parentPageGrantLabel": "父页的权限: ",
+        "docLink": "关于修改授权的更多信息,请参见此<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>此链接</a>"
+      },
+      "radio_btn": {
+        "restrected": "只有那些知道链接的人",
+        "only_me": "只对自己说",
+        "grant_group": "仅限特定群体"
+      },
+      "select_group_default_text": "选择组别",
+      "alert_message_select_group": "未选择组别",
+      "btn_label": "蜕变",
+      "title": "修改后的授权书"
+    },
+    "alert": {
+      "description": "本页的授权设置需要修改。",
+      "btn_label": "修改"
+    }
+  },
+  "tooltip": {
+    "like": "很好!",
+    "cancel_like": "取消喜欢",
+    "bookmark": "书签",
+    "cancel_bookmark": "取消书签",
+    "receive_notifications": "接收通知",
+    "stop_notification": "停止通知",
+    "footprints": "脚印"
   }
   }
 }
 }

+ 3 - 4
packages/app/src/client/app.jsx

@@ -33,7 +33,7 @@ import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationS
 import NotFoundPage from '../components/NotFoundPage';
 import NotFoundPage from '../components/NotFoundPage';
 import Page from '../components/Page';
 import Page from '../components/Page';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
-import NotFoundAlert from '../components/Page/NotFoundAlert';
+import FixPageGrantAlert from '../components/Page/FixPageGrantAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
@@ -98,6 +98,8 @@ Object.assign(componentMappings, {
 
 
   'trash-page-alert': <TrashPageAlert />,
   'trash-page-alert': <TrashPageAlert />,
 
 
+  'fix-page-grant-alert': <FixPageGrantAlert />,
+
   'trash-page-list-container': <TrashPageList />,
   'trash-page-list-container': <TrashPageList />,
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
@@ -114,9 +116,6 @@ Object.assign(componentMappings, {
 
 
   'share-link-alert': <ShareLinkAlert />,
   'share-link-alert': <ShareLinkAlert />,
   'redirected-alert': <RedirectedAlert />,
   'redirected-alert': <RedirectedAlert />,
-  'not-found-alert': <NotFoundAlert
-    isGuestUserMode={appContainer.isGuestUser}
-  />,
 });
 });
 
 
 // additional definitions if data exists
 // additional definitions if data exists

+ 3 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -16,7 +16,7 @@ import {
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
+  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
   useDefaultIndentSize, useIsIndentSizeForced,
   useDefaultIndentSize, useIsIndentSizeForced,
 } from '../../stores/context';
 } from '../../stores/context';
@@ -71,6 +71,7 @@ const ContextExtractorOnce: FC = () => {
   const isForbidden = forbiddenContent != null;
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
+  const hasParent = JSON.parse(mainContent?.getAttribute('data-has-parent') || jsonNull);
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
   const shareLinksNumber = mainContent?.getAttribute('data-share-links-number');
   const shareLinksNumber = mainContent?.getAttribute('data-share-links-number');
   const shareLinkId = JSON.parse(mainContent?.getAttribute('data-share-link-id') || jsonNull);
   const shareLinkId = JSON.parse(mainContent?.getAttribute('data-share-link-id') || jsonNull);
@@ -144,6 +145,7 @@ const ContextExtractorOnce: FC = () => {
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useIsNotFoundPermalink(isNotFoundPermalink);
   useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
   useIsSearchPage(isSearchPage);
+  useHasParent(hasParent);
 
 
   // Navigation
   // Navigation
   usePreferDrawerModeByUser();
   usePreferDrawerModeByUser();

+ 1 - 22
packages/app/src/client/services/PageContainer.js

@@ -17,7 +17,6 @@ import {
 import {
 import {
   DrawioInterceptor,
   DrawioInterceptor,
 } from '../util/interceptor/drawio-interceptor';
 } from '../util/interceptor/drawio-interceptor';
-import { emojiMartData } from '../util/markdown-it/emoji-mart-data';
 
 
 const { isTrashPage } = pagePathUtils;
 const { isTrashPage } = pagePathUtils;
 
 
@@ -197,30 +196,10 @@ export default class PageContainer extends Container {
 
 
   async setTocHtml(tocHtml) {
   async setTocHtml(tocHtml) {
     if (this.state.tocHtml !== tocHtml) {
     if (this.state.tocHtml !== tocHtml) {
-      const tocHtmlWithEmoji = await this.colonsToEmoji(tocHtml);
-      this.setState({ tocHtml: tocHtmlWithEmoji });
+      this.setState({ tocHtml });
     }
     }
   }
   }
 
 
-  /**
-   *
-   * @param {*} html TOC html string
-   * @returns TOC html with emoji (emoji-mart) in URL
-   */
-  async colonsToEmoji(html) {
-    // Emoji colons matching
-    const colons = ':[a-zA-Z0-9-_+]+:';
-    // Emoji with skin tone matching
-    const skin = ':skin-tone-[2-6]:';
-    const colonsRegex = new RegExp(`(${colons}${skin}|${colons})`, 'g');
-    const emojiData = await emojiMartData();
-    return html.replace(colonsRegex, (index, match) => {
-      const emojiName = match.slice(1, -1);
-      return emojiData[emojiName];
-    });
-
-  }
-
   /**
   /**
    * save success handler
    * save success handler
    * @param {object} page Page instance
    * @param {object} page Page instance

+ 1 - 1
packages/app/src/client/util/interceptor/drawio-interceptor.js

@@ -104,7 +104,7 @@ export class DrawioInterceptor extends BasicInterceptor {
    */
    */
   drawioPostRender(contextName, context) {
   drawioPostRender(contextName, context) {
     const isPreview = (contextName === 'postRenderPreviewHtml');
     const isPreview = (contextName === 'postRenderPreviewHtml');
-    const renderDrawioInRealtime = context.editorSettings?.renderDrawioInRealtime;
+    const renderDrawioInRealtime = context.renderDrawioInRealtime;
 
 
     Object.keys(context.DrawioMap).forEach((domId) => {
     Object.keys(context.DrawioMap).forEach((domId) => {
       const elem = document.getElementById(domId);
       const elem = document.getElementById(domId);

+ 16 - 16
packages/app/src/client/util/markdown-it/emoji-mart-data.ts

@@ -3,24 +3,28 @@ import data from 'emoji-mart/data/apple.json';
 
 
 const DEFAULT_EMOJI_SIZE = 24;
 const DEFAULT_EMOJI_SIZE = 24;
 
 
+
+type EmojiMap = {
+  [key: string]: string,
+};
+
 /**
 /**
  *
  *
  * Get native emoji with skin tone
  * Get native emoji with skin tone
- * @param emoji Emoji object
  * @param skin number
  * @param skin number
  * @returns emoji data with skin tone
  * @returns emoji data with skin tone
  */
  */
-const getEmojiSkinTone = async(emoji) => {
+const getEmojiSkinTone = (emojiName: string): EmojiMap => {
   const emojiData = {};
   const emojiData = {};
   [...Array(6).keys()].forEach((index) => {
   [...Array(6).keys()].forEach((index) => {
     if (index > 0) {
     if (index > 0) {
       const elem = Emoji({
       const elem = Emoji({
-        emoji,
+        emoji: emojiName,
         skin: index + 1,
         skin: index + 1,
         size: DEFAULT_EMOJI_SIZE,
         size: DEFAULT_EMOJI_SIZE,
       });
       });
       if (elem) {
       if (elem) {
-        emojiData[`${emoji}::skin-tone-${index + 1}`] = elem.props['aria-label'].split(',')[0];
+        emojiData[`${emojiName}::skin-tone-${index + 1}`] = elem.props['aria-label'].split(',')[0];
       }
       }
     }
     }
   });
   });
@@ -29,27 +33,29 @@ const getEmojiSkinTone = async(emoji) => {
 
 
 /**
 /**
  * Get native emoji from emoji array
  * Get native emoji from emoji array
- * @param emojis array of emoji
  * @returns emoji data
  * @returns emoji data
  */
  */
 
 
-const getNativeEmoji = async(emojis) => {
+const getNativeEmoji = (): EmojiMap => {
   const emojiData = {};
   const emojiData = {};
-  emojis.forEach(async(emoji) => {
+  Object.entries(data.emojis).forEach((emoji) => {
     const emojiName = emoji[0];
     const emojiName = emoji[0];
-    const hasSkinVariation = emoji[1].skin_variations;
+    const hasSkinVariation = 'skin_variations' in emoji[1];
+
     const elem = Emoji({
     const elem = Emoji({
       emoji: emojiName,
       emoji: emojiName,
       size: DEFAULT_EMOJI_SIZE,
       size: DEFAULT_EMOJI_SIZE,
     });
     });
+
     if (elem != null) {
     if (elem != null) {
       emojiData[emojiName] = elem.props['aria-label'].split(',')[0];
       emojiData[emojiName] = elem.props['aria-label'].split(',')[0];
       if (hasSkinVariation) {
       if (hasSkinVariation) {
-        const emojiWithSkinTone = await getEmojiSkinTone(emojiName);
+        const emojiWithSkinTone = getEmojiSkinTone(emojiName);
         Object.assign(emojiData, emojiWithSkinTone);
         Object.assign(emojiData, emojiWithSkinTone);
       }
       }
     }
     }
   });
   });
+
   return emojiData;
   return emojiData;
 };
 };
 
 
@@ -57,10 +63,4 @@ const getNativeEmoji = async(emojis) => {
  * Get native emoji mart data
  * Get native emoji mart data
  * @returns native emoji mart data
  * @returns native emoji mart data
  */
  */
-
-export const emojiMartData = () => {
-  const emojis = Object.entries(data.emojis).map((emoji) => {
-    return emoji;
-  });
-  return getNativeEmoji(emojis);
-};
+export const emojiMartData = getNativeEmoji();

+ 4 - 7
packages/app/src/client/util/markdown-it/emoji.js

@@ -1,15 +1,12 @@
+import markdownItEmojiMart from 'markdown-it-emoji-mart';
+
 import { emojiMartData } from './emoji-mart-data';
 import { emojiMartData } from './emoji-mart-data';
 
 
-export default class EmojiConfigurer {
 
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
+export default class EmojiConfigurer {
 
 
   configure(md) {
   configure(md) {
-    emojiMartData().then((data) => {
-      md.use(require('markdown-it-emoji-mart'), { defs: data });
-    });
+    md.use(markdownItEmojiMart, { defs: emojiMartData });
   }
   }
 
 
 }
 }

+ 13 - 7
packages/app/src/client/util/markdown-it/toc-and-anchor.js

@@ -1,3 +1,8 @@
+import markdownItEmojiMart from 'markdown-it-emoji-mart';
+import markdownItToc from 'markdown-it-toc-and-anchor-with-slugid';
+
+import { emojiMartData } from './emoji-mart-data';
+
 export default class TocAndAnchorConfigurer {
 export default class TocAndAnchorConfigurer {
 
 
   constructor(crowi, setHtml) {
   constructor(crowi, setHtml) {
@@ -6,13 +11,14 @@ export default class TocAndAnchorConfigurer {
   }
   }
 
 
   configure(md) {
   configure(md) {
-    md.use(require('markdown-it-toc-and-anchor-with-slugid').default, {
-      tocLastLevel: 3,
-      anchorLinkBefore: false,
-      anchorLinkSymbol: '',
-      anchorLinkSymbolClassName: 'icon-link',
-      anchorClassName: 'revision-head-link',
-    });
+    md.use(markdownItEmojiMart, { defs: emojiMartData })
+      .use(markdownItToc, {
+        tocLastLevel: 3,
+        anchorLinkBefore: false,
+        anchorLinkSymbol: '',
+        anchorLinkSymbolClassName: 'icon-link',
+        anchorClassName: 'revision-head-link',
+      });
 
 
     // set toc render function
     // set toc render function
     if (this.setHtml != null) {
     if (this.setHtml != null) {

+ 18 - 8
packages/app/src/components/BookmarkButtons.tsx

@@ -1,12 +1,13 @@
-import React, { FC, useState } from 'react';
+import React, { FC, useState, useCallback } from 'react';
 
 
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+
+import { useIsGuestUser } from '~/stores/context';
 
 
 import { IUser } from '../interfaces/user';
 import { IUser } from '../interfaces/user';
 
 
 import UserPictureList from './User/UserPictureList';
 import UserPictureList from './User/UserPictureList';
-import { useIsGuestUser } from '~/stores/context';
 
 
 interface Props {
 interface Props {
   bookmarkCount?: number
   bookmarkCount?: number
@@ -37,6 +38,17 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
     }
     }
   };
   };
 
 
+  const getTooltipMessage = useCallback(() => {
+    if (isGuestUser) {
+      return 'Not available for guest';
+    }
+
+    if (isBookmarked) {
+      return 'tooltip.cancel_bookmark';
+    }
+    return 'tooltip.bookmark';
+  }, [isGuestUser, isBookmarked]);
+
   return (
   return (
     <div className="btn-group" role="group" aria-label="Bookmark buttons">
     <div className="btn-group" role="group" aria-label="Bookmark buttons">
       <button
       <button
@@ -49,11 +61,9 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
       </button>
       </button>
 
 
-      {isGuestUser && (
-        <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      )}
+      <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
+        {t(getTooltipMessage())}
+      </UncontrolledTooltip>
 
 
       { !hideTotalNumber && (
       { !hideTotalNumber && (
         <>
         <>

+ 11 - 3
packages/app/src/components/DescendantsPageList.tsx

@@ -1,5 +1,7 @@
 import React, { useCallback, useState } from 'react';
 import React, { useCallback, useState } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+
 import { toastSuccess } from '~/client/util/apiNotification';
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
 import {
   IDataWithMeta,
   IDataWithMeta,
@@ -9,13 +11,12 @@ import {
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSharedUser, useIsTrashPage } from '~/stores/context';
 import { useIsGuestUser, useIsSharedUser, useIsTrashPage } from '~/stores/context';
-
 import {
 import {
   useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList, useDescendantsPageListForCurrentPathTermManager,
   useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList, useDescendantsPageListForCurrentPathTermManager,
 } from '~/stores/page';
 } from '~/stores/page';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { usePageTreeTermManager } from '~/stores/page-listing';
-import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
 
 
+import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
 import PageList from './PageList/PageList';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 
 
@@ -61,7 +62,14 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   }
   }
 
 
   const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
   const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
-    toastSuccess(args[2] ? t('deleted_pages_completely') : t('deleted_pages'));
+    const path = args[0];
+    const isCompletely = args[2];
+    if (path == null || isCompletely == null) {
+      toastSuccess(t('deleted_page'));
+    }
+    else {
+      toastSuccess(t('deleted_pages_completely', { path }));
+    }
 
 
     advancePt();
     advancePt();
 
 

+ 21 - 9
packages/app/src/components/LikeButtons.tsx

@@ -1,13 +1,15 @@
-import React, { FC, useState } from 'react';
+import React, { FC, useState, useCallback } from 'react';
 
 
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import UserPictureList from './User/UserPictureList';
-import { withUnstatedContainers } from './UnstatedUtils';
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+
 import { IUser } from '../interfaces/user';
 import { IUser } from '../interfaces/user';
 
 
+import { withUnstatedContainers } from './UnstatedUtils';
+import UserPictureList from './User/UserPictureList';
+
 type LikeButtonsProps = {
 type LikeButtonsProps = {
 
 
   hideTotalNumber?: boolean,
   hideTotalNumber?: boolean,
@@ -32,6 +34,17 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
     hideTotalNumber, isGuestUser, isLiked, sumOfLikers, onLikeClicked,
     hideTotalNumber, isGuestUser, isLiked, sumOfLikers, onLikeClicked,
   } = props;
   } = props;
 
 
+  const getTooltipMessage = useCallback(() => {
+    if (isGuestUser) {
+      return 'Not available for guest';
+    }
+
+    if (isLiked) {
+      return 'tooltip.cancel_like';
+    }
+    return 'tooltip.like';
+  }, [isGuestUser, isLiked]);
+
   return (
   return (
     <div className="btn-group" role="group" aria-label="Like buttons">
     <div className="btn-group" role="group" aria-label="Like buttons">
       <button
       <button
@@ -43,11 +56,10 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
       >
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
       </button>
       </button>
-      { isGuestUser && (
-        <UncontrolledTooltip placement="top" target="like-button" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      )}
+
+      <UncontrolledTooltip placement="top" target="like-button" fade={false}>
+        {t(getTooltipMessage())}
+      </UncontrolledTooltip>
 
 
       { !hideTotalNumber && (
       { !hideTotalNumber && (
         <>
         <>

+ 8 - 9
packages/app/src/components/Me/PasswordSettings.jsx

@@ -10,7 +10,6 @@ import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
-
 class PasswordSettings extends React.Component {
 class PasswordSettings extends React.Component {
 
 
   constructor() {
   constructor() {
@@ -22,6 +21,7 @@ class PasswordSettings extends React.Component {
       newPassword: '',
       newPassword: '',
       newPasswordConfirm: '',
       newPasswordConfirm: '',
       isPasswordSet: false,
       isPasswordSet: false,
+      minPasswordLength: null,
     };
     };
 
 
     this.onClickSubmit = this.onClickSubmit.bind(this);
     this.onClickSubmit = this.onClickSubmit.bind(this);
@@ -32,8 +32,8 @@ class PasswordSettings extends React.Component {
   async componentDidMount() {
   async componentDidMount() {
     try {
     try {
       const res = await apiv3Get('/personal-setting/is-password-set');
       const res = await apiv3Get('/personal-setting/is-password-set');
-      const { isPasswordSet } = res.data;
-      this.setState({ isPasswordSet });
+      const { isPasswordSet, minPasswordLength } = res.data;
+      this.setState({ isPasswordSet, minPasswordLength });
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -74,9 +74,8 @@ class PasswordSettings extends React.Component {
 
 
   render() {
   render() {
     const { t } = this.props;
     const { t } = this.props;
-    const { newPassword, newPasswordConfirm } = this.state;
+    const { newPassword, newPasswordConfirm, minPasswordLength } = this.state;
     const isIncorrectConfirmPassword = (newPassword !== newPasswordConfirm);
     const isIncorrectConfirmPassword = (newPassword !== newPasswordConfirm);
-
     if (this.state.retrieveError != null) {
     if (this.state.retrieveError != null) {
       throw new Error(this.state.retrieveError.message);
       throw new Error(this.state.retrieveError.message);
     }
     }
@@ -95,9 +94,6 @@ class PasswordSettings extends React.Component {
           <div className="row mb-3">
           <div className="row mb-3">
             <label htmlFor="oldPassword" className="col-md-3 text-md-right">{ t('personal_settings.current_password') }</label>
             <label htmlFor="oldPassword" className="col-md-3 text-md-right">{ t('personal_settings.current_password') }</label>
             <div className="col-md-5">
             <div className="col-md-5">
-              {/* to prevent autocomplete username into userForm[email] in BasicInfoSettings component */}
-              {/* https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion */}
-              <input type="password" autoComplete="new-password" style={{ display: 'none' }} />
               <input
               <input
                 className="form-control"
                 className="form-control"
                 type="password"
                 type="password"
@@ -111,6 +107,9 @@ class PasswordSettings extends React.Component {
         <div className="row mb-3">
         <div className="row mb-3">
           <label htmlFor="newPassword" className="col-md-3 text-md-right">{t('personal_settings.new_password') }</label>
           <label htmlFor="newPassword" className="col-md-3 text-md-right">{t('personal_settings.new_password') }</label>
           <div className="col-md-5">
           <div className="col-md-5">
+            {/* to prevent autocomplete username into userForm[email] in BasicInfoSettings component */}
+            {/* https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion */}
+            <input type="password" autoComplete="new-password" style={{ display: 'none' }} />
             <input
             <input
               className="form-control"
               className="form-control"
               type="password"
               type="password"
@@ -131,7 +130,7 @@ class PasswordSettings extends React.Component {
               onChange={(e) => { this.onChangeNewPasswordConfirm(e.target.value) }}
               onChange={(e) => { this.onChangeNewPasswordConfirm(e.target.value) }}
             />
             />
 
 
-            <p className="form-text text-muted">{t('page_register.form_help.password') }</p>
+            <p className="form-text text-muted">{t('page_register.form_help.password', { target: minPasswordLength }) }</p>
           </div>
           </div>
         </div>
         </div>
 
 

+ 280 - 0
packages/app/src/components/Page/FixPageGrantAlert.tsx

@@ -0,0 +1,280 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { PageGrant, IPageGrantData } from '~/interfaces/page';
+import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { useCurrentPageId, useCurrentUser, useHasParent } from '~/stores/context';
+import { useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
+
+type ModalProps = {
+  isOpen: boolean
+  pageId: string
+  dataApplicableGrant: IRecordApplicableGrant
+  currentAndParentPageGrantData: IResIsGrantNormalizedGrantData
+  close(): void
+}
+
+const FixPageGrantModal = (props: ModalProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    isOpen, pageId, dataApplicableGrant, currentAndParentPageGrantData, close,
+  } = props;
+
+  const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
+  const [selectedGroup, setSelectedGroup] = useState<{_id: string, name: string} | undefined>(undefined); // TODO: Typescriptize model
+
+  // Alert message state
+  const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
+
+  const applicableGroups = dataApplicableGrant[PageGrant.GRANT_USER_GROUP]?.applicableGroups;
+
+  // Reset state when opened
+  useEffect(() => {
+    if (isOpen) {
+      setSelectedGrant(PageGrant.GRANT_RESTRICTED);
+      setSelectedGroup(undefined);
+      setShowModalAlert(false);
+    }
+  }, [isOpen]);
+
+  const submit = async() => {
+    // Validate input values
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
+      setShowModalAlert(true);
+      return;
+    }
+
+    close();
+
+    try {
+      await apiv3Put(`/page/${pageId}/grant`, {
+        grant: selectedGrant,
+        grantedGroup: selectedGroup?._id,
+      });
+
+      toastSuccess(t('Successfully updated'));
+    }
+    catch (err) {
+      toastError(t('Failed to update'));
+    }
+  };
+
+  const getGrantLabel = useCallback((isForbidden: boolean, grantData?: IPageGrantData): string => {
+
+    if (isForbidden) {
+      return t('fix_page_grant.modal.grant_label.isForbidden');
+    }
+
+    if (grantData == null) {
+      return t('fix_page_grant.modal.grant_label.isForbidden');
+    }
+
+    if (grantData.grant === 4) {
+      return t('fix_page_grant.modal.radio_btn.only_me');
+    }
+
+    if (grantData.grant === 5) {
+      if (grantData.grantedGroup == null) {
+        return t('fix_page_grant.modal.grant_label.isForbidden');
+      }
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
+    }
+
+    throw Error('cannnot get grant label'); // this error can't be throwed
+  }, [t]);
+
+  const renderGrantDataLabel = useCallback(() => {
+    const { isForbidden, currentPageGrant, parentPageGrant } = currentAndParentPageGrantData;
+
+    const currentGrantLabel = getGrantLabel(false, currentPageGrant);
+    const parentGrantLabel = getGrantLabel(isForbidden, parentPageGrant);
+
+    return (
+      <>
+        <p className="mt-3">{ t('fix_page_grant.modal.grant_label.parentPageGrantLabel') + parentGrantLabel }</p>
+        <p>{ t('fix_page_grant.modal.grant_label.currentPageGrantLabel') + currentGrantLabel }</p>
+        {/* eslint-disable-next-line react/no-danger */}
+        <p dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.grant_label.docLink') }} />
+      </>
+    );
+  }, [t, currentAndParentPageGrantData, getGrantLabel]);
+
+  const renderModalBodyAndFooter = () => {
+    const isGrantAvailable = Object.keys(dataApplicableGrant || {}).length > 0;
+
+    if (!isGrantAvailable) {
+      return (
+        <p className="m-5">
+          { t('fix_page_grant.modal.no_grant_available') }
+        </p>
+      );
+    }
+
+    return (
+      <>
+        <ModalBody>
+          <div className="form-group">
+            {/* eslint-disable-next-line react/no-danger */}
+            <p className="mb-2" dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.need_to_fix_grant') }} />
+
+            {/* grant data label */}
+            {renderGrantDataLabel()}
+
+            <div className="ml-2">
+              <div className="custom-control custom-radio mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantRestricted"
+                  id="grantRestricted"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_RESTRICTED in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_RESTRICTED}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_RESTRICTED)}
+                />
+                <label className="custom-control-label" htmlFor="grantRestricted">
+                  { t('fix_page_grant.modal.radio_btn.restrected') }
+                </label>
+              </div>
+              <div className="custom-control custom-radio mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantUser"
+                  id="grantUser"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_OWNER in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_OWNER}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_OWNER)}
+                />
+                <label className="custom-control-label" htmlFor="grantUser">
+                  { t('fix_page_grant.modal.radio_btn.only_me') }
+                </label>
+              </div>
+              <div className="custom-control custom-radio d-flex mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantUserGroup"
+                  id="grantUserGroup"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_USER_GROUP in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_USER_GROUP}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_USER_GROUP)}
+                />
+                <label className="custom-control-label" htmlFor="grantUserGroup">
+                  { t('fix_page_grant.modal.radio_btn.grant_group') }
+                </label>
+                <div className="dropdown ml-2">
+                  <button
+                    type="button"
+                    className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                    data-toggle="dropdown"
+                    disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
+                  >
+                    <span className="float-left ml-2">
+                      {
+                        selectedGroup == null
+                          ? t('fix_page_grant.modal.select_group_default_text')
+                          : selectedGroup.name
+                      }
+                    </span>
+                  </button>
+                  <div className="dropdown-menu">
+                    {
+                      applicableGroups != null && applicableGroups.map(g => (
+                        <button
+                          className="dropdown-item"
+                          type="button"
+                          onClick={() => setSelectedGroup(g)}
+                        >
+                          {g.name}
+                        </button>
+                      ))
+                    }
+                  </div>
+                </div>
+              </div>
+              {
+                shouldShowModalAlert && (
+                  <p className="alert alert-warning">
+                    {t('fix_page_grant.modal.alert_message')}
+                  </p>
+                )
+              }
+            </div>
+          </div>
+        </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-primary" onClick={submit}>
+            { t('fix_page_grant.modal.btn_label') }
+          </button>
+        </ModalFooter>
+      </>
+    );
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpen} toggle={close} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        { t('fix_page_grant.modal.title') }
+      </ModalHeader>
+      {renderModalBodyAndFooter()}
+    </Modal>
+  );
+};
+
+const FixPageGrantAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: currentUser } = useCurrentUser();
+  const { data: pageId } = useCurrentPageId();
+  const { data: hasParent } = useHasParent();
+
+  const [isOpen, setOpen] = useState<boolean>(false);
+
+  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(currentUser != null ? pageId : null);
+  const { data: dataApplicableGrant } = useSWRxApplicableGrant(currentUser != null ? pageId : null);
+
+  // Dependencies
+  if (!hasParent) {
+    return <></>;
+  }
+  if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) {
+    return <></>;
+  }
+
+  return (
+    <>
+      <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
+        <div className="flex-grow-1 d-flex align-items-center">
+          <i className="icon-fw icon-exclamation ml-1" aria-hidden="true" />
+          {t('fix_page_grant.alert.description')}
+        </div>
+        <div className="d-flex align-items-end align-items-lg-center">
+          <button type="button" className="btn btn-info btn-sm rounded-pill px-3" onClick={() => setOpen(true)}>
+            {t('fix_page_grant.alert.btn_label')}
+          </button>
+        </div>
+      </div>
+
+      {
+        pageId != null && dataApplicableGrant != null && (
+          <FixPageGrantModal
+            isOpen={isOpen}
+            pageId={pageId}
+            dataApplicableGrant={dataApplicableGrant}
+            currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
+            close={() => setOpen(false)}
+          />
+        )
+      }
+    </>
+  );
+};
+
+export default FixPageGrantAlert;

+ 0 - 72
packages/app/src/components/Page/NotFoundAlert.tsx

@@ -1,72 +0,0 @@
-import React, { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-import { useIsNotFoundPermalink } from '~/stores/context';
-
-import { EditorMode, useEditorMode } from '~/stores/ui';
-
-
-type Props = {
-  isGuestUserMode?: boolean,
-}
-
-const NotFoundAlert = (props: Props): JSX.Element => {
-  const { t } = useTranslation();
-  const { isGuestUserMode } = props;
-
-  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
-  const { data: isNotFoundPermalink } = useIsNotFoundPermalink(); // TODO: Remove this when renaming on editor is implemented
-
-  const isEditorMode = editorMode !== EditorMode.View;
-
-  const clickHandler = useCallback(() => {
-    // check guest user,
-    // disabled of button cannot be used for using tooltip.
-    if (isGuestUserMode) {
-      return;
-    }
-
-    mutateEditorMode(EditorMode.Editor);
-
-  }, [isGuestUserMode, mutateEditorMode]);
-
-  if (isEditorMode) {
-    return <></>;
-  }
-
-  return (
-    <div className="border border-info p-3">
-      <div
-        className="col-md-12 p-0"
-      >
-        <h2 className="text-info lead">
-          <i className="icon-info pr-2 font-weight-bold" aria-hidden="true"></i>
-          {t('not_found_page.page_not_exist_alert')}
-        </h2>
-        {
-          !isNotFoundPermalink && (
-            <div id="create-page-btn-wrapper-for-tooltip" className="d-inline-block">
-              <button
-                type="button"
-                className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
-                onClick={clickHandler}
-              >
-                <i className="icon-note icon-fw" />
-                {t('not_found_page.Create Page')}
-              </button>
-            </div>
-          )
-        }
-
-        {!isNotFoundPermalink && isGuestUserMode && (
-          <UncontrolledTooltip placement="bottom" target="create-page-btn-wrapper-for-tooltip" fade={false}>
-            {t('Not available for guest')}
-          </UncontrolledTooltip>
-        )}
-      </div>
-    </div>
-  );
-};
-
-
-export default NotFoundAlert;

+ 1 - 5
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -33,7 +33,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
     this.currentRenderingContext = {
     this.currentRenderingContext = {
       markdown: this.props.markdown,
       markdown: this.props.markdown,
       pagePath: this.props.pagePath,
       pagePath: this.props.pagePath,
-      editorSettings: this.editorSettings,
+      renderDrawioInRealtime: this.props.editorSettings?.renderDrawioInRealtime,
       currentPathname: decodeURIComponent(window.location.pathname),
       currentPathname: decodeURIComponent(window.location.pathname),
     };
     };
   }
   }
@@ -191,10 +191,6 @@ const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRende
 const RevisionRenderer = (props) => {
 const RevisionRenderer = (props) => {
   const { data: editorSettings } = useEditorSettings();
   const { data: editorSettings } = useEditorSettings();
 
 
-  if (editorSettings == null) {
-    return <></>;
-  }
-
   return <LegacyRevisionRendererWrapper {...props} editorSettings={editorSettings} />;
   return <LegacyRevisionRendererWrapper {...props} editorSettings={editorSettings} />;
 };
 };
 
 

+ 5 - 1
packages/app/src/components/PageDeleteModal.tsx

@@ -1,5 +1,5 @@
 import React, {
 import React, {
-  useState, FC, useMemo,
+  useState, FC, useMemo, useEffect,
 } from 'react';
 } from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -83,6 +83,10 @@ const PageDeleteModal: FC = () => {
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState<Error[] | null>(null);
   const [errs, setErrs] = useState<Error[] | null>(null);
 
 
+  useEffect(() => {
+    setIsDeleteCompletely(forceDeleteCompletelyMode);
+  }, [forceDeleteCompletelyMode]);
+
   function changeIsDeleteRecursivelyHandler() {
   function changeIsDeleteRecursivelyHandler() {
     setIsDeleteRecursively(!isDeleteRecursively);
     setIsDeleteRecursively(!isDeleteRecursively);
   }
   }

+ 1 - 1
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -514,7 +514,7 @@ class CodeMirrorEditor extends AbstractEditor {
     const context = {
     const context = {
       handlers: [], // list of handlers which process enter key
       handlers: [], // list of handlers which process enter key
       editor: this,
       editor: this,
-      editorSettings: this.props.editorSettings,
+      autoFormatMarkdownTable: this.props.editorSettings.autoFormatMarkdownTable,
     };
     };
 
 
     const interceptorManager = this.interceptorManager;
     const interceptorManager = this.interceptorManager;

+ 5 - 5
packages/app/src/components/PageEditor/CommentMentionHelper.ts

@@ -48,12 +48,12 @@ export default class CommentMentionHelper {
     });
     });
   }
   }
 
 
-  getUsersList = async(username) => {
+  getUsersList = async(q: string) => {
     const limit = 20;
     const limit = 20;
-    const { data } = await apiv3Get('/users/list', { username, limit });
-    return data.users.map(user => ({
-      text: `@${user.username} `,
-      displayText: user.username,
+    const { data } = await apiv3Get('/users/usernames', { q, limit });
+    return data.activeUser.usernames.map(username => ({
+      text: `@${username} `,
+      displayText: username,
     }));
     }));
   }
   }
 
 

+ 4 - 1
packages/app/src/components/PageEditor/EmojiPickerHelper.ts

@@ -2,6 +2,9 @@ import { CSSProperties } from 'react';
 
 
 import i18n from 'i18next';
 import i18n from 'i18next';
 
 
+// https://regex101.com/r/Gqhor8/1
+const EMOJI_PATTERN = new RegExp(/\B:[^:\s]+/);
+
 export default class EmojiPickerHelper {
 export default class EmojiPickerHelper {
 
 
 editor;
 editor;
@@ -10,7 +13,7 @@ pattern: RegExp;
 
 
 constructor(editor) {
 constructor(editor) {
   this.editor = editor;
   this.editor = editor;
-  this.pattern = /:[^:\s]+/;
+  this.pattern = EMOJI_PATTERN;
 }
 }
 
 
 setStyle = ():CSSProperties => {
 setStyle = ():CSSProperties => {

+ 1 - 1
packages/app/src/components/PageEditor/MarkdownTableInterceptor.js

@@ -58,7 +58,7 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
     const context = Object.assign(args[0]); // clone
     const context = Object.assign(args[0]); // clone
     const editor = context.editor; // AbstractEditor instance
     const editor = context.editor; // AbstractEditor instance
     // "autoFormatMarkdownTable" may be undefined, so it is compared to true and converted to bool.
     // "autoFormatMarkdownTable" may be undefined, so it is compared to true and converted to bool.
-    const noIntercept = (context.editorSettings?.autoFormatMarkdownTable === false);
+    const noIntercept = (context.autoFormatMarkdownTable === false);
 
 
     // do nothing if editor is not a CodeMirrorEditor or no intercept
     // do nothing if editor is not a CodeMirrorEditor or no intercept
     if (editor == null || editor.getCodeMirror() == null || noIntercept) {
     if (editor == null || editor.getCodeMirror() == null || noIntercept) {

+ 2 - 2
packages/app/src/components/PageEditor/Preview.tsx

@@ -42,11 +42,11 @@ const Preview = (props: Props): JSX.Element => {
     return {
     return {
       markdown,
       markdown,
       pagePath,
       pagePath,
-      editorSettings,
+      renderDrawioInRealtime: editorSettings?.renderDrawioInRealtime,
       currentPathname: decodeURIComponent(window.location.pathname),
       currentPathname: decodeURIComponent(window.location.pathname),
       parsedHTML: null,
       parsedHTML: null,
     };
     };
-  }, [markdown, pagePath, editorSettings]);
+  }, [markdown, pagePath, editorSettings?.renderDrawioInRealtime]);
 
 
   const renderPreview = useCallback(async() => {
   const renderPreview = useCallback(async() => {
     if (interceptorManager != null) {
     if (interceptorManager != null) {

+ 44 - 8
packages/app/src/components/PrivateLegacyPages.tsx

@@ -15,6 +15,7 @@ import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5MigrationStatus } from '~/interfaces/page-listing-results';
 import { V5MigrationStatus } from '~/interfaces/page-listing-results';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
 import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
+import { useCurrentUser } from '~/stores/context';
 import {
 import {
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
@@ -139,6 +140,11 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const [currentInput, setInput] = useState<string>('');
   const [currentInput, setInput] = useState<string>('');
+  const [checked, setChecked] = useState<boolean>(false);
+
+  useEffect(() => {
+    setChecked(false);
+  }, [props.isOpen]);
 
 
   return (
   return (
     <Modal size="lg" isOpen={props.isOpen} toggle={props.close} className="grw-create-page">
     <Modal size="lg" isOpen={props.isOpen} toggle={props.close} className="grw-create-page">
@@ -148,9 +154,26 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
       <ModalBody>
       <ModalBody>
         <p>{t('private_legacy_pages.by_path_modal.description')}</p>
         <p>{t('private_legacy_pages.by_path_modal.description')}</p>
         <input type="text" className="form-control" placeholder="/" value={currentInput} onChange={e => setInput(e.target.value)} />
         <input type="text" className="form-control" placeholder="/" value={currentInput} onChange={e => setInput(e.target.value)} />
+        <div className="alert alert-danger mt-3" role="alert">
+          { t('private_legacy_pages.by_path_modal.alert') }
+        </div>
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <button type="button" className="btn btn-primary" onClick={() => props.onSubmit?.(currentInput)}>
+        <div className="form-check">
+          <input
+            className="form-check-input"
+            type="checkbox"
+            id="understoodCheckbox"
+            onChange={e => setChecked(e.target.checked)}
+          />
+          <label className="form-check-label" htmlFor="understoodCheckbox">{ t('private_legacy_pages.by_path_modal.checkbox_label') }</label>
+        </div>
+        <button
+          type="button"
+          className="btn btn-primary"
+          disabled={!checked}
+          onClick={() => props.onSubmit?.(currentInput)}
+        >
           <i className="icon-fw icon-refresh" aria-hidden="true"></i>
           <i className="icon-fw icon-refresh" aria-hidden="true"></i>
           { t('private_legacy_pages.by_path_modal.button_label') }
           { t('private_legacy_pages.by_path_modal.button_label') }
         </button>
         </button>
@@ -159,7 +182,6 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   );
   );
 });
 });
 
 
-
 /**
 /**
  * LegacyPage
  * LegacyPage
  */
  */
@@ -170,6 +192,9 @@ type Props = {
 
 
 const PrivateLegacyPages = (props: Props): JSX.Element => {
 const PrivateLegacyPages = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { data: currentUser } = useCurrentUser();
+
+  const isAdmin = currentUser?.admin;
 
 
   const {
   const {
     appContainer,
     appContainer,
@@ -310,8 +335,23 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
     mutate();
     mutate();
   }, [limit, mutate]);
   }, [limit, mutate]);
 
 
+  const openConvertModalHandler = useCallback(() => {
+    if (!isAdmin) { return }
+    setOpenConvertModal(true);
+  }, [isAdmin]);
+
   const hitsCount = data?.meta.hitsCount;
   const hitsCount = data?.meta.hitsCount;
 
 
+  const renderOpenModalButton = useCallback(() => {
+    return (
+      <div className="d-flex pl-md-2">
+        <button type="button" className="btn btn-light" onClick={() => openConvertModalHandler()}>
+          {t('private_legacy_pages.input_path_to_convert')}
+        </button>
+      </div>
+    );
+  }, [t, openConvertModalHandler]);
+
   const searchControlAllAction = useMemo(() => {
   const searchControlAllAction = useMemo(() => {
     const isCheckboxDisabled = hitsCount === 0;
     const isCheckboxDisabled = hitsCount === 0;
 
 
@@ -342,11 +382,7 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
             </UncontrolledButtonDropdown>
             </UncontrolledButtonDropdown>
           </OperateAllControl>
           </OperateAllControl>
         </div>
         </div>
-        <div className="d-flex pl-md-2">
-          <button type="button" className="btn btn-light" onClick={() => setOpenConvertModal(true)}>
-            {t('private_legacy_pages.input_path_to_convert')}
-          </button>
-        </div>
+        {isAdmin && renderOpenModalButton()}
       </div>
       </div>
     );
     );
   }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
   }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
@@ -418,7 +454,7 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
         close={() => setOpenConvertModal(false)}
         close={() => setOpenConvertModal(false)}
         onSubmit={async(convertPath: string) => {
         onSubmit={async(convertPath: string) => {
           try {
           try {
-            await apiv3Post<void>('/pages/legacy-pages-migration', {
+            await apiv3Post<void>('/pages/convert-pages-by-path', {
               convertPath,
               convertPath,
             });
             });
             toastSuccess(t('private_legacy_pages.by_path_modal.success'));
             toastSuccess(t('private_legacy_pages.by_path_modal.success'));

+ 11 - 2
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -1,7 +1,9 @@
 import React, {
 import React, {
   forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
   forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
 } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess } from '~/client/util/apiNotification';
 import { toastSuccess } from '~/client/util/apiNotification';
@@ -11,8 +13,8 @@ import { OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
 import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
 import { usePageDeleteModal } from '~/stores/modal';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { usePageTreeTermManager } from '~/stores/page-listing';
-import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { SearchResultContent } from '../SearchPage/SearchResultContent';
 import { SearchResultContent } from '../SearchPage/SearchResultContent';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 
 
@@ -253,7 +255,14 @@ export const usePageDeleteModalForBulkDeletion = (
 
 
     openDeleteModal(selectedPages, {
     openDeleteModal(selectedPages, {
       onDeleted: (...args) => {
       onDeleted: (...args) => {
-        toastSuccess(args[2] ? t('deleted_pages_completely') : t('deleted_pages'));
+        const path = args[0];
+        const isCompletely = args[2];
+        if (path == null || isCompletely == null) {
+          toastSuccess(t('deleted_page'));
+        }
+        else {
+          toastSuccess(t('deleted_pages_completely', { path }));
+        }
         advancePt();
         advancePt();
 
 
         if (onDeleted != null) {
         if (onDeleted != null) {

+ 1 - 1
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -43,7 +43,7 @@ const CustomSidebar: FC<Props> = (props: Props) => {
           Custom Sidebar
           Custom Sidebar
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
         </h3>
         </h3>
-        <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={() => mutate()}>
+        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => mutate()}>
           <i className="icon icon-reload"></i>
           <i className="icon icon-reload"></i>
         </button>
         </button>
       </div>
       </div>

+ 1 - 1
packages/app/src/components/Sidebar/Tag.tsx

@@ -38,7 +38,7 @@ const Tag: FC = () => {
         <h3 className="mb-0">{t('Tags')}</h3>
         <h3 className="mb-0">{t('Tags')}</h3>
         <button
         <button
           type="button"
           type="button"
-          className="btn btn-sm ml-auto grw-btn-reload-rc"
+          className="btn btn-sm ml-auto grw-btn-reload"
           onClick={onReload}
           onClick={onReload}
         >
         >
           <i className="icon icon-reload"></i>
           <i className="icon icon-reload"></i>

+ 16 - 6
packages/app/src/components/SubscribeButton.tsx

@@ -1,7 +1,8 @@
-import React, { FC } from 'react';
+import React, { FC, useCallback } from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
+
 import { SubscriptionStatusType } from '~/interfaces/subscription';
 import { SubscriptionStatusType } from '~/interfaces/subscription';
 
 
 
 
@@ -20,6 +21,17 @@ const SubscribeButton: FC<Props> = (props: Props) => {
   const buttonClass = `${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`;
   const buttonClass = `${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`;
   const iconClass = isSubscribing === false ? 'fa fa-eye-slash' : 'fa fa-eye';
   const iconClass = isSubscribing === false ? 'fa fa-eye-slash' : 'fa fa-eye';
 
 
+  const getTooltipMessage = useCallback(() => {
+    if (isGuestUser) {
+      return 'Not available for guest';
+    }
+
+    if (isSubscribing) {
+      return 'tooltip.stop_notification';
+    }
+    return 'tooltip.receive_notifications';
+  }, [isGuestUser, isSubscribing]);
+
   return (
   return (
     <>
     <>
       <button
       <button
@@ -31,11 +43,9 @@ const SubscribeButton: FC<Props> = (props: Props) => {
         <i className={iconClass}></i>
         <i className={iconClass}></i>
       </button>
       </button>
 
 
-      {isGuestUser && (
-        <UncontrolledTooltip placement="top" target="subscribe-button" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      )}
+      <UncontrolledTooltip placement="top" target="subscribe-button" fade={false}>
+        {t(getTooltipMessage())}
+      </UncontrolledTooltip>
     </>
     </>
   );
   );
 
 

+ 6 - 8
packages/app/src/components/TableOfContents.jsx

@@ -1,16 +1,16 @@
 import React, { useCallback, useEffect } from 'react';
 import React, { useCallback, useEffect } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import loggerFactory from '~/utils/logger';
 
 
 
 
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
-import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
 import { blinkElem } from '~/client/util/blink-section-header';
 import { blinkElem } from '~/client/util/blink-section-header';
+import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import loggerFactory from '~/utils/logger';
 
 
-import { withUnstatedContainers } from './UnstatedUtils';
 
 
 import { StickyStretchableScroller } from './StickyStretchableScroller';
 import { StickyStretchableScroller } from './StickyStretchableScroller';
+import { withUnstatedContainers } from './UnstatedUtils';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
 const logger = loggerFactory('growi:TableOfContents');
@@ -21,7 +21,7 @@ const logger = loggerFactory('growi:TableOfContents');
  */
  */
 const TableOfContents = (props) => {
 const TableOfContents = (props) => {
 
 
-  const { t, pageContainer } = props;
+  const { pageContainer } = props;
   const { pageUser } = pageContainer.state;
   const { pageUser } = pageContainer.state;
   const isUserPage = pageUser != null;
   const isUserPage = pageUser != null;
 
 
@@ -87,9 +87,7 @@ const TableOfContents = (props) => {
 const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer]);
 const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer]);
 
 
 TableOfContents.propTypes = {
 TableOfContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(TableOfContentsWrapper);
+export default TableOfContentsWrapper;

+ 6 - 1
packages/app/src/components/User/SeenUserInfo.tsx

@@ -1,7 +1,8 @@
 import React, { FC, useState } from 'react';
 import React, { FC, useState } from 'react';
 
 
-import { Popover, PopoverBody } from 'reactstrap';
 import { FootstampIcon } from '@growi/ui';
 import { FootstampIcon } from '@growi/ui';
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 
 
 import { IUser } from '~/interfaces/user';
 import { IUser } from '~/interfaces/user';
 
 
@@ -14,6 +15,7 @@ interface Props {
 }
 }
 
 
 const SeenUserInfo: FC<Props> = (props: Props) => {
 const SeenUserInfo: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
 
   const { seenUsers, sumOfSeenUsers, disabled } = props;
   const { seenUsers, sumOfSeenUsers, disabled } = props;
@@ -35,6 +37,9 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
           </div>
           </div>
         </PopoverBody>
         </PopoverBody>
       </Popover>
       </Popover>
+      <UncontrolledTooltip placement="top" target="btn-seen-user" fade={false}>
+        {t('tooltip.footprints')}
+      </UncontrolledTooltip>
     </div>
     </div>
   );
   );
 };
 };

+ 1 - 0
packages/app/src/interfaces/errors/v5-conversion-error.ts

@@ -2,6 +2,7 @@ export const V5ConversionErrCode = {
   GRANT_INVALID: 'GrantInvalid',
   GRANT_INVALID: 'GrantInvalid',
   PAGE_NOT_FOUND: 'PageNotFound',
   PAGE_NOT_FOUND: 'PageNotFound',
   DUPLICATE_PAGES_FOUND: 'DuplicatePagesFound',
   DUPLICATE_PAGES_FOUND: 'DuplicatePagesFound',
+  FORBIDDEN: 'Forbidden',
 } as const;
 } as const;
 
 
 export type V5ConversionErrCode = typeof V5ConversionErrCode[keyof typeof V5ConversionErrCode];
 export type V5ConversionErrCode = typeof V5ConversionErrCode[keyof typeof V5ConversionErrCode];

+ 20 - 0
packages/app/src/interfaces/page-grant.ts

@@ -0,0 +1,20 @@
+import { PageGrant, IPageGrantData } from './page';
+
+export type IDataApplicableGroup = {
+  applicableGroups?: {_id: string, name: string}[] // TODO: Typescriptize model
+}
+
+export type IDataApplicableGrant = null | IDataApplicableGroup;
+export type IRecordApplicableGrant = Record<PageGrant, IDataApplicableGrant>
+export type IResApplicableGrant = {
+  data?: IRecordApplicableGrant
+}
+export type IResIsGrantNormalizedGrantData = {
+  isForbidden: boolean,
+  currentPageGrant: IPageGrantData,
+  parentPageGrant?: IPageGrantData
+}
+export type IResIsGrantNormalized = {
+  isGrantNormalized: boolean,
+  grantData: IResIsGrantNormalizedGrantData
+};

+ 18 - 1
packages/app/src/interfaces/page.ts

@@ -18,7 +18,7 @@ export interface IPage {
   parent: Ref<IPage> | null,
   parent: Ref<IPage> | null,
   descendantCount: number,
   descendantCount: number,
   isEmpty: boolean,
   isEmpty: boolean,
-  grant: number,
+  grant: PageGrant,
   grantedUsers: Ref<IUser>[],
   grantedUsers: Ref<IUser>[],
   grantedGroup: Ref<any>,
   grantedGroup: Ref<any>,
   lastUpdateUser: Ref<IUser>,
   lastUpdateUser: Ref<IUser>,
@@ -32,6 +32,15 @@ export interface IPage {
   deletedAt: Date,
   deletedAt: Date,
 }
 }
 
 
+export const PageGrant = {
+  GRANT_PUBLIC: 1,
+  GRANT_RESTRICTED: 2,
+  GRANT_SPECIFIED: 3,
+  GRANT_OWNER: 4,
+  GRANT_USER_GROUP: 5,
+};
+export type PageGrant = typeof PageGrant[keyof typeof PageGrant];
+
 export type IPageHasId = IPage & HasObjectId;
 export type IPageHasId = IPage & HasObjectId;
 
 
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
@@ -108,6 +117,14 @@ export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
 export type IPageToDeleteWithMeta = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string }), IPageInfoForEntity | unknown>;
 export type IPageToDeleteWithMeta = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string }), IPageInfoForEntity | unknown>;
 export type IPageToRenameWithMeta = IPageToDeleteWithMeta;
 export type IPageToRenameWithMeta = IPageToDeleteWithMeta;
 
 
+export type IPageGrantData = {
+  grant: number,
+  grantedGroup?: {
+    id: string,
+    name: string
+  }
+}
+
 export type IDeleteSinglePageApiv1Result = {
 export type IDeleteSinglePageApiv1Result = {
   ok: boolean
   ok: boolean
   path: string,
   path: string,

+ 5 - 1
packages/app/src/server/crowi/index.js

@@ -400,12 +400,16 @@ Crowi.prototype.autoInstall = function() {
     admin: true,
     admin: true,
   };
   };
   const globalLang = this.configManager.getConfig('crowi', 'autoInstall:globalLang');
   const globalLang = this.configManager.getConfig('crowi', 'autoInstall:globalLang');
+  const allowGuestMode = this.configManager.getConfig('crowi', 'autoInstall:allowGuestMode');
   const serverDate = this.configManager.getConfig('crowi', 'autoInstall:serverDate');
   const serverDate = this.configManager.getConfig('crowi', 'autoInstall:serverDate');
 
 
   const installerService = new InstallerService(this);
   const installerService = new InstallerService(this);
 
 
   try {
   try {
-    installerService.install(firstAdminUserToSave, globalLang ?? 'en_US', serverDate);
+    installerService.install(firstAdminUserToSave, globalLang ?? 'en_US', {
+      allowGuestMode,
+      serverDate,
+    });
   }
   }
   catch (err) {
   catch (err) {
     logger.warn('Automatic installation failed.', err);
     logger.warn('Automatic installation failed.', err);

+ 1 - 1
packages/app/src/server/events/user.js

@@ -21,7 +21,7 @@ UserEvent.prototype.onActivated = async function(user) {
 
 
     // create user page
     // create user page
     try {
     try {
-      await Page.create(userPagePath, body, user, {});
+      await this.crowi.pageService.create(userPagePath, body, user, {});
 
 
       // page created
       // page created
       debug('User page created', page);
       debug('User page created', page);

+ 15 - 8
packages/app/src/server/models/obsolete-page.js

@@ -248,14 +248,14 @@ export const getPageSchema = (crowi) => {
   };
   };
 
 
   pageSchema.methods.applyScope = function(user, grant, grantUserGroupId) {
   pageSchema.methods.applyScope = function(user, grant, grantUserGroupId) {
-    // reset
+    // Reset
     this.grantedUsers = [];
     this.grantedUsers = [];
     this.grantedGroup = null;
     this.grantedGroup = null;
 
 
     this.grant = grant || GRANT_PUBLIC;
     this.grant = grant || GRANT_PUBLIC;
 
 
-    if (grant !== GRANT_PUBLIC && grant !== GRANT_USER_GROUP && grant !== GRANT_RESTRICTED) {
-      this.grantedUsers.push(user._id);
+    if (grant === GRANT_OWNER) {
+      this.grantedUsers.push(user?._id ?? user);
     }
     }
 
 
     if (grant === GRANT_USER_GROUP) {
     if (grant === GRANT_USER_GROUP) {
@@ -742,14 +742,21 @@ export const getPageSchema = (crowi) => {
 
 
     // update existing page
     // update existing page
     let savedPage = await pageData.save();
     let savedPage = await pageData.save();
-    const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
 
 
-    if (isSyncRevisionToHackmd) {
-      savedPage = await this.syncRevisionToHackmd(savedPage);
+    // Update revision
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await this.syncRevisionToHackmd(savedPage);
+      }
     }
     }
 
 
+
     pageEvent.emit('update', savedPage, user);
     pageEvent.emit('update', savedPage, user);
 
 
     return savedPage;
     return savedPage;

+ 122 - 261
packages/app/src/server/models/page.ts

@@ -10,15 +10,14 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 
 
+import { IPage, IPageHasId, PageGrant } from '~/interfaces/page';
 import { IUserHasId } from '~/interfaces/user';
 import { IUserHasId } from '~/interfaces/user';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 
-import { IPage, IPageHasId } from '../../interfaces/page';
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
 
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
-import { PageRedirectModel } from './page-redirect';
 
 
 const { addTrailingSlash, normalizePath } = pathUtils;
 const { addTrailingSlash, normalizePath } = pathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
@@ -36,7 +35,9 @@ const PAGE_GRANT_ERROR = 1;
 const STATUS_PUBLISHED = 'published';
 const STATUS_PUBLISHED = 'published';
 const STATUS_DELETED = 'deleted';
 const STATUS_DELETED = 'deleted';
 
 
-export interface PageDocument extends IPage, Document { }
+export interface PageDocument extends IPage, Document {
+  [x:string]: any // for obsolete methods
+}
 
 
 
 
 type TargetAndAncestorsResult = {
 type TargetAndAncestorsResult = {
@@ -51,11 +52,9 @@ type PaginatedPages = {
   offset: number
   offset: number
 }
 }
 
 
-export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
+export type CreateMethod = (path: string, body: string, user, options: PageCreateOptions) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
-  [x: string]: any; // for obsolete methods
-  createEmptyPagesByPaths(paths: string[], user: any | null, onlyMigratedAsExistingPages?: boolean, andFilter?): Promise<void>
-  getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
+  [x: string]: any; // for obsolete static methods
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
@@ -392,7 +391,7 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
-  addConditionAsMigrated() {
+  addConditionAsOnTree() {
     this.query = this.query
     this.query = this.query
       .and(
       .and(
         {
         {
@@ -481,58 +480,6 @@ export class PageQueryBuilder {
 
 
 }
 }
 
 
-/**
- * Create empty pages if the page in paths didn't exist
- * @param onlyMigratedAsExistingPages Determine whether to include non-migrated pages as existing pages. If a page exists,
- * an empty page will not be created at that page's path.
- */
-schema.statics.createEmptyPagesByPaths = async function(paths: string[], user: any | null, onlyMigratedAsExistingPages = true, filter?): Promise<void> {
-  const aggregationPipeline: any[] = [];
-  // 1. Filter by paths
-  aggregationPipeline.push({ $match: { path: { $in: paths } } });
-  // 2. Normalized condition
-  if (onlyMigratedAsExistingPages) {
-    aggregationPipeline.push({
-      $match: {
-        $or: [
-          { grant: GRANT_PUBLIC },
-          { parent: { $ne: null } },
-          { path: '/' },
-        ],
-      },
-    });
-  }
-  // 3. Add custom pipeline
-  if (filter != null) {
-    aggregationPipeline.push({ $match: filter });
-  }
-  // 4. Add grant conditions
-  let userGroups = null;
-  if (user != null) {
-    const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
-    userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-  }
-  const grantCondition = this.generateGrantCondition(user, userGroups);
-  aggregationPipeline.push({ $match: grantCondition });
-
-  // Run aggregation
-  const existingPages = await this.aggregate(aggregationPipeline);
-
-
-  const existingPagePaths = existingPages.map(page => page.path);
-  // paths to create empty pages
-  const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
-
-  // insertMany empty pages
-  try {
-    await this.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
-  }
-  catch (err) {
-    logger.error('Failed to insert empty pages.', err);
-    throw err;
-  }
-};
-
 schema.statics.createEmptyPage = async function(
 schema.statics.createEmptyPage = async function(
     path: string, parent: any, descendantCount = 0, // TODO: improve type including IPage at https://redmine.weseek.co.jp/issues/86506
     path: string, parent: any, descendantCount = 0, // TODO: improve type including IPage at https://redmine.weseek.co.jp/issues/86506
 ): Promise<PageDocument & { _id: any }> {
 ): Promise<PageDocument & { _id: any }> {
@@ -600,73 +547,6 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
   return this.findById(newTarget._id);
   return this.findById(newTarget._id);
 };
 };
 
 
-/**
- * Find parent or create parent if not exists.
- * It also updates parent of ancestors
- * @param path string
- * @returns Promise<PageDocument>
- */
-schema.statics.getParentAndFillAncestors = async function(path: string, user): Promise<PageDocument> {
-  const parentPath = nodePath.dirname(path);
-
-  const builder1 = new PageQueryBuilder(this.find({ path: parentPath }), true);
-  const pagesCanBeParent = await builder1
-    .addConditionAsMigrated()
-    .query
-    .exec();
-
-  if (pagesCanBeParent.length >= 1) {
-    return pagesCanBeParent[0]; // the earliest page will be the result
-  }
-
-  /*
-   * Fill parents if parent is null
-   */
-  const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
-
-  // just create ancestors with empty pages
-  await this.createEmptyPagesByPaths(ancestorPaths, user);
-
-  // find ancestors
-  const builder2 = new PageQueryBuilder(this.find(), true);
-
-  // avoid including not normalized pages
-  builder2.addConditionToFilterByApplicableAncestors(ancestorPaths);
-
-  const ancestors = await builder2
-    .addConditionToListByPathsArray(ancestorPaths)
-    .addConditionToSortPagesByDescPath()
-    .query
-    .exec();
-
-  const ancestorsMap = new Map(); // Map<path, page>
-  ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page)); // the earlier element should be the true ancestor
-
-  // bulkWrite to update ancestors
-  const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
-  const operations = nonRootAncestors.map((page) => {
-    const parentPath = nodePath.dirname(page.path);
-    return {
-      updateOne: {
-        filter: {
-          _id: page._id,
-        },
-        update: {
-          parent: ancestorsMap.get(parentPath)._id,
-        },
-      },
-    };
-  });
-  await this.bulkWrite(operations);
-
-  const parentId = ancestorsMap.get(parentPath)._id; // get parent page id to fetch updated parent parent
-  const createdParent = await this.findOne({ _id: parentId });
-  if (createdParent == null) {
-    throw Error('updated parent not Found');
-  }
-  return createdParent;
-};
-
 // Utility function to add viewer condition to PageQueryBuilder instance
 // Utility function to add viewer condition to PageQueryBuilder instance
 const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroups = null): Promise<void> => {
 const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroups = null): Promise<void> => {
   let relatedUserGroups = userGroups;
   let relatedUserGroups = userGroups;
@@ -766,7 +646,7 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
   await addViewerCondition(queryBuilder, user, userGroups);
   await addViewerCondition(queryBuilder, user, userGroups);
 
 
   const _targetAndAncestors: PageDocument[] = await queryBuilder
   const _targetAndAncestors: PageDocument[] = await queryBuilder
-    .addConditionAsMigrated()
+    .addConditionAsOnTree()
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToMinimizeDataForRendering()
     .addConditionToMinimizeDataForRendering()
     .addConditionToSortPagesByDescPath()
     .addConditionToSortPagesByDescPath()
@@ -814,7 +694,7 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
   const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }), true);
   const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }), true);
   await addViewerCondition(queryBuilder, user, userGroups);
   await addViewerCondition(queryBuilder, user, userGroups);
   const _pages = await queryBuilder
   const _pages = await queryBuilder
-    .addConditionAsMigrated()
+    .addConditionAsOnTree()
     .addConditionToMinimizeDataForRendering()
     .addConditionToMinimizeDataForRendering()
     .addConditionToSortPagesByAscPath()
     .addConditionToSortPagesByAscPath()
     .query
     .query
@@ -845,14 +725,49 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
   return pathToChildren;
   return pathToChildren;
 };
 };
 
 
+/**
+ * Create empty pages at paths at which no pages exist
+ * @param paths Page paths
+ * @param aggrPipelineForExistingPages AggregationPipeline object to find existing pages at paths
+ */
+schema.statics.createEmptyPagesByPaths = async function(paths: string[], aggrPipelineForExistingPages: any[]): Promise<void> {
+  const existingPages = await this.aggregate(aggrPipelineForExistingPages);
+
+  const existingPagePaths = existingPages.map(page => page.path);
+  const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
+
+  await this.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
+};
+
+/**
+ * Find a parent page by path
+ * @param {string} path
+ * @returns {Promise<PageDocument | null>}
+ */
+schema.statics.findParentByPath = async function(path: string): Promise<PageDocument | null> {
+  const parentPath = nodePath.dirname(path);
+
+  const builder = new PageQueryBuilder(this.find({ path: parentPath }), true);
+  const pagesCanBeParent = await builder
+    .addConditionAsOnTree()
+    .query
+    .exec();
+
+  if (pagesCanBeParent.length >= 1) {
+    return pagesCanBeParent[0]; // the earliest page will be the result
+  }
+
+  return null;
+};
+
 /*
 /*
  * Utils from obsolete-page.js
  * Utils from obsolete-page.js
  */
  */
-async function pushRevision(pageData, newRevision, user) {
+export async function pushRevision(pageData, newRevision, user) {
   await newRevision.save();
   await newRevision.save();
 
 
   pageData.revision = newRevision;
   pageData.revision = newRevision;
-  pageData.lastUpdateUser = user;
+  pageData.lastUpdateUser = user?._id ?? user;
   pageData.updatedAt = Date.now();
   pageData.updatedAt = Date.now();
 
 
   return pageData.save();
   return pageData.save();
@@ -999,6 +914,40 @@ schema.statics.removeEmptyPages = async function(pageIdsToNotRemove: ObjectIdLik
   });
   });
 };
 };
 
 
+/**
+ * Find a not empty parent recursively.
+ * @param {string} path
+ * @returns {Promise<PageDocument | null>}
+ */
+schema.statics.findNotEmptyParentByPathRecursively = async function(path: string): Promise<PageDocument | null> {
+  const parent = await this.findParentByPath(path);
+  if (parent == null) {
+    return null;
+  }
+
+  const recursive = async(page: PageDocument): Promise<PageDocument> => {
+    if (!page.isEmpty) {
+      return page;
+    }
+
+    const next = await this.findById(page.parent);
+
+    if (next == null || isTopPage(next.path)) {
+      return page;
+    }
+
+    return recursive(next);
+  };
+
+  const notEmptyParent = await recursive(parent);
+
+  return notEmptyParent;
+};
+
+schema.statics.findParent = async function(pageId): Promise<PageDocument | null> {
+  return this.findOne({ _id: pageId });
+};
+
 schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
 schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
 
 
 export function generateGrantCondition(
 export function generateGrantCondition(
@@ -1059,125 +1008,6 @@ export default (crowi: Crowi): any => {
     pageEvent = crowi.event('page');
     pageEvent = crowi.event('page');
   }
   }
 
 
-  schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
-    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null || crowi.pageOperationService == null) {
-      throw Error('Crowi is not setup');
-    }
-
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    // v4 compatible process
-    if (!isV5Compatible) {
-      return this.createV4(path, body, user, options);
-    }
-
-    const canOperate = await crowi.pageOperationService.canOperate(false, null, path);
-    if (!canOperate) {
-      throw Error(`Cannot operate create to path "${path}" right now.`);
-    }
-
-    const Page = this;
-    const Revision = crowi.model('Revision');
-    const {
-      format = 'markdown', grantUserGroupId,
-    } = options;
-    let grant = options.grant;
-
-    // sanitize path
-    path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
-    // throw if exists
-    const isExist = (await this.count({ path, isEmpty: false })) > 0; // not validate empty page
-    if (isExist) {
-      throw new Error('Cannot create new page to existed path');
-    }
-    // force public
-    if (isTopPage(path)) {
-      grant = GRANT_PUBLIC;
-    }
-
-    // find an existing empty page
-    const emptyPage = await Page.findOne({ path, isEmpty: true });
-
-    /*
-     * UserGroup & Owner validation
-     */
-    if (grant !== GRANT_RESTRICTED) {
-      let isGrantNormalized = false;
-      try {
-        // It must check descendants as well if emptyTarget is not null
-        const shouldCheckDescendants = emptyPage != null;
-        const newGrantedUserIds = grant === GRANT_OWNER ? [user._id] as IObjectId[] : undefined;
-
-        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(user, path, grant, newGrantedUserIds, grantUserGroupId, shouldCheckDescendants);
-      }
-      catch (err) {
-        logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
-        throw err;
-      }
-      if (!isGrantNormalized) {
-        throw Error('The selected grant or grantedGroup is not assignable to this page.');
-      }
-    }
-
-    /*
-     * update empty page if exists, if not, create a new page
-     */
-    let page;
-    if (emptyPage != null && grant !== GRANT_RESTRICTED) {
-      page = emptyPage;
-      const descendantCount = await this.recountDescendantCount(page._id);
-
-      page.descendantCount = descendantCount;
-      page.isEmpty = false;
-    }
-    else {
-      page = new Page();
-    }
-
-    page.path = path;
-    page.creator = user;
-    page.lastUpdateUser = user;
-    page.status = STATUS_PUBLISHED;
-
-    // set parent to null when GRANT_RESTRICTED
-    const isGrantRestricted = grant === GRANT_RESTRICTED;
-    if (isTopPage(path) || isGrantRestricted) {
-      page.parent = null;
-    }
-    else {
-      const parent = await Page.getParentAndFillAncestors(path, user);
-      page.parent = parent._id;
-    }
-
-    page.applyScope(user, grant, grantUserGroupId);
-
-    let savedPage = await page.save();
-
-    /*
-     * After save
-     */
-    // Delete PageRedirect if exists
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
-    try {
-      await PageRedirect.deleteOne({ fromPath: path });
-      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
-    }
-    catch (err) {
-      // no throw
-      logger.error('Failed to delete PageRedirect');
-    }
-
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
-
-    pageEvent.emit('create', savedPage, user);
-
-    // update descendantCount asynchronously
-    await crowi.pageService.updateDescendantCountOfAncestors(savedPage._id, 1, false);
-
-    return savedPage;
-  };
-
   const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
   const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
     const isRestricted = grant === GRANT_RESTRICTED;
     const isRestricted = grant === GRANT_RESTRICTED;
     return !isRestricted && (!isV5Compatible || !isOnTree);
     return !isRestricted && (!isV5Compatible || !isOnTree);
@@ -1187,7 +1017,31 @@ export default (crowi: Crowi): any => {
     pageEvent.emit('update', page, user);
     pageEvent.emit('update', page, user);
   };
   };
 
 
-  schema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
+  /**
+   * A wrapper method of schema.statics.updatePage for updating grant only.
+   * @param {PageDocument} page
+   * @param {UserDocument} user
+   * @param options
+   */
+  schema.statics.updateGrant = async function(page, user, grantData: {grant: PageGrant, grantedGroup: ObjectIdLike}) {
+    const { grant, grantedGroup } = grantData;
+
+    const options = {
+      grant,
+      grantUserGroupId: grantedGroup,
+      isSyncRevisionToHackmd: false,
+    };
+
+    return this.updatePage(page, null, null, user, options);
+  };
+
+  schema.statics.updatePage = async function(
+      pageData,
+      body: string | null,
+      previousBody: string | null,
+      user,
+      options: {grant?: PageGrant, grantUserGroupId?: ObjectIdLike, isSyncRevisionToHackmd?: boolean} = {},
+  ) {
     if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
     if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
       throw Error('Crowi is not set up');
       throw Error('Crowi is not set up');
     }
     }
@@ -1202,10 +1056,9 @@ export default (crowi: Crowi): any => {
       return this.updatePageV4(pageData, body, previousBody, user, options);
       return this.updatePageV4(pageData, body, previousBody, user, options);
     }
     }
 
 
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const grant = options.grant || pageData.grant; // use the previous data if absence
+    const grant = options.grant ?? pageData.grant; // use the previous data if absence
     const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
     const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+
     const grantedUserIds = pageData.grantedUserIds || [user._id];
     const grantedUserIds = pageData.grantedUserIds || [user._id];
     const shouldBeOnTree = grant !== GRANT_RESTRICTED;
     const shouldBeOnTree = grant !== GRANT_RESTRICTED;
     const isChildrenExist = await this.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(pageData.path))}`), parent: { $ne: null } });
     const isChildrenExist = await this.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(pageData.path))}`), parent: { $ne: null } });
@@ -1226,7 +1079,7 @@ export default (crowi: Crowi): any => {
       }
       }
 
 
       if (!wasOnTree) {
       if (!wasOnTree) {
-        const newParent = await this.getParentAndFillAncestors(newPageData.path, user);
+        const newParent = await crowi.pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
         newPageData.parent = newParent._id;
         newPageData.parent = newParent._id;
       }
       }
     }
     }
@@ -1248,14 +1101,23 @@ export default (crowi: Crowi): any => {
 
 
     // update existing page
     // update existing page
     let savedPage = await newPageData.save();
     let savedPage = await newPageData.save();
-    const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
 
 
-    if (isSyncRevisionToHackmd) {
-      savedPage = await this.syncRevisionToHackmd(savedPage);
+    // Update body
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await this.syncRevisionToHackmd(savedPage);
+      }
     }
     }
 
 
+
     this.emitPageEventUpdate(savedPage, user);
     this.emitPageEventUpdate(savedPage, user);
 
 
     // Update ex children's parent
     // Update ex children's parent
@@ -1292,7 +1154,6 @@ export default (crowi: Crowi): any => {
     }
     }
 
 
     // 2. Delete unnecessary empty pages
     // 2. Delete unnecessary empty pages
-
     const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
     const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
     if (shouldRemoveLeafEmpPages) {
     if (shouldRemoveLeafEmpPages) {
       await this.removeLeafEmptyPagesRecursively(exParent);
       await this.removeLeafEmptyPagesRecursively(exParent);

+ 58 - 0
packages/app/src/server/models/user-group-relation.js

@@ -313,6 +313,64 @@ class UserGroupRelation {
     await this.bulkWrite(insertOperations);
     await this.bulkWrite(insertOperations);
   }
   }
 
 
+  /**
+   * Recursively finds descendant groups by populating relations.
+   * @static
+   * @param {UserGroupDocument[]} groups
+   * @param {UserDocument} user
+   * @returns UserGroupDocument[]
+   */
+  static async findGroupsWithDescendantsByGroupAndUser(group, user) {
+    const descendantGroups = [group];
+
+    const incrementGroupsRecursively = async(groups, user) => {
+      const groupIds = groups.map(g => g._id);
+
+      const populatedRelations = await this.aggregate([
+        {
+          $match: {
+            relatedUser: user._id,
+          },
+        },
+        {
+          $lookup: {
+            from: 'usergroups',
+            localField: 'relatedGroup',
+            foreignField: '_id',
+            as: 'relatedGroup',
+          },
+        },
+        {
+          $unwind: {
+            path: '$relatedGroup',
+          },
+        },
+        {
+          $match: {
+            'relatedGroup.parent': { $in: groupIds },
+          },
+        },
+      ]);
+
+      const nextGroups = populatedRelations.map(d => d.relatedGroup);
+
+      // End
+      const shouldEnd = nextGroups.length === 0;
+      if (shouldEnd) {
+        return;
+      }
+
+      // Increment
+      descendantGroups.push(...nextGroups);
+
+      return incrementGroupsRecursively(nextGroups, user);
+    };
+
+    await incrementGroupsRecursively([group], user);
+
+    return descendantGroups;
+  }
+
 }
 }
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {

+ 16 - 2
packages/app/src/server/models/user.js

@@ -715,8 +715,22 @@ module.exports = function(crowi) {
     return users;
     return users;
   };
   };
 
 
-  userSchema.statics.findUserByUsernameRegex = async function(username, limit) {
-    return this.find({ username: { $regex: username, $options: 'i' } }).limit(limit);
+  userSchema.statics.findUserByUsernameRegexWithTotalCount = async function(username, status, option) {
+    const opt = option || {};
+    const sortOpt = opt.sortOpt || { username: 1 };
+    const offset = opt.offset || 0;
+    const limit = opt.limit || 10;
+
+    const conditions = { username: { $regex: username, $options: 'i' }, status: { $in: status } };
+
+    const users = await this.find(conditions)
+      .sort(sortOpt)
+      .skip(offset)
+      .limit(limit);
+
+    const totalCount = (await this.find(conditions).distinct('username')).length;
+
+    return { users, totalCount };
   };
   };
 
 
   class UserUpperLimitException {
   class UserUpperLimitException {

+ 5 - 2
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -10,6 +10,7 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import httpErrorHandler from '../../middlewares/http-error-handler';
 import httpErrorHandler from '../../middlewares/http-error-handler';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
@@ -25,11 +26,13 @@ module.exports = (crowi) => {
   const path = require('path');
   const path = require('path');
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
 
 
+  const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
+
   const validator = {
   const validator = {
     password: [
     password: [
       body('newPassword').isString().not().isEmpty()
       body('newPassword').isString().not().isEmpty()
-        .isLength({ min: 8 })
-        .withMessage('password must be at least 8 characters long'),
+        .isLength({ min: minPasswordLength })
+        .withMessage(`password must be at least ${minPasswordLength} characters long`),
       // checking if password confirmation matches password
       // checking if password confirmation matches password
       body('newPasswordConfirm').isString().not().isEmpty()
       body('newPasswordConfirm').isString().not().isEmpty()
         .custom((value, { req }) => {
         .custom((value, { req }) => {

+ 168 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -2,6 +2,7 @@ import { pagePathUtils } from '@growi/core';
 
 
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
+import UserGroup from '~/server/models/user-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -9,10 +10,10 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
-const { body, query } = require('express-validator');
+const { body, query, param } = require('express-validator');
 
 
 const router = express.Router();
 const router = express.Router();
-const { convertToNewAffiliationPath } = pagePathUtils;
+const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 
 
@@ -179,6 +180,17 @@ module.exports = (crowi) => {
     info: [
     info: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
     ],
+    isGrantNormalized: [
+      query('pageId').isMongoId().withMessage('pageId is required'),
+    ],
+    applicableGrant: [
+      query('pageId').isMongoId().withMessage('pageId is required'),
+    ],
+    updateGrant: [
+      param('pageId').isMongoId().withMessage('pageId is required'),
+      body('grant').isInt().withMessage('grant is required'),
+      body('grantedGroup').optional().isMongoId().withMessage('grantedGroup must be a mongo id'),
+    ],
     export: [
     export: [
       query('format').isString().isIn(['md', 'pdf']),
       query('format').isString().isIn(['md', 'pdf']),
       query('revisionId').isString(),
       query('revisionId').isString(),
@@ -379,6 +391,160 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *    /page/is-grant-normalized:
+   *      get:
+   *        tags: [Page]
+   *        summary: /page/info
+   *        description: Retrieve current page's isGrantNormalized value
+   *        operationId: getIsGrantNormalized
+   *        parameters:
+   *          - name: pageId
+   *            in: query
+   *            description: page id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *        responses:
+   *          200:
+   *            description: Successfully retrieved current isGrantNormalized.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  properties:
+   *                    isGrantNormalized:
+   *                      type: boolean
+   *          400:
+   *            description: Bad request. Page is unreachable or empty.
+   *          500:
+   *            description: Internal server error.
+   */
+  router.get('/is-grant-normalized', loginRequiredStrictly, validator.isGrantNormalized, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.query;
+
+    const Page = crowi.model('Page');
+    const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+
+    if (page == null) {
+      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+    }
+
+    const {
+      path, grant, grantedUsers, grantedGroup,
+    } = page;
+
+    let isGrantNormalized;
+    try {
+      isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroup, false, false);
+    }
+    catch (err) {
+      logger.error('Error occurred while processing isGrantNormalized.', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    const currentPageUserGroup = await UserGroup.findOne({ _id: grantedGroup });
+    const currentPageGrant = {
+      grant,
+      grantedGroup: currentPageUserGroup != null
+        ? {
+          id: currentPageUserGroup._id,
+          name: currentPageUserGroup.name,
+        }
+        : null,
+    };
+
+    // page doesn't have parent page
+    if (page.parent == null) {
+      const grantData = {
+        isForbidden: false,
+        currentPageGrant,
+        parentPageGrant: null,
+      };
+      return res.apiv3({ isGrantNormalized, grantData });
+    }
+
+    const parentPage = await Page.findByIdAndViewer(page.parent, req.user, null, false);
+
+    // user isn't allowed to see parent's grant
+    if (parentPage == null) {
+      const grantData = {
+        isForbidden: true,
+        currentPageGrant,
+        parentPageGrant: null,
+      };
+      return res.apiv3({ isGrantNormalized, grantData });
+    }
+
+    const parentPageUserGroup = await UserGroup.findOne({ _id: parentPage.grantedGroup });
+    const parentPageGrant = {
+      grant: parentPage.grant,
+      grantedGroup: parentPageUserGroup != null
+        ? {
+          id: parentPageUserGroup._id,
+          name: parentPageUserGroup.name,
+        }
+        : null,
+    };
+
+    const grantData = {
+      isForbidden: false,
+      currentPageGrant,
+      parentPageGrant,
+    };
+
+    return res.apiv3({ isGrantNormalized, grantData });
+  });
+
+  router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.query;
+
+    const Page = crowi.model('Page');
+    const page = await Page.findByIdAndViewer(pageId, req.user, null);
+
+    if (page == null) {
+      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+    }
+
+    let data;
+    try {
+      data = await crowi.pageGrantService.calcApplicableGrantData(page, req.user);
+    }
+    catch (err) {
+      logger.error('Error occurred while processing calcApplicableGrantData.', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    return res.apiv3(data);
+  });
+
+  router.put('/:pageId/grant', loginRequiredStrictly, validator.updateGrant, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.params;
+    const { grant, grantedGroup } = req.body;
+
+    const Page = crowi.model('Page');
+
+    const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+
+    if (page == null) {
+      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+    }
+
+    let data;
+    try {
+      const shouldUseV4Process = false;
+      const grantData = { grant, grantedGroup };
+      data = await Page.updateGrant(page, req.user, grantData, shouldUseV4Process);
+    }
+    catch (err) {
+      logger.error('Error occurred while processing calcApplicableGrantData.', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    return res.apiv3(data);
+  });
+
   /**
   /**
   * @swagger
   * @swagger
   *
   *

+ 23 - 16
packages/app/src/server/routes/apiv3/pages.js

@@ -204,12 +204,15 @@ module.exports = (crowi) => {
         .custom(v => v === 'true' || v === true || v == null)
         .custom(v => v === 'true' || v === true || v == null)
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
     ],
     ],
+    convertPagesByPath: [
+      body('convertPath').optional().isString().withMessage('convertPath must be a string'),
+    ],
   };
   };
 
 
   async function createPageAction({
   async function createPageAction({
     path, body, user, options,
     path, body, user, options,
   }) {
   }) {
-    const createdPage = await Page.create(path, body, user, options);
+    const createdPage = await crowi.pageService.create(path, body, user, options);
     return createdPage;
     return createdPage;
   }
   }
 
 
@@ -823,29 +826,33 @@ module.exports = (crowi) => {
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
   });
 
 
+
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
-    const { convertPath, pageIds: _pageIds, isRecursively } = req.body;
+  router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
+    const { convertPath } = req.body;
 
 
     // Convert by path
     // Convert by path
-    if (convertPath != null) {
-      const normalizedPath = pathUtils.normalizePath(convertPath);
-      try {
-        await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
-      }
-      catch (err) {
-        logger.error(err);
-
-        if (isV5ConversionError(err)) {
-          return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
-        }
+    const normalizedPath = pathUtils.normalizePath(convertPath);
+    try {
+      await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
+    }
+    catch (err) {
+      logger.error(err);
 
 
-        return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
+      if (isV5ConversionError(err)) {
+        return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
       }
       }
 
 
-      return res.apiv3({});
+      return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
     }
     }
 
 
+    return res.apiv3({});
+  });
+
+  // eslint-disable-next-line max-len
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+    const { pageIds: _pageIds, isRecursively } = req.body;
+
     // Convert by pageIds
     // Convert by pageIds
     const pageIds = _pageIds == null ? [] : _pageIds;
     const pageIds = _pageIds == null ? [] : _pageIds;
 
 

+ 6 - 4
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -72,6 +72,8 @@ module.exports = (crowi) => {
 
 
   const { User, ExternalAccount } = crowi.models;
   const { User, ExternalAccount } = crowi.models;
 
 
+  const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
+
   const validator = {
   const validator = {
     personal: [
     personal: [
       body('name').isString().not().isEmpty(),
       body('name').isString().not().isEmpty(),
@@ -91,8 +93,8 @@ module.exports = (crowi) => {
     password: [
     password: [
       body('oldPassword').isString(),
       body('oldPassword').isString(),
       body('newPassword').isString().not().isEmpty()
       body('newPassword').isString().not().isEmpty()
-        .isLength({ min: 8 })
-        .withMessage('password must be at least 8 characters long'),
+        .isLength({ min: minPasswordLength })
+        .withMessage(`password must be at least ${minPasswordLength} characters long`),
       body('newPasswordConfirm').isString().not().isEmpty()
       body('newPasswordConfirm').isString().not().isEmpty()
         .custom((value, { req }) => {
         .custom((value, { req }) => {
           return (value === req.body.newPassword);
           return (value === req.body.newPassword);
@@ -146,7 +148,6 @@ module.exports = (crowi) => {
    */
    */
   router.get('/', accessTokenParser, loginRequiredStrictly, async(req, res) => {
   router.get('/', accessTokenParser, loginRequiredStrictly, async(req, res) => {
     const { username } = req.user;
     const { username } = req.user;
-
     try {
     try {
       const user = await User.findUserByUsername(username);
       const user = await User.findUserByUsername(username);
 
 
@@ -189,7 +190,8 @@ module.exports = (crowi) => {
     try {
     try {
       const user = await User.findUserByUsername(username);
       const user = await User.findUserByUsername(username);
       const isPasswordSet = user.isPasswordSet();
       const isPasswordSet = user.isPasswordSet();
-      return res.apiv3({ isPasswordSet });
+      const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
+      return res.apiv3({ isPasswordSet, minPasswordLength });
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);

+ 43 - 6
packages/app/src/server/routes/apiv3/users.js

@@ -120,6 +120,13 @@ module.exports = (crowi) => {
     query('limit').if(value => value != null).isInt({ max: 300 }).withMessage('You should set less than 300 or not to set limit.'),
     query('limit').if(value => value != null).isInt({ max: 300 }).withMessage('You should set less than 300 or not to set limit.'),
   ];
   ];
 
 
+  validator.usernames = [
+    query('q').isString().withMessage('q is required'),
+    query('offset').optional().isInt().withMessage('offset must be a number'),
+    query('limit').optional().isInt({ max: 20 }).withMessage('You should set less than 20 or not to set limit.'),
+    query('options').optional().isString().withMessage('options must be string'),
+  ];
+
   const sendEmailByUserList = async(userList) => {
   const sendEmailByUserList = async(userList) => {
     const { appService, mailService } = crowi;
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
     const appTitle = appService.getAppTitle();
@@ -899,17 +906,11 @@ module.exports = (crowi) => {
    */
    */
   router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
   router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
     const userIds = req.query.userIds || null;
     const userIds = req.query.userIds || null;
-    const username = req.query.username || null;
-    const limit = req.query.limit || 20;
 
 
     let userFetcher;
     let userFetcher;
     if (userIds !== null && userIds.split(',').length > 0) {
     if (userIds !== null && userIds.split(',').length > 0) {
       userFetcher = User.findUsersByIds(userIds.split(','));
       userFetcher = User.findUsersByIds(userIds.split(','));
     }
     }
-    // Get username list by matching pattern from username mention
-    else if (username !== null) {
-      userFetcher = User.findUserByUsernameRegex(username, limit);
-    }
     else {
     else {
       userFetcher = User.findAllUsers();
       userFetcher = User.findAllUsers();
     }
     }
@@ -932,5 +933,41 @@ module.exports = (crowi) => {
     return res.apiv3(data);
     return res.apiv3(data);
   });
   });
 
 
+  router.get('/usernames', accessTokenParser, loginRequired, validator.usernames, apiV3FormValidator, async(req, res) => {
+    const q = req.query.q;
+    const offset = +req.query.offset || 0;
+    const limit = +req.query.limit || 10;
+
+    try {
+      const options = JSON.parse(req.query.options || '{}');
+      const data = {};
+
+      if (options.isIncludeActiveUser == null || options.isIncludeActiveUser) {
+        const activeUserData = await User.findUserByUsernameRegexWithTotalCount(q, [User.STATUS_ACTIVE], { offset, limit });
+        const activeUsernames = activeUserData.users.map(user => user.username);
+        Object.assign(data, { activeUser: { usernames: activeUsernames, totalCount: activeUserData.totalCount } });
+      }
+
+      if (options.isIncludeInactiveUser) {
+        const inactiveUserStates = [User.STATUS_REGISTERED, User.STATUS_SUSPENDED, User.STATUS_DELETED, User.STATUS_INVITED];
+        const inactiveUserData = await User.findUserByUsernameRegexWithTotalCount(q, inactiveUserStates, { offset, limit });
+        const inactiveUsernames = inactiveUserData.users.map(user => user.username);
+        Object.assign(data, { inactiveUser: { usernames: inactiveUsernames, totalCount: inactiveUserData.totalCount } });
+      }
+
+      if (options.isIncludeMixedUsername) {
+        const allUsernames = [...data.activeUser?.usernames || [], ...data.inactiveUser?.usernames || []];
+        const distinctUsernames = Array.from(new Set(allUsernames));
+        Object.assign(data, { mixedUsernames: distinctUsernames });
+      }
+
+      return res.apiv3(data);
+    }
+    catch (err) {
+      logger.error('Failed to get usernames', err);
+      return res.apiv3Err(err);
+    }
+  });
+
   return router;
   return router;
 };
 };

+ 1 - 1
packages/app/src/server/routes/attachment.js

@@ -437,7 +437,7 @@ module.exports = function(crowi, app) {
     if (pageId == null) {
     if (pageId == null) {
       logger.debug('Create page before file upload');
       logger.debug('Create page before file upload');
 
 
-      page = await Page.create(pagePath, `# ${pagePath}`, req.user, { grant: Page.GRANT_OWNER });
+      page = await crowi.pageService.create(pagePath, `# ${pagePath}`, req.user, { grant: Page.GRANT_OWNER });
       pageCreated = true;
       pageCreated = true;
       pageId = page._id;
       pageId = page._id;
     }
     }

+ 1 - 1
packages/app/src/server/routes/page.js

@@ -792,7 +792,7 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
       options.grantUserGroupId = grantUserGroupId;
     }
     }
 
 
-    const createdPage = await Page.create(pagePath, body, req.user, options);
+    const createdPage = await crowi.pageService.create(pagePath, body, req.user, options);
 
 
     let savedTags;
     let savedTags;
     if (pageTags != null) {
     if (pageTags != null) {

+ 29 - 1
packages/app/src/server/service/comment.ts

@@ -7,6 +7,8 @@ import { stringifySnapshot } from '~/models/serializers/in-app-notification-snap
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
 
 
+// https://regex101.com/r/Ztxj2j/1
+const USERNAME_PATTERN = new RegExp(/\B@[\w@.-]+/g);
 
 
 const logger = loggerFactory('growi:service:CommentService');
 const logger = loggerFactory('growi:service:CommentService');
 
 
@@ -99,11 +101,37 @@ class CommentService {
     let targetUsers: Types.ObjectId[] = [];
     let targetUsers: Types.ObjectId[] = [];
     targetUsers = await activity.getNotificationTargetUsers();
     targetUsers = await activity.getNotificationTargetUsers();
 
 
-    // Create and send notifications
+    // Add mentioned users to targetUsers
+    const mentionedUsers = await this.getMentionedUsers(activity.event);
+    targetUsers = targetUsers.concat(mentionedUsers);
+
     await this.inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
     await this.inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
     await this.inAppNotificationService.emitSocketIo(targetUsers);
     await this.inAppNotificationService.emitSocketIo(targetUsers);
   };
   };
 
 
+  getMentionedUsers = async(commentId: Types.ObjectId): Promise<Types.ObjectId[]> => {
+    const Comment = getModelSafely('Comment') || require('../models/comment')(this.crowi);
+    const User = getModelSafely('User') || require('../models/user')(this.crowi);
+
+    // Get comment by comment ID
+    const commentData = await Comment.findOne({ _id: commentId });
+    const { comment } = commentData;
+
+    const usernamesFromComment = comment.match(USERNAME_PATTERN);
+
+    // Get username from comment and remove duplicate username
+    const mentionedUsernames = [...new Set(usernamesFromComment?.map((username) => {
+      return username.slice(1);
+    }))];
+
+    // Get mentioned users ID
+    const mentionedUserIDs = await User.find({ username: { $in: mentionedUsernames } });
+    return mentionedUserIDs?.map((user) => {
+      return user._id;
+    });
+  }
+
 }
 }
 
 
+
 module.exports = CommentService;
 module.exports = CommentService;

+ 13 - 1
packages/app/src/server/service/config-loader.ts

@@ -1,6 +1,6 @@
+import { envUtils } from '@growi/core';
 import { parseISO } from 'date-fns';
 import { parseISO } from 'date-fns';
 
 
-import { envUtils } from '@growi/core';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -217,6 +217,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
+  AUTO_INSTALL_ALLOW_GUEST_MODE: {
+    ns:      'crowi',
+    key:     'autoInstall:allowGuestMode',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
   AUTO_INSTALL_SERVER_DATE: {
   AUTO_INSTALL_SERVER_DATE: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'autoInstall:serverDate',
     key:     'autoInstall:serverDate',
@@ -610,6 +616,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
+  MIN_PASSWORD_LENGTH: {
+    ns: 'crowi',
+    key: 'app:minPasswordLength',
+    type: ValueType.NUMBER,
+    default: 8,
+  },
 };
 };
 
 
 
 

+ 71 - 71
packages/app/src/server/service/file-uploader/aws.js → packages/app/src/server/service/file-uploader/aws.ts

@@ -1,41 +1,57 @@
+import {
+  S3Client,
+  HeadObjectCommand,
+  GetObjectCommand,
+  DeleteObjectsCommand,
+  PutObjectCommand,
+  DeleteObjectCommand,
+  GetObjectCommandOutput,
+} from '@aws-sdk/client-s3';
+import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
+import urljoin from 'url-join';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-const logger = loggerFactory('growi:service:fileUploaderAws');
 
 
-const urljoin = require('url-join');
-const aws = require('aws-sdk');
+const logger = loggerFactory('growi:service:fileUploaderAws');
 
 
-module.exports = function(crowi) {
+type AwsCredential = {
+  accessKeyId: string,
+  secretAccessKey: string
+}
+type AwsConfig = {
+  credentials: AwsCredential,
+  region: string,
+  endpoint: string,
+  bucket: string,
+  forcePathStyle?: boolean
+}
+
+module.exports = (crowi) => {
   const Uploader = require('./uploader');
   const Uploader = require('./uploader');
   const { configManager } = crowi;
   const { configManager } = crowi;
   const lib = new Uploader(crowi);
   const lib = new Uploader(crowi);
 
 
-  function getAwsConfig() {
+  const getAwsConfig = (): AwsConfig => {
     return {
     return {
-      accessKeyId: configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
-      secretAccessKey: configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
+      credentials: {
+        accessKeyId: configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
+        secretAccessKey: configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
+      },
       region: configManager.getConfig('crowi', 'aws:s3Region'),
       region: configManager.getConfig('crowi', 'aws:s3Region'),
+      endpoint: configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
       bucket: configManager.getConfig('crowi', 'aws:s3Bucket'),
       bucket: configManager.getConfig('crowi', 'aws:s3Bucket'),
-      customEndpoint: configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
+      forcePathStyle: configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null, // s3ForcePathStyle renamed to forcePathStyle in v3
     };
     };
-  }
-
-  function S3Factory() {
-    const awsConfig = getAwsConfig();
-
-    aws.config.update({
-      accessKeyId: awsConfig.accessKeyId,
-      secretAccessKey: awsConfig.secretAccessKey,
-      region: awsConfig.region,
-      s3ForcePathStyle: awsConfig.customEndpoint ? true : undefined,
-    });
+  };
 
 
-    // undefined & null & '' => default endpoint (genuine S3)
-    return new aws.S3({ endpoint: awsConfig.customEndpoint || undefined });
-  }
+  const S3Factory = (): S3Client => {
+    const config = getAwsConfig();
+    return new S3Client(config);
+  };
 
 
-  function getFilePathOnStorage(attachment) {
-    if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
+  const getFilePathOnStorage = (attachment) => {
+    if (attachment.filePath != null) {
       return attachment.filePath;
       return attachment.filePath;
     }
     }
 
 
@@ -45,41 +61,37 @@ module.exports = function(crowi) {
     const filePath = urljoin(dirName, attachment.fileName);
     const filePath = urljoin(dirName, attachment.fileName);
 
 
     return filePath;
     return filePath;
-  }
+  };
 
 
-  async function isFileExists(s3, params) {
-    // check file exists
+  const isFileExists = async(s3: S3Client, params) => {
     try {
     try {
-      await s3.headObject(params).promise();
+      await s3.send(new HeadObjectCommand(params));
     }
     }
     catch (err) {
     catch (err) {
       if (err != null && err.code === 'NotFound') {
       if (err != null && err.code === 'NotFound') {
         return false;
         return false;
       }
       }
-
-      // error except for 'NotFound
       throw err;
       throw err;
     }
     }
-
     return true;
     return true;
-  }
+  };
 
 
-  lib.isValidUploadSettings = function() {
-    return this.configManager.getConfig('crowi', 'aws:s3AccessKeyId') != null
-      && this.configManager.getConfig('crowi', 'aws:s3SecretAccessKey') != null
+  lib.isValidUploadSettings = () => {
+    return configManager.getConfig('crowi', 'aws:s3AccessKeyId') != null
+      && configManager.getConfig('crowi', 'aws:s3SecretAccessKey') != null
       && (
       && (
-        this.configManager.getConfig('crowi', 'aws:s3Region') != null
-          || this.configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null
+        configManager.getConfig('crowi', 'aws:s3Region') != null
+          || configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null
       )
       )
-      && this.configManager.getConfig('crowi', 'aws:s3Bucket') != null;
+      && configManager.getConfig('crowi', 'aws:s3Bucket') != null;
   };
   };
 
 
-  lib.canRespond = function() {
-    return !this.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
+  lib.canRespond = () => {
+    return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
   };
   };
 
 
-  lib.respond = async function(res, attachment) {
-    if (!this.getIsUploadable()) {
+  lib.respond = async(res, attachment) => {
+    if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
     const temporaryUrl = attachment.getValidTemporaryUrl();
     const temporaryUrl = attachment.getValidTemporaryUrl();
@@ -90,16 +102,16 @@ module.exports = function(crowi) {
     const s3 = S3Factory();
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
     const awsConfig = getAwsConfig();
     const filePath = getFilePathOnStorage(attachment);
     const filePath = getFilePathOnStorage(attachment);
-    const lifetimeSecForTemporaryUrl = this.configManager.getConfig('crowi', 'aws:lifetimeSecForTemporaryUrl');
+    const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'aws:lifetimeSecForTemporaryUrl');
 
 
     // issue signed url (default: expires 120 seconds)
     // issue signed url (default: expires 120 seconds)
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
     const params = {
     const params = {
       Bucket: awsConfig.bucket,
       Bucket: awsConfig.bucket,
       Key: filePath,
       Key: filePath,
-      Expires: lifetimeSecForTemporaryUrl,
     };
     };
-    const signedUrl = s3.getSignedUrl('getObject', params);
+    const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), { expiresIn: lifetimeSecForTemporaryUrl });
+
 
 
     res.redirect(signedUrl);
     res.redirect(signedUrl);
 
 
@@ -112,13 +124,13 @@ module.exports = function(crowi) {
 
 
   };
   };
 
 
-  lib.deleteFile = async function(attachment) {
+  lib.deleteFile = async(attachment) => {
     const filePath = getFilePathOnStorage(attachment);
     const filePath = getFilePathOnStorage(attachment);
     return lib.deleteFileByFilePath(filePath);
     return lib.deleteFileByFilePath(filePath);
   };
   };
 
 
-  lib.deleteFiles = async function(attachments) {
-    if (!this.getIsUploadable()) {
+  lib.deleteFiles = async(attachments) => {
+    if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
     const s3 = S3Factory();
     const s3 = S3Factory();
@@ -132,11 +144,11 @@ module.exports = function(crowi) {
       Bucket: awsConfig.bucket,
       Bucket: awsConfig.bucket,
       Delete: { Objects: filePaths },
       Delete: { Objects: filePaths },
     };
     };
-    return s3.deleteObjects(totalParams).promise();
+    return s3.send(new DeleteObjectsCommand(totalParams));
   };
   };
 
 
-  lib.deleteFileByFilePath = async function(filePath) {
-    if (!this.getIsUploadable()) {
+  lib.deleteFileByFilePath = async(filePath) => {
+    if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
     const s3 = S3Factory();
     const s3 = S3Factory();
@@ -154,11 +166,11 @@ module.exports = function(crowi) {
       return;
       return;
     }
     }
 
 
-    return s3.deleteObject(params).promise();
+    return s3.send(new DeleteObjectCommand(params));
   };
   };
 
 
-  lib.uploadFile = function(fileStream, attachment) {
-    if (!this.getIsUploadable()) {
+  lib.uploadFile = async(fileStream, attachment) => {
+    if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
 
 
@@ -176,17 +188,11 @@ module.exports = function(crowi) {
       ACL: 'public-read',
       ACL: 'public-read',
     };
     };
 
 
-    return s3.upload(params).promise();
+    return s3.send(new PutObjectCommand(params));
   };
   };
 
 
-  /**
-   * Find data substance
-   *
-   * @param {Attachment} attachment
-   * @return {stream.Readable} readable stream
-   */
-  lib.findDeliveryFile = async function(attachment) {
-    if (!this.getIsReadable()) {
+  lib.findDeliveryFile = async(attachment) => {
+    if (!lib.getIsReadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
 
 
@@ -205,9 +211,9 @@ module.exports = function(crowi) {
       throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
       throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
     }
     }
 
 
-    let stream;
+    let stream : GetObjectCommandOutput['Body'];
     try {
     try {
-      stream = s3.getObject(params).createReadStream();
+      stream = (await s3.send(new GetObjectCommand(params))).Body;
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
@@ -218,12 +224,6 @@ module.exports = function(crowi) {
     return stream;
     return stream;
   };
   };
 
 
-  /**
-   * check the file size limit
-   *
-   * In detail, the followings are checked.
-   * - per-file size limit (specified by MAX_FILE_SIZE)
-   */
   lib.checkLimit = async(uploadFileSize) => {
   lib.checkLimit = async(uploadFileSize) => {
     const maxFileSize = crowi.configManager.getConfig('crowi', 'app:maxFileSize');
     const maxFileSize = crowi.configManager.getConfig('crowi', 'app:maxFileSize');
     const totalLimit = crowi.configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
     const totalLimit = crowi.configManager.getConfig('crowi', 'app:fileUploadTotalLimit');

+ 21 - 14
packages/app/src/server/service/installer.ts

@@ -1,23 +1,30 @@
-import mongoose from 'mongoose';
-import fs from 'graceful-fs';
 import path from 'path';
 import path from 'path';
+
 import ExtensibleCustomError from 'extensible-custom-error';
 import ExtensibleCustomError from 'extensible-custom-error';
+import fs from 'graceful-fs';
+import mongoose from 'mongoose';
 
 
+
+import { Lang } from '~/interfaces/lang';
 import { IPage } from '~/interfaces/page';
 import { IPage } from '~/interfaces/page';
 import { IUser } from '~/interfaces/user';
 import { IUser } from '~/interfaces/user';
-import { Lang } from '~/interfaces/lang';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateConfigsForInstalling } from '../models/config';
 import { generateConfigsForInstalling } from '../models/config';
 
 
-import SearchService from './search';
 import ConfigManager from './config-manager';
 import ConfigManager from './config-manager';
+import SearchService from './search';
 
 
 const logger = loggerFactory('growi:service:installer');
 const logger = loggerFactory('growi:service:installer');
 
 
 export class FailedToCreateAdminUserError extends ExtensibleCustomError {
 export class FailedToCreateAdminUserError extends ExtensibleCustomError {
 }
 }
 
 
+export type AutoInstallOptions = {
+  allowGuestMode?: boolean,
+  serverDate?: Date,
+}
+
 export class InstallerService {
 export class InstallerService {
 
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -43,14 +50,9 @@ export class InstallerService {
   }
   }
 
 
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
-
-    // TODO typescriptize models/user.js and remove eslint-disable-next-line
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const Page = mongoose.model('Page') as any;
-
     try {
     try {
       const markdown = fs.readFileSync(filePath);
       const markdown = fs.readFileSync(filePath);
-      return Page.create(pagePath, markdown, owner, {}) as IPage;
+      return this.crowi.pageService.create(pagePath, markdown, owner, {}) as IPage;
     }
     }
     catch (err) {
     catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
       logger.error(`Failed to create ${pagePath}`, err);
@@ -95,16 +97,21 @@ export class InstallerService {
   /**
   /**
    * Execute only once for installing application
    * Execute only once for installing application
    */
    */
-  private async initDB(globalLang: Lang): Promise<void> {
+  private async initDB(globalLang: Lang, options?: AutoInstallOptions): Promise<void> {
     const configManager: ConfigManager = this.crowi.configManager;
     const configManager: ConfigManager = this.crowi.configManager;
 
 
     const initialConfig = generateConfigsForInstalling();
     const initialConfig = generateConfigsForInstalling();
     initialConfig['app:globalLang'] = globalLang;
     initialConfig['app:globalLang'] = globalLang;
+
+    if (options?.allowGuestMode) {
+      initialConfig['security:restrictGuestMode'] = 'Readonly';
+    }
+
     return configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
     return configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
   }
   }
 
 
-  async install(firstAdminUserToSave: IUser, globalLang: Lang, initialPagesCreatedAt?: Date): Promise<IUser> {
-    await this.initDB(globalLang);
+  async install(firstAdminUserToSave: IUser, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
+    await this.initDB(globalLang, options);
 
 
     // TODO typescriptize models/user.js and remove eslint-disable-next-line
     // TODO typescriptize models/user.js and remove eslint-disable-next-line
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -142,7 +149,7 @@ export class InstallerService {
     await Promise.all([rootPage.save(), rootRevision.save()]);
     await Promise.all([rootPage.save(), rootRevision.save()]);
 
 
     // create initial pages
     // create initial pages
-    await this.createInitialPages(adminUser, globalLang, initialPagesCreatedAt);
+    await this.createInitialPages(adminUser, globalLang, options?.serverDate);
 
 
     return adminUser;
     return adminUser;
   }
   }

+ 72 - 3
packages/app/src/server/service/page-grant.ts

@@ -1,9 +1,10 @@
-import mongoose from 'mongoose';
 import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
 import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
+import mongoose from 'mongoose';
 
 
-import UserGroup from '~/server/models/user-group';
+import { IRecordApplicableGrant } from '~/interfaces/page-grant';
 import { PageDocument, PageModel } from '~/server/models/page';
 import { PageDocument, PageModel } from '~/server/models/page';
+import UserGroup from '~/server/models/user-group';
 import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 
 
 const { addTrailingSlash } = pathUtils;
 const { addTrailingSlash } = pathUtils;
@@ -226,7 +227,7 @@ class PageGrantService {
      */
      */
     const builderForAncestors = new PageQueryBuilder(Page.find(), false);
     const builderForAncestors = new PageQueryBuilder(Page.find(), false);
     if (!includeNotMigratedPages) {
     if (!includeNotMigratedPages) {
-      builderForAncestors.addConditionAsMigrated();
+      builderForAncestors.addConditionAsOnTree();
     }
     }
     const ancestors = await builderForAncestors
     const ancestors = await builderForAncestors
       .addConditionToListOnlyAncestors(targetPath)
       .addConditionToListOnlyAncestors(targetPath)
@@ -403,6 +404,74 @@ class PageGrantService {
     return [normalizable, nonNormalizable];
     return [normalizable, nonNormalizable];
   }
   }
 
 
+  async calcApplicableGrantData(page, user): Promise<IRecordApplicableGrant> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+
+    // Increment an object (type IRecordApplicableGrant)
+    // grant is never public, anyone with the link, nor specified
+    const data: IRecordApplicableGrant = {
+      [Page.GRANT_RESTRICTED]: null, // any page can be restricted
+    };
+
+    // -- Public only if top page
+    const isOnlyPublicApplicable = isTopPage(page.path);
+    if (isOnlyPublicApplicable) {
+      data[Page.GRANT_PUBLIC] = null;
+      return data;
+    }
+
+    // -- Any grant is allowed if parent is null
+    const isAnyGrantApplicable = page.parent == null;
+    if (isAnyGrantApplicable) {
+      data[Page.GRANT_PUBLIC] = null;
+      data[Page.GRANT_OWNER] = null;
+      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      return data;
+    }
+
+    const parent = await Page.findById(page.parent);
+    if (parent == null) {
+      throw Error('The page\'s parent does not exist.');
+    }
+
+    const {
+      grant, grantedUsers, grantedGroup,
+    } = parent;
+
+    if (grant === Page.GRANT_PUBLIC) {
+      data[Page.GRANT_PUBLIC] = null;
+      data[Page.GRANT_OWNER] = null;
+      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+    else if (grant === Page.GRANT_OWNER) {
+      const grantedUser = grantedUsers[0];
+
+      const isUserApplicable = grantedUser.toString() === user._id.toString();
+
+      if (isUserApplicable) {
+        data[Page.GRANT_OWNER] = null;
+      }
+    }
+    else if (grant === Page.GRANT_USER_GROUP) {
+      const group = await UserGroup.findById(grantedGroup);
+      if (group == null) {
+        throw Error('Group not found to calculate grant data.');
+      }
+
+      const applicableGroups = await UserGroupRelation.findGroupsWithDescendantsByGroupAndUser(group, user);
+
+      const isUserExistInGroup = await UserGroupRelation.countByGroupIdAndUser(group, user) > 0;
+
+      if (isUserExistInGroup) {
+        data[Page.GRANT_OWNER] = null;
+      }
+      data[Page.GRANT_USER_GROUP] = { applicableGroups };
+    }
+
+    return data;
+  }
+
 }
 }
 
 
 export default PageGrantService;
 export default PageGrantService;

+ 469 - 23
packages/app/src/server/service/page.ts

@@ -20,7 +20,7 @@ import { IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import {
 import {
-  CreateMethod, PageCreateOptions, PageModel, PageDocument,
+  CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision,
 } from '~/server/models/page';
 } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -262,7 +262,7 @@ class PageService {
       authority: IPageDeleteConfigValueToProcessValidation | null,
       authority: IPageDeleteConfigValueToProcessValidation | null,
       recursiveAuthority: IPageDeleteConfigValueToProcessValidation | null,
       recursiveAuthority: IPageDeleteConfigValueToProcessValidation | null,
   ): boolean {
   ): boolean {
-    const isAdmin = operator.admin;
+    const isAdmin = operator?.admin ?? false;
     const isOperator = operator?._id == null ? false : operator._id.equals(creatorId);
     const isOperator = operator?._id == null ? false : operator._id.equals(creatorId);
 
 
     if (isRecursively) {
     if (isRecursively) {
@@ -525,7 +525,7 @@ class PageService {
       newParent = await this.getParentAndforceCreateEmptyTree(page, newPagePath);
       newParent = await this.getParentAndforceCreateEmptyTree(page, newPagePath);
     }
     }
     else {
     else {
-      newParent = await Page.getParentAndFillAncestors(newPagePath, user);
+      newParent = await this.getParentAndFillAncestorsByUser(user, newPagePath);
     }
     }
 
 
     // 3. Put back target page to tree (also update the other attrs)
     // 3. Put back target page to tree (also update the other attrs)
@@ -979,12 +979,12 @@ class PageService {
     };
     };
     let duplicatedTarget;
     let duplicatedTarget;
     if (page.isEmpty) {
     if (page.isEmpty) {
-      const parent = await Page.getParentAndFillAncestors(newPagePath, user);
+      const parent = await this.getParentAndFillAncestorsByUser(user, newPagePath);
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
     }
     }
     else {
     else {
       await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
       await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
-      duplicatedTarget = await (Page.create as CreateMethod)(
+      duplicatedTarget = await (this.create as CreateMethod)(
         newPagePath, page.revision.body, user, options,
         newPagePath, page.revision.body, user, options,
       );
       );
     }
     }
@@ -1067,7 +1067,6 @@ class PageService {
   }
   }
 
 
   async duplicateV4(page, newPagePath, user, isRecursively) {
   async duplicateV4(page, newPagePath, user, isRecursively) {
-    const Page = this.crowi.model('Page');
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     // populate
     // populate
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
@@ -1080,7 +1079,7 @@ class PageService {
 
 
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
 
-    const createdPage = await Page.create(
+    const createdPage = await this.crowi.pageService.create(
       newPagePath, page.revision.body, user, options,
       newPagePath, page.revision.body, user, options,
     );
     );
     this.pageEvent.emit('duplicate', page, user);
     this.pageEvent.emit('duplicate', page, user);
@@ -1910,7 +1909,7 @@ class PageService {
     }
     }
 
 
     // 2. Revert target
     // 2. Revert target
-    const parent = await Page.getParentAndFillAncestors(newPath, user);
+    const parent = await this.getParentAndFillAncestorsByUser(user, newPath);
     const updatedPage = await Page.findByIdAndUpdate(page._id, {
     const updatedPage = await Page.findByIdAndUpdate(page._id, {
       $set: {
       $set: {
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null, parent: parent._id, descendantCount: 0,
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null, parent: parent._id, descendantCount: 0,
@@ -2255,13 +2254,28 @@ class PageService {
 
 
   async normalizeParentByPath(path: string, user): Promise<void> {
   async normalizeParentByPath(path: string, user): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    // This validation is not 100% correct since it ignores user to count
+    const builder = new PageQueryBuilder(Page.find());
+    builder.addConditionAsNotMigrated();
+    builder.addConditionToListWithDescendants(path);
+    const nEstimatedNormalizationTarget: number = await builder.query.exec('count');
+    if (nEstimatedNormalizationTarget === 0) {
+      throw Error('No page is available for conversion');
+    }
 
 
     const pages = await Page.findByPathAndViewer(path, user, null, false);
     const pages = await Page.findByPathAndViewer(path, user, null, false);
     if (pages == null || !Array.isArray(pages)) {
     if (pages == null || !Array.isArray(pages)) {
       throw Error('Something went wrong while converting pages.');
       throw Error('Something went wrong while converting pages.');
     }
     }
+
+
     if (pages.length === 0) {
     if (pages.length === 0) {
-      throw new V5ConversionError(`Could not find the page "${path}" to convert.`, V5ConversionErrCode.PAGE_NOT_FOUND);
+      const isForbidden = await Page.count({ path, isEmpty: false }) > 0;
+      if (isForbidden) {
+        throw new V5ConversionError('It is not allowed to convert this page.', V5ConversionErrCode.FORBIDDEN);
+      }
     }
     }
     if (pages.length > 1) {
     if (pages.length > 1) {
       throw new V5ConversionError(
       throw new V5ConversionError(
@@ -2270,10 +2284,33 @@ class PageService {
       );
       );
     }
     }
 
 
-    const page = pages[0];
-    const {
-      grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
-    } = page;
+    let page;
+    let systematicallyCreatedPage;
+
+    const shouldCreateNewPage = pages[0] == null;
+    if (shouldCreateNewPage) {
+      const notEmptyParent = await Page.findNotEmptyParentByPathRecursively(path);
+
+      const options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] | undefined } = {
+        grant: notEmptyParent.grant,
+        grantUserGroupId: notEmptyParent.grantedGroup,
+        grantedUsers: notEmptyParent.grantedUsers,
+      };
+
+      systematicallyCreatedPage = await this.createBySystem(
+        path,
+        '',
+        options,
+      );
+      page = systematicallyCreatedPage;
+    }
+    else {
+      page = pages[0];
+    }
+
+    const grant = page.grant;
+    const grantedUserIds = page.grantedUsers;
+    const grantedGroupId = page.grantedGroup;
 
 
     /*
     /*
      * UserGroup & Owner validation
      * UserGroup & Owner validation
@@ -2311,7 +2348,6 @@ class PageService {
       throw err;
       throw err;
     }
     }
 
 
-    // no await
     this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
     this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
   }
   }
 
 
@@ -2412,7 +2448,7 @@ class PageService {
       normalizedPage = await Page.findById(page._id);
       normalizedPage = await Page.findById(page._id);
     }
     }
     else {
     else {
-      const parent = await Page.getParentAndFillAncestors(page.path, user);
+      const parent = await this.getParentAndFillAncestorsByUser(user, page.path);
       normalizedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
       normalizedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
     }
     }
 
 
@@ -2466,7 +2502,7 @@ class PageService {
       const Page = mongoose.model('Page') as unknown as PageModel;
       const Page = mongoose.model('Page') as unknown as PageModel;
       const { PageQueryBuilder } = Page;
       const { PageQueryBuilder } = Page;
       const builder = new PageQueryBuilder(Page.findOne());
       const builder = new PageQueryBuilder(Page.findOne());
-      builder.addConditionAsMigrated();
+      builder.addConditionAsOnTree();
       builder.addConditionToListByPathsArray([page.path]);
       builder.addConditionToListByPathsArray([page.path]);
       const existingPage = await builder.query.exec();
       const existingPage = await builder.query.exec();
 
 
@@ -2509,18 +2545,19 @@ class PageService {
     }
     }
   }
   }
 
 
-  async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
+  async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<number> {
     // Save prevDescendantCount for sub-operation
     // Save prevDescendantCount for sub-operation
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
     const builder = new PageQueryBuilder(Page.findOne(), true);
     const builder = new PageQueryBuilder(Page.findOne(), true);
-    builder.addConditionAsMigrated();
+    builder.addConditionAsOnTree();
     builder.addConditionToListByPathsArray([page.path]);
     builder.addConditionToListByPathsArray([page.path]);
     const exPage = await builder.query.exec();
     const exPage = await builder.query.exec();
     const options = { prevDescendantCount: exPage?.descendantCount ?? 0 };
     const options = { prevDescendantCount: exPage?.descendantCount ?? 0 };
 
 
+    let count: number;
     try {
     try {
-      await this.normalizeParentRecursively([page.path], user);
+      count = await this.normalizeParentRecursively([page.path], user);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('V5 initial miration failed.', err);
       logger.error('V5 initial miration failed.', err);
@@ -2536,6 +2573,8 @@ class PageService {
     }
     }
 
 
     await this.normalizeParentRecursivelySubOperation(page, user, pageOp._id, options);
     await this.normalizeParentRecursivelySubOperation(page, user, pageOp._id, options);
+
+    return count;
   }
   }
 
 
   async normalizeParentRecursivelySubOperation(page, user, pageOpId: ObjectIdLike, options: {prevDescendantCount: number}): Promise<void> {
   async normalizeParentRecursivelySubOperation(page, user, pageOpId: ObjectIdLike, options: {prevDescendantCount: number}): Promise<void> {
@@ -2674,7 +2713,7 @@ class PageService {
    * @param user To be used to filter pages to update. If null, only public pages will be updated.
    * @param user To be used to filter pages to update. If null, only public pages will be updated.
    * @returns Promise<void>
    * @returns Promise<void>
    */
    */
-  async normalizeParentRecursively(paths: string[], user: any | null): Promise<void> {
+  async normalizeParentRecursively(paths: string[], user: any | null): Promise<number> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
@@ -2740,7 +2779,7 @@ class PageService {
 
 
   private async _normalizeParentRecursively(
   private async _normalizeParentRecursively(
       pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
       pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
-  ): Promise<void> {
+  ): Promise<number> {
     const BATCH_SIZE = 100;
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
     const PAGES_LIMIT = 1000;
 
 
@@ -2779,6 +2818,9 @@ class PageService {
     let nextCount = count;
     let nextCount = count;
     let nextSkiped = skiped;
     let nextSkiped = skiped;
 
 
+    // eslint-disable-next-line max-len
+    const buildPipelineToCreateEmptyPagesByUser = this.buildPipelineToCreateEmptyPagesByUser.bind(this);
+
     const migratePagesStream = new Writable({
     const migratePagesStream = new Writable({
       objectMode: true,
       objectMode: true,
       async write(pages, encoding, callback) {
       async write(pages, encoding, callback) {
@@ -2817,7 +2859,8 @@ class PageService {
           { path: { $nin: publicPathsToNormalize }, status: Page.STATUS_PUBLISHED },
           { path: { $nin: publicPathsToNormalize }, status: Page.STATUS_PUBLISHED },
         ];
         ];
         const filterForApplicableAncestors = { $or: orFilters };
         const filterForApplicableAncestors = { $or: orFilters };
-        await Page.createEmptyPagesByPaths(parentPaths, user, false, filterForApplicableAncestors);
+        const aggregationPipeline = await buildPipelineToCreateEmptyPagesByUser(user, parentPaths, false, filterForApplicableAncestors);
+        await Page.createEmptyPagesByPaths(parentPaths, aggregationPipeline);
 
 
         // 3. Find parents
         // 3. Find parents
         const addGrantCondition = (builder) => {
         const addGrantCondition = (builder) => {
@@ -2910,6 +2953,8 @@ class PageService {
 
 
     // End
     // End
     socket.emit(SocketEventName.PMEnded, { isSucceeded: true });
     socket.emit(SocketEventName.PMEnded, { isSucceeded: true });
+
+    return nextCount;
   }
   }
 
 
   private async _v5NormalizeIndex() {
   private async _v5NormalizeIndex() {
@@ -2963,7 +3008,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
 
 
     const builder = new PageQueryBuilder(Page.find(), true);
     const builder = new PageQueryBuilder(Page.find(), true);
-    builder.addConditionAsMigrated();
+    builder.addConditionAsOnTree();
     builder.addConditionToListWithDescendants(path);
     builder.addConditionToListWithDescendants(path);
     builder.addConditionToSortPagesByDescPath();
     builder.addConditionToSortPagesByDescPath();
 
 
@@ -3008,6 +3053,407 @@ class PageService {
     socket.emit(SocketEventName.UpdateDescCount, data);
     socket.emit(SocketEventName.UpdateDescCount, data);
   }
   }
 
 
+  /**
+   * Build the base aggregation pipeline for fillAncestors--- methods
+   * @param onlyMigratedAsExistingPages Determine whether to include non-migrated pages as existing pages. If a page exists,
+   * an empty page will not be created at that page's path.
+   */
+  private buildBasePipelineToCreateEmptyPages(paths: string[], onlyMigratedAsExistingPages = true, andFilter?): any[] {
+    const aggregationPipeline: any[] = [];
+
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // -- Filter by paths
+    aggregationPipeline.push({ $match: { path: { $in: paths } } });
+    // -- Normalized condition
+    if (onlyMigratedAsExistingPages) {
+      aggregationPipeline.push({
+        $match: {
+          $or: [
+            { grant: Page.GRANT_PUBLIC },
+            { parent: { $ne: null } },
+            { path: '/' },
+          ],
+        },
+      });
+    }
+    // -- Add custom pipeline
+    if (andFilter != null) {
+      aggregationPipeline.push({ $match: andFilter });
+    }
+
+    return aggregationPipeline;
+  }
+
+  private async buildPipelineToCreateEmptyPagesByUser(user, paths: string[], onlyMigratedAsExistingPages = true, andFilter?): Promise<any[]> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const pipeline = this.buildBasePipelineToCreateEmptyPages(paths, onlyMigratedAsExistingPages, andFilter);
+    let userGroups = null;
+    if (user != null) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+    const grantCondition = Page.generateGrantCondition(user, userGroups);
+    pipeline.push({ $match: grantCondition });
+
+    return pipeline;
+  }
+
+  private buildPipelineToCreateEmptyPagesBySystem(paths: string[]): any[] {
+    return this.buildBasePipelineToCreateEmptyPages(paths);
+  }
+
+  private async connectPageTree(path: string): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    const ancestorPaths = collectAncestorPaths(path);
+
+    // Find ancestors
+    const builder = new PageQueryBuilder(Page.find(), true);
+    builder.addConditionToFilterByApplicableAncestors(ancestorPaths); // avoid including not normalized pages
+    const ancestors = await builder
+      .addConditionToListByPathsArray(ancestorPaths)
+      .addConditionToSortPagesByDescPath()
+      .query
+      .exec();
+
+    // Update parent attrs
+    const ancestorsMap = new Map(); // Map<path, page>
+    ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page)); // the earlier element should be the true ancestor
+
+    const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
+    const operations = nonRootAncestors.map((page) => {
+      const parentPath = pathlib.dirname(page.path);
+      return {
+        updateOne: {
+          filter: {
+            _id: page._id,
+          },
+          update: {
+            parent: ancestorsMap.get(parentPath)._id,
+          },
+        },
+      };
+    });
+    await Page.bulkWrite(operations);
+  }
+
+  /**
+   * Find parent or create parent if not exists.
+   * It also updates parent of ancestors
+   * @param path string
+   * @returns Promise<PageDocument>
+   */
+  async getParentAndFillAncestorsByUser(user, path: string): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Find parent
+    const parent = await Page.findParentByPath(path);
+    if (parent != null) {
+      return parent;
+    }
+
+    const ancestorPaths = collectAncestorPaths(path);
+
+    // Fill ancestors
+    const aggregationPipeline: any[] = await this.buildPipelineToCreateEmptyPagesByUser(user, ancestorPaths);
+
+    await Page.createEmptyPagesByPaths(ancestorPaths, aggregationPipeline);
+
+    // Connect ancestors
+    await this.connectPageTree(path);
+
+    // Return the created parent
+    const createdParent = await Page.findParentByPath(path);
+    if (createdParent == null) {
+      throw Error('Failed to find the created parent by getParentAndFillAncestorsByUser');
+    }
+    return createdParent;
+  }
+
+  async getParentAndFillAncestorsBySystem(path: string): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Find parent
+    const parent = await Page.findParentByPath(path);
+    if (parent != null) {
+      return parent;
+    }
+
+    // Fill ancestors
+    const ancestorPaths = collectAncestorPaths(path);
+    const aggregationPipeline: any[] = this.buildPipelineToCreateEmptyPagesBySystem(ancestorPaths);
+
+    await Page.createEmptyPagesByPaths(ancestorPaths, aggregationPipeline);
+
+    // Connect ancestors
+    await this.connectPageTree(path);
+
+    // Return the created parent
+    const createdParent = await Page.findParentByPath(path);
+    if (createdParent == null) {
+      throw Error('Failed to find the created parent by getParentAndFillAncestorsByUser');
+    }
+
+    return createdParent;
+  }
+
+  // --------- Create ---------
+
+  private async preparePageDocumentToCreate(path: string, shouldNew: boolean): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const emptyPage = await Page.findOne({ path, isEmpty: true });
+
+    // Use empty page if exists, if not, create a new page
+    let page;
+    if (shouldNew) {
+      page = new Page();
+    }
+    else if (emptyPage != null) {
+      page = emptyPage;
+      const descendantCount = await Page.recountDescendantCount(page._id);
+
+      page.descendantCount = descendantCount;
+      page.isEmpty = false;
+    }
+    else {
+      page = new Page();
+    }
+
+    return page;
+  }
+
+  private setFieldExceptForGrantRevisionParent(
+      pageDocument: PageDocument,
+      path: string,
+      user?,
+  ): void {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    pageDocument.path = path;
+    pageDocument.creator = user;
+    pageDocument.lastUpdateUser = user;
+    pageDocument.status = Page.STATUS_PUBLISHED;
+  }
+
+  private async canProcessCreate(
+      path: string,
+      grantData: {
+        grant: number,
+        grantedUserIds?: ObjectIdLike[],
+        grantUserGroupId?: ObjectIdLike,
+      },
+      shouldValidateGrant: boolean,
+      user?,
+  ): Promise<boolean> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Operatability validation
+    const canOperate = await this.crowi.pageOperationService.canOperate(false, null, path);
+    if (!canOperate) {
+      logger.error(`Cannot operate create to path "${path}" right now.`);
+      return false;
+    }
+
+    // Existance validation
+    const isExist = (await Page.count({ path, isEmpty: false })) > 0; // not validate empty page
+    if (isExist) {
+      logger.error('Cannot create new page to existed path');
+      return false;
+    }
+
+    // UserGroup & Owner validation
+    const { grant, grantedUserIds, grantUserGroupId } = grantData;
+    if (shouldValidateGrant) {
+      if (user == null) {
+        throw Error('user is required to validate grant');
+      }
+
+      let isGrantNormalized = false;
+      try {
+        // It must check descendants as well if emptyTarget is not null
+        const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
+        const shouldCheckDescendants = isEmptyPageAlreadyExist;
+
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('The selected grant or grantedGroup is not assignable to this page.');
+      }
+    }
+
+    return true;
+  }
+
+  async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Switch method
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    if (!isV5Compatible) {
+      return Page.createV4(path, body, user, options);
+    }
+
+    // Values
+    // eslint-disable-next-line no-param-reassign
+    path = this.crowi.xss.process(path); // sanitize path
+    const {
+      format = 'markdown', grantUserGroupId,
+    } = options;
+    const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
+    const grantData = {
+      grant,
+      grantedUserIds: grant === Page.GRANT_OWNER ? [user._id] : undefined,
+      grantUserGroupId,
+    };
+
+    const isGrantRestricted = grant === Page.GRANT_RESTRICTED;
+
+    // Validate
+    const shouldValidateGrant = !isGrantRestricted;
+    const canProcessCreate = await this.canProcessCreate(path, grantData, shouldValidateGrant, user);
+    if (!canProcessCreate) {
+      throw Error('Cannnot process create');
+    }
+
+    // Prepare a page document
+    const shouldNew = isGrantRestricted;
+    const page = await this.preparePageDocumentToCreate(path, shouldNew);
+
+    // Set field
+    this.setFieldExceptForGrantRevisionParent(page, path, user);
+
+    // Apply scope
+    page.applyScope(user, grant, grantUserGroupId);
+
+    // Set parent
+    if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
+      page.parent = null;
+    }
+    else {
+      const parent = await this.getParentAndFillAncestorsByUser(user, path);
+      page.parent = parent._id;
+    }
+
+    // Save
+    let savedPage = await page.save();
+
+    // Create revision
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    savedPage = await pushRevision(savedPage, newRevision, user);
+    await savedPage.populateDataToShowRevision();
+
+    // Update descendantCount
+    await this.updateDescendantCountOfAncestors(savedPage._id, 1, false);
+
+    // Emit create event
+    this.pageEvent.emit('create', savedPage, user);
+
+    // Delete PageRedirect if exists
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+    try {
+      await PageRedirect.deleteOne({ fromPath: path });
+      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
+    }
+    catch (err) {
+      // no throw
+      logger.error('Failed to delete PageRedirect');
+    }
+
+    return savedPage;
+  }
+
+  private async canProcessCreateBySystem(
+      path: string,
+      grantData: {
+        grant: number,
+        grantedUserIds?: ObjectIdLike[],
+        grantUserGroupId?: ObjectIdLike,
+      },
+  ): Promise<boolean> {
+    return this.canProcessCreate(path, grantData, false);
+  }
+
+  async createBySystem(path: string, body: string, options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] }): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    if (!isV5Compatible) {
+      throw Error('This method is available only when v5 compatible');
+    }
+
+    // Values
+    // eslint-disable-next-line no-param-reassign
+    path = this.crowi.xss.process(path); // sanitize path
+
+    const {
+      format = 'markdown', grantUserGroupId, grantedUsers,
+    } = options;
+    const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
+
+    const isGrantRestricted = grant === Page.GRANT_RESTRICTED;
+    const isGrantOwner = grant === Page.GRANT_OWNER;
+
+    const grantData = {
+      grant,
+      grantedUserIds: isGrantOwner ? grantedUsers : undefined,
+      grantUserGroupId,
+    };
+
+    // Validate
+    if (isGrantOwner && grantedUsers?.length !== 1) {
+      throw Error('grantedUser must exist when grant is GRANT_OWNER');
+    }
+    const canProcessCreateBySystem = await this.canProcessCreateBySystem(path, grantData);
+    if (!canProcessCreateBySystem) {
+      throw Error('Cannnot process createBySystem');
+    }
+
+    // Prepare a page document
+    const shouldNew = isGrantRestricted;
+    const page = await this.preparePageDocumentToCreate(path, shouldNew);
+
+    // Set field
+    this.setFieldExceptForGrantRevisionParent(page, path);
+
+    // Apply scope
+    page.applyScope({ _id: grantedUsers?.[0] }, grant, grantUserGroupId);
+
+    // Set parent
+    if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
+      page.parent = null;
+    }
+    else {
+      const parent = await this.getParentAndFillAncestorsBySystem(path);
+      page.parent = parent._id;
+    }
+
+    // Save
+    let savedPage = await page.save();
+
+    // Create revision
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const dummyUser = { _id: new mongoose.Types.ObjectId() };
+    const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser, { format });
+    savedPage = await pushRevision(savedPage, newRevision, dummyUser);
+
+    // Update descendantCount
+    await this.updateDescendantCountOfAncestors(savedPage._id, 1, false);
+
+    // Emit create event
+    this.pageEvent.emit('create', savedPage, dummyUser);
+
+    return savedPage;
+  }
+
 }
 }
 
 
 export default PageService;
 export default PageService;

+ 1 - 1
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -21,7 +21,7 @@ class CreatePageService {
 
 
     // generate a dummy id because Operation to create a page needs ObjectId
     // generate a dummy id because Operation to create a page needs ObjectId
     const dummyObjectIdOfUser = userId != null ? userId : new mongoose.Types.ObjectId();
     const dummyObjectIdOfUser = userId != null ? userId : new mongoose.Types.ObjectId();
-    const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
+    const page = await this.crowi.pageService.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
 
 
     // Send a message when page creation is complete
     // Send a message when page creation is complete
     const growiUri = this.crowi.appService.getSiteUrl();
     const growiUri = this.crowi.appService.getSiteUrl();

+ 1 - 1
packages/app/src/server/util/createGrowiPagesFromImports.js

@@ -27,7 +27,7 @@ module.exports = (crowi) => {
 
 
       if (isCreatableName && !isPageNameTaken) {
       if (isCreatableName && !isPageNameTaken) {
         try {
         try {
-          const promise = Page.create(path, body, user, { grant: Page.GRANT_PUBLIC, grantUserGroupId: null });
+          const promise = crowi.pageService.create(path, body, user, { grant: Page.GRANT_PUBLIC, grantUserGroupId: null });
           promises.push(promise);
           promises.push(promise);
         }
         }
         catch (err) {
         catch (err) {

+ 7 - 2
packages/app/src/server/util/middlewares.js

@@ -4,10 +4,10 @@ import loggerFactory from '~/utils/logger';
 // all new middlewares should be an independent file under /server/middlewares
 // all new middlewares should be an independent file under /server/middlewares
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 
 
-const { formatDistanceStrict } = require('date-fns');
 const { pathUtils } = require('@growi/core');
 const { pathUtils } = require('@growi/core');
-const md5 = require('md5');
+const { formatDistanceStrict } = require('date-fns');
 const entities = require('entities');
 const entities = require('entities');
+const md5 = require('md5');
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:lib:middlewares');
 const logger = loggerFactory('growi:lib:middlewares');
@@ -153,6 +153,11 @@ module.exports = (crowi) => {
         return list.slice(start, end);
         return list.slice(start, end);
       });
       });
 
 
+      swig.setFilter('push', (list, element) => {
+        list.push(element);
+        return list;
+      });
+
       next();
       next();
     };
     };
   };
   };

+ 0 - 1
packages/app/src/server/views/layout-growi/not_found.html

@@ -15,7 +15,6 @@
   </div>
   </div>
   <div class="grw-container-convertible">
   <div class="grw-container-convertible">
     {% include '../widget/page_alerts.html' %}
     {% include '../widget/page_alerts.html' %}
-    <div id="not-found-alert"></div>
   </div>
   </div>
 {% endblock %}
 {% endblock %}
 
 

+ 4 - 2
packages/app/src/server/views/layout/layout.html

@@ -59,11 +59,13 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block html_body %}
 {% block html_body %}
+{% set additionalBodyClasses = []; %}
+{% block html_additional_body_classes %}{% endblock %}
 {% if getConfig('crowi', 'customize:isContainerFluid') %}
 {% if getConfig('crowi', 'customize:isContainerFluid') %}
-  {% set additionalBodyClass = 'growi-layout-fluid' %}
+  {% set additionalBodyClasses = additionalBodyClasses|push('growi-layout-fluid') %}
 {% endif %}
 {% endif %}
 <body
 <body
-  class="{% block html_base_css %}{% endblock %} growi {{ additionalBodyClass }}"
+  class="{% block html_base_css %}{% endblock %} growi {{ additionalBodyClasses|join(' ') }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"
   {% block html_base_attr %}{% endblock %}
   {% block html_base_attr %}{% endblock %}
   data-csrftoken="{{ csrf() }}"
   data-csrftoken="{{ csrf() }}"

+ 3 - 1
packages/app/src/server/views/private-legacy-pages.html

@@ -12,7 +12,9 @@
 {% endblock %}
 {% endblock %}
 
 
 <!-- add .on-search to body tag class in layout -->
 <!-- add .on-search to body tag class in layout -->
-{% set additionalBodyClass = 'on-search' %}
+{% block html_additional_body_classes %}
+  {% set additionalBodyClasses = additionalBodyClasses|push('on-search') %}
+{% endblock %}
 
 
 {% block layout_main %}
 {% block layout_main %}
 <div id="grw-fav-sticky-trigger" class="sticky-top"></div>
 <div id="grw-fav-sticky-trigger" class="sticky-top"></div>

+ 3 - 1
packages/app/src/server/views/search.html

@@ -12,7 +12,9 @@
 {% endblock %}
 {% endblock %}
 
 
 <!-- add .on-search to body tag class in layout -->
 <!-- add .on-search to body tag class in layout -->
-{% set additionalBodyClass = 'on-search' %}
+{% block html_additional_body_classes %}
+  {% set additionalBodyClasses = additionalBodyClasses|push('on-search') %}
+{% endblock %}
 
 
 {% block layout_main %}
 {% block layout_main %}
 <div id="grw-fav-sticky-trigger" class="sticky-top"></div>
 <div id="grw-fav-sticky-trigger" class="sticky-top"></div>

+ 2 - 0
packages/app/src/server/views/widget/page_alerts.html

@@ -69,5 +69,7 @@
     {% if isTrashPage(page.path) %}
     {% if isTrashPage(page.path) %}
       <div id="trash-page-alert"></div>
       <div id="trash-page-alert"></div>
     {% endif %}
     {% endif %}
+
+    <div id="fix-page-grant-alert"></div>
   </div>
   </div>
 </div>
 </div>

+ 1 - 0
packages/app/src/server/views/widget/page_content.html

@@ -25,6 +25,7 @@
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
+  data-has-parent="{{ page.parent != null }}"
   >
   >
 {% else %}
 {% else %}
 <div id="content-main" class="content-main d-flex"
 <div id="content-main" class="content-main d-flex"

+ 4 - 0
packages/app/src/stores/context.tsx

@@ -156,6 +156,10 @@ export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRRespon
   return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
   return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
 };
 };
 
 
+export const useHasParent = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('hasParent', initialData);
+};
+
 export const useIsIndentSizeForced = (initialData?: boolean) : SWRResponse<boolean, Error> => {
 export const useIsIndentSizeForced = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isIndentSizeForced', initialData);
   return useStaticSWR<boolean, Error>('isIndentSizeForced', initialData);
 };
 };

+ 25 - 1
packages/app/src/stores/page.tsx

@@ -1,12 +1,13 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
-import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
 
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import {
 import {
   IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta,
   IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
+import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
 
 
 import { apiGet } from '../client/util/apiv1-client';
 import { apiGet } from '../client/util/apiv1-client';
@@ -146,3 +147,26 @@ export const useSWRxPageInfoForList = (
     },
     },
   };
   };
 };
 };
+
+/*
+ * Grant normalization fetching hooks
+ */
+export const useSWRxIsGrantNormalized = (
+    pageId: string | null | undefined,
+): SWRResponse<IResIsGrantNormalized, Error> => {
+
+  return useSWRImmutable(
+    pageId != null ? ['/page/is-grant-normalized', pageId] : null,
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
+  );
+};
+
+export const useSWRxApplicableGrant = (
+    pageId: string | null | undefined,
+): SWRResponse<IRecordApplicableGrant, Error> => {
+
+  return useSWRImmutable(
+    pageId != null ? ['/page/applicable-grant', pageId] : null,
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
+  );
+};

+ 2 - 2
packages/app/src/styles/_layout.scss

@@ -66,8 +66,8 @@ body.growi-layout-fluid .grw-container-convertible {
 
 
 .grw-side-contents-sticky-container {
 .grw-side-contents-sticky-container {
   position: sticky;
   position: sticky;
-  // growisubnavigation + grw-navbar-boder
-  top: calc(100px + 4px);
+  // growisubnavigation + grw-navbar-boder + some spacing
+  top: calc(100px + 4px + 20px);
   margin-top: 5px;
   margin-top: 5px;
 }
 }
 
 

+ 3 - 3
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -277,11 +277,11 @@ ul.pagination {
   // Pagetree
   // Pagetree
   .grw-pagetree {
   .grw-pagetree {
     @include override-list-group-item-for-pagetree(
     @include override-list-group-item-for-pagetree(
-      $gray-200,
+      $color-sidebar-context,
       lighten($bgcolor-sidebar-context, 8%),
       lighten($bgcolor-sidebar-context, 8%),
       lighten($bgcolor-sidebar-context, 15%),
       lighten($bgcolor-sidebar-context, 15%),
-      $gray-500,
-      $gray-200,
+      darken($color-sidebar-context, 15%),
+      darken($color-sidebar-context, 10%),
       lighten($bgcolor-sidebar-context, 18%),
       lighten($bgcolor-sidebar-context, 18%),
       lighten($bgcolor-sidebar-context, 24%)
       lighten($bgcolor-sidebar-context, 24%)
     );
     );

+ 0 - 0
packages/app/test/cypress/integration/1-install/install.spec.ts → packages/app/test/cypress/integration/10-install/install.spec.ts


+ 0 - 1
packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts → packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts

@@ -1,4 +1,3 @@
-
 context('Access to page', () => {
 context('Access to page', () => {
   const ssPrefix = 'access-to-page-';
   const ssPrefix = 'access-to-page-';
 
 

+ 0 - 0
packages/app/test/cypress/integration/2-basic-features/use-tools.spec.ts → packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts


+ 81 - 0
packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts

@@ -0,0 +1,81 @@
+context('Access to page by guest', () => {
+  const ssPrefix = 'access-to-page-by-guest-';
+
+  beforeEach(() => {
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it('/Sandbox is successfully loaded', () => {
+    cy.visit('/Sandbox', {  });
+    cy.screenshot(`${ssPrefix}-sandbox`);
+  });
+
+  it('/Sandbox with anchor hash is successfully loaded', () => {
+    cy.visit('/Sandbox#Headers');
+
+    // hide fab
+    cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
+
+    cy.screenshot(`${ssPrefix}-sandbox-headers`);
+  });
+
+  it('/Sandbox/Math is successfully loaded', () => {
+    cy.visit('/Sandbox/Math');
+    cy.screenshot(`${ssPrefix}-sandbox-math`);
+  });
+
+  it('/Sandbox with edit is successfully loaded', () => {
+    cy.visit('/Sandbox#edit');
+    cy.screenshot(`${ssPrefix}-sandbox-edit-page`);
+  })
+
+});
+
+
+context('Access to /me page', () => {
+  const ssPrefix = 'access-to-me-page-by-guest-';
+
+  beforeEach(() => {
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it('/me should be redirected to /login', () => {
+    cy.visit('/me', {  });
+    cy.screenshot(`${ssPrefix}-me`);
+  });
+
+});
+
+
+context('Access to special pages by guest', () => {
+  const ssPrefix = 'access-to-special-pages-by-guest-';
+
+  beforeEach(() => {
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it('/trash is successfully loaded', () => {
+    cy.visit('/trash', {  });
+    cy.getByTestid('trash-page-list').should('be.visible');
+    cy.screenshot(`${ssPrefix}-trash`);
+  });
+
+  it('/tags is successfully loaded', () => {
+    cy.visit('/tags');
+
+    // open sidebar
+    cy.collapseSidebar(false);
+    // select tags
+    cy.getByTestid('grw-sidebar-nav-primary-tags').click();
+    cy.getByTestid('grw-sidebar-content-tags').should('be.visible');
+    cy.getByTestid('grw-tags-list').should('be.visible');
+    cy.getByTestid('grw-tags-list').contains('You have no tag, You can set tags on pages');
+
+    cy.getByTestid('tags-page').should('be.visible');
+    cy.screenshot(`${ssPrefix}-tags`);
+  });
+
+});

+ 0 - 0
packages/app/test/cypress/integration/3-search/search.spec.ts → packages/app/test/cypress/integration/30-search/search.spec.ts


+ 0 - 0
packages/app/test/cypress/integration/4-admin/access-to-admin-page.spec.ts → packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts


+ 0 - 0
packages/app/test/cypress/integration/5-switch-sidebar-mode/switching-sidebar-mode.spec.ts → packages/app/test/cypress/integration/50-switch-sidebar-mode/switching-sidebar-mode.spec.ts


+ 0 - 0
packages/app/test/cypress/integration/6-home/home.spec.ts → packages/app/test/cypress/integration/60-home/home.spec.ts


+ 12 - 12
packages/app/test/integration/models/v5.page.test.js

@@ -511,13 +511,13 @@ describe('Page', () => {
   describe('create', () => {
   describe('create', () => {
 
 
     test('Should create single page', async() => {
     test('Should create single page', async() => {
-      const page = await Page.create('/v5_create1', 'create1', dummyUser1, {});
+      const page = await crowi.pageService.create('/v5_create1', 'create1', dummyUser1, {});
       expect(page).toBeTruthy();
       expect(page).toBeTruthy();
       expect(page.parent).toStrictEqual(rootPage._id);
       expect(page.parent).toStrictEqual(rootPage._id);
     });
     });
 
 
     test('Should create empty-child and non-empty grandchild', async() => {
     test('Should create empty-child and non-empty grandchild', async() => {
-      const grandchildPage = await Page.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
+      const grandchildPage = await crowi.pageService.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
       const childPage = await Page.findOne({ path: '/v5_empty_create2' });
       const childPage = await Page.findOne({ path: '/v5_empty_create2' });
 
 
       expect(childPage.isEmpty).toBe(true);
       expect(childPage.isEmpty).toBe(true);
@@ -531,7 +531,7 @@ describe('Page', () => {
       const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
       const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
       expect(beforeCreatePage.isEmpty).toBe(true);
       expect(beforeCreatePage.isEmpty).toBe(true);
 
 
-      const childPage = await Page.create('/v5_empty_create_4', 'body', dummyUser1, {});
+      const childPage = await crowi.pageService.create('/v5_empty_create_4', 'body', dummyUser1, {});
       const grandchildPage = await Page.findOne({ parent: childPage._id });
       const grandchildPage = await Page.findOne({ parent: childPage._id });
 
 
       expect(childPage).toBeTruthy();
       expect(childPage).toBeTruthy();
@@ -557,7 +557,7 @@ describe('Page', () => {
         expect(page3).toBeNull();
         expect(page3).toBeNull();
 
 
         // use existing path
         // use existing path
-        await Page.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await crowi.pageService.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
 
         const _pageT = await Page.findOne({ path: pathT });
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
@@ -582,7 +582,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page1).toBeTruthy();
         expect(page2).toBeNull();
         expect(page2).toBeNull();
 
 
-        await Page.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await crowi.pageService.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
 
         const _pageT = await Page.findOne({ path: pathT });
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
@@ -814,7 +814,7 @@ describe('Page', () => {
   describe('getParentAndFillAncestors', () => {
   describe('getParentAndFillAncestors', () => {
     test('return parent if exist', async() => {
     test('return parent if exist', async() => {
       const page1 = await Page.findOne({ path: '/PAF1' });
       const page1 = await Page.findOne({ path: '/PAF1' });
-      const parent = await Page.getParentAndFillAncestors(page1.path, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, page1.path);
       expect(parent).toBeTruthy();
       expect(parent).toBeTruthy();
       expect(page1.parent).toStrictEqual(parent._id);
       expect(page1.parent).toStrictEqual(parent._id);
     });
     });
@@ -829,7 +829,7 @@ describe('Page', () => {
       expect(_page2).toBeNull();
       expect(_page2).toBeNull();
       expect(_page3).toBeNull();
       expect(_page3).toBeNull();
 
 
-      const parent = await Page.getParentAndFillAncestors(path3, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, path3);
       const page1 = await Page.findOne({ path: path1 });
       const page1 = await Page.findOne({ path: path1 });
       const page2 = await Page.findOne({ path: path2 });
       const page2 = await Page.findOne({ path: path2 });
       const page3 = await Page.findOne({ path: path3 });
       const page3 = await Page.findOne({ path: path3 });
@@ -854,7 +854,7 @@ describe('Page', () => {
       expect(_page1).toBeTruthy();
       expect(_page1).toBeTruthy();
       expect(_page2).toBeTruthy();
       expect(_page2).toBeTruthy();
 
 
-      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
       const page1 = await Page.findOne({ path: path1, isEmpty: true }); // parent
       const page1 = await Page.findOne({ path: path1, isEmpty: true }); // parent
       const page2 = await Page.findOne({ path: path2, isEmpty: false });
       const page2 = await Page.findOne({ path: path2, isEmpty: false });
 
 
@@ -877,7 +877,7 @@ describe('Page', () => {
       expect(_page3).toBeTruthy();
       expect(_page3).toBeTruthy();
       expect(_page3.parent).toBeNull();
       expect(_page3.parent).toBeNull();
 
 
-      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
       const page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
       const page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
       const page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
       const page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
       const page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
       const page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
@@ -920,7 +920,7 @@ describe('Page', () => {
       expect(_emptyA).toBeNull();
       expect(_emptyA).toBeNull();
       expect(_emptyAB).toBeNull();
       expect(_emptyAB).toBeNull();
 
 
-      const parent = await Page.getParentAndFillAncestors('/get_parent_A/get_parent_B/get_parent_C', dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_A/get_parent_B/get_parent_C');
 
 
       const pageA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: false });
@@ -966,7 +966,7 @@ describe('Page', () => {
       expect(_emptyC).toBeNull();
       expect(_emptyC).toBeNull();
       expect(_emptyCD).toBeNull();
       expect(_emptyCD).toBeNull();
 
 
-      const parent = await Page.getParentAndFillAncestors('/get_parent_C/get_parent_D/get_parent_E', dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_C/get_parent_D/get_parent_E');
 
 
       const pageC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: false });
@@ -985,7 +985,7 @@ describe('Page', () => {
       expect(pageCD.parent).toStrictEqual(pageC._id);
       expect(pageCD.parent).toStrictEqual(pageC._id);
 
 
       // -- Check the found parent
       // -- Check the found parent
-      expect(parent).toStrictEqual(pageCD);
+      expect(parent.toObject()).toStrictEqual(pageCD.toObject());
     });
     });
   });
   });
 });
 });

+ 223 - 35
packages/app/test/integration/service/v5.migration.test.js

@@ -34,6 +34,16 @@ describe('V5 page migration', () => {
   const pageId10 = new mongoose.Types.ObjectId();
   const pageId10 = new mongoose.Types.ObjectId();
   const pageId11 = new mongoose.Types.ObjectId();
   const pageId11 = new mongoose.Types.ObjectId();
 
 
+  const public = filter => ({ grant: Page.GRANT_PUBLIC, ...filter });
+  const ownedByTestUser1 = filter => ({ grant: Page.GRANT_OWNER, grantedUsers: [testUser1._id], ...filter });
+  const root = filter => ({ grantedUsers: [rootUser._id], ...filter });
+  const rootUserGroup = filter => ({ grantedGroup: rootUserGroupId, ...filter });
+  const testUser1Group = filter => ({ grantedGroup: testUser1GroupId, ...filter });
+
+  const normalized = { parent: { $ne: null } };
+  const notNormalized = { parent: null };
+  const empty = { isEmpty: true };
+
   beforeAll(async() => {
   beforeAll(async() => {
     jest.restoreAllMocks();
     jest.restoreAllMocks();
 
 
@@ -392,14 +402,6 @@ describe('V5 page migration', () => {
      *     - /normalize_g/normalize_i/normalize_k (only me) is normalized
      *     - /normalize_g/normalize_i/normalize_k (only me) is normalized
      */
      */
 
 
-    const public = filter => ({ grant: Page.GRANT_PUBLIC, ...filter });
-    const owned = filter => ({ grant: Page.GRANT_OWNER, grantedUsers: [testUser1._id], ...filter });
-    const testUser1Group = filter => ({ grantedGroup: testUser1GroupId, ...filter });
-
-    const normalized = { parent: { $ne: null } };
-    const notNormalized = { parent: null };
-    const empty = { isEmpty: true };
-
     beforeAll(async() => {
     beforeAll(async() => {
       // Prepare data
       // Prepare data
       const id1 = new mongoose.Types.ObjectId();
       const id1 = new mongoose.Types.ObjectId();
@@ -555,10 +557,10 @@ describe('V5 page migration', () => {
 
 
     test('should replace all unnecessary empty pages and normalization succeeds', async() => {
     test('should replace all unnecessary empty pages and normalization succeeds', async() => {
       const _pageG = await Page.findOne(public({ path: '/normalize_g', ...normalized }));
       const _pageG = await Page.findOne(public({ path: '/normalize_g', ...normalized }));
-      const _pageGH = await Page.findOne(owned({ path: '/normalize_g/normalize_h', ...notNormalized }));
-      const _pageGI = await Page.findOne(owned({ path: '/normalize_g/normalize_i', ...notNormalized }));
-      const _pageGHJ = await Page.findOne(owned({ path: '/normalize_g/normalize_h/normalize_j', ...notNormalized }));
-      const _pageGIK = await Page.findOne(owned({ path: '/normalize_g/normalize_i/normalize_k', ...notNormalized }));
+      const _pageGH = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_h', ...notNormalized }));
+      const _pageGI = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_i', ...notNormalized }));
+      const _pageGHJ = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_h/normalize_j', ...notNormalized }));
+      const _pageGIK = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_i/normalize_k', ...notNormalized }));
 
 
       expect(_pageG).not.toBeNull();
       expect(_pageG).not.toBeNull();
       expect(_pageGH).not.toBeNull();
       expect(_pageGH).not.toBeNull();
@@ -608,8 +610,8 @@ describe('V5 page migration', () => {
       expect(pageGIK.descendantCount).toStrictEqual(0);
       expect(pageGIK.descendantCount).toStrictEqual(0);
 
 
       // -- not normalized pages
       // -- not normalized pages
-      const pageGH = await Page.findOne(owned({ path: '/normalize_g/normalize_h' }));
-      const pageGI = await Page.findOne(owned({ path: '/normalize_g/normalize_i' }));
+      const pageGH = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_h' }));
+      const pageGI = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_i' }));
       // Check existence
       // Check existence
       expect(pageGH).not.toBeNull();
       expect(pageGH).not.toBeNull();
       expect(pageGI).not.toBeNull();
       expect(pageGI).not.toBeNull();
@@ -655,15 +657,6 @@ describe('V5 page migration', () => {
      *     - E and F are NOT normalized
      *     - E and F are NOT normalized
      */
      */
 
 
-    const owned = filter => ({ grantedUsers: [testUser1._id], ...filter });
-    const root = filter => ({ grantedUsers: [rootUser._id], ...filter });
-    const rootUserGroup = filter => ({ grantedGroup: rootUserGroupId, ...filter });
-    const testUser1Group = filter => ({ grantedGroup: testUser1GroupId, ...filter });
-
-    const normalized = { parent: { $ne: null } };
-    const notNormalized = { parent: null };
-    const empty = { isEmpty: true };
-
     beforeAll(async() => {
     beforeAll(async() => {
       // Prepare data
       // Prepare data
       const id17 = new mongoose.Types.ObjectId();
       const id17 = new mongoose.Types.ObjectId();
@@ -787,10 +780,10 @@ describe('V5 page migration', () => {
 
 
 
 
     test('Should normalize a single page without including other pages', async() => {
     test('Should normalize a single page without including other pages', async() => {
-      const _owned13 = await Page.findOne(owned({ path: '/normalize_13_owned', ...notNormalized }));
-      const _owned14 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned', ...notNormalized }));
-      const _owned15 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned', ...notNormalized }));
-      const _owned16 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_owned', ...notNormalized }));
+      const _owned13 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned', ...notNormalized }));
+      const _owned14 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned/normalize_14_owned', ...notNormalized }));
+      const _owned15 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned', ...notNormalized }));
+      const _owned16 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_owned', ...notNormalized }));
       const _root16 = await Page.findOne(root({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_root', ...notNormalized }));
       const _root16 = await Page.findOne(root({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_root', ...notNormalized }));
       const _group16 = await Page.findOne(testUser1Group({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_group', ...notNormalized }));
       const _group16 = await Page.findOne(testUser1Group({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_group', ...notNormalized }));
 
 
@@ -836,10 +829,10 @@ describe('V5 page migration', () => {
     });
     });
 
 
     test('Should normalize pages recursively excluding the pages not selected', async() => {
     test('Should normalize pages recursively excluding the pages not selected', async() => {
-      const _owned17 = await Page.findOne(owned({ path: '/normalize_17_owned', ...normalized }));
-      const _owned18 = await Page.findOne(owned({ path: '/normalize_17_owned/normalize_18_owned', ...normalized }));
-      const _owned19 = await Page.findOne(owned({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned', ...notNormalized }));
-      const _owned20 = await Page.findOne(owned({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_owned', ...notNormalized }));
+      const _owned17 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned', ...normalized }));
+      const _owned18 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned/normalize_18_owned', ...normalized }));
+      const _owned19 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned', ...notNormalized }));
+      const _owned20 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_owned', ...notNormalized }));
       const _root20 = await Page.findOne(root({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_root', ...notNormalized }));
       const _root20 = await Page.findOne(root({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_root', ...notNormalized }));
       const _group20 = await Page.findOne(rootUserGroup({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_group', ...notNormalized }));
       const _group20 = await Page.findOne(rootUserGroup({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_group', ...notNormalized }));
 
 
@@ -884,11 +877,11 @@ describe('V5 page migration', () => {
     });
     });
 
 
     test('Should normalize pages recursively excluding the pages of not user\'s & Should delete unnecessary empty pages', async() => {
     test('Should normalize pages recursively excluding the pages of not user\'s & Should delete unnecessary empty pages', async() => {
-      const _owned21 = await Page.findOne(owned({ path: '/normalize_21_owned', ...normalized }));
-      const _owned22 = await Page.findOne(owned({ path: '/normalize_21_owned/normalize_22_owned', ...normalized }));
-      const _owned23 = await Page.findOne(owned({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...notNormalized }));
+      const _owned21 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned', ...normalized }));
+      const _owned22 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned/normalize_22_owned', ...normalized }));
+      const _owned23 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...notNormalized }));
       const _empty23 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...normalized, ...empty });
       const _empty23 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...normalized, ...empty });
-      const _owned24 = await Page.findOne(owned({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_owned', ...normalized }));
+      const _owned24 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_owned', ...normalized }));
       const _root24 = await Page.findOne(root({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_root', ...notNormalized }));
       const _root24 = await Page.findOne(root({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_root', ...notNormalized }));
       const _rootGroup24 = await Page.findOne(rootUserGroup({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_rootGroup', ...notNormalized }));
       const _rootGroup24 = await Page.findOne(rootUserGroup({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_rootGroup', ...notNormalized }));
 
 
@@ -1116,4 +1109,199 @@ describe('V5 page migration', () => {
     expect(privatePage).toStrictEqual(expectedPrivatePage);
     expect(privatePage).toStrictEqual(expectedPrivatePage);
   });
   });
 
 
+  describe('normalizeParentByPath', () => {
+    const normalizeParentByPath = async(path, user) => {
+      const mock = jest.spyOn(crowi.pageService, 'normalizeParentRecursivelyMainOperation').mockReturnValue(null);
+      const result = await crowi.pageService.normalizeParentByPath(path, user);
+      const args = mock.mock.calls[0];
+
+      mock.mockRestore();
+
+      await crowi.pageService.normalizeParentRecursivelyMainOperation(...args);
+
+      return result;
+    };
+
+    beforeAll(async() => {
+      const pageIdD = new mongoose.Types.ObjectId();
+      const pageIdG = new mongoose.Types.ObjectId();
+
+      await Page.insertMany([
+        {
+          path: '/norm_parent_by_path_A',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          creator: testUser1._id,
+          lastUpdateUser: testUser1._id,
+          parent: rootPage._id,
+        },
+        {
+          path: '/norm_parent_by_path_B/norm_parent_by_path_C',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [rootUser._id],
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+        },
+        {
+          _id: pageIdD,
+          path: '/norm_parent_by_path_D',
+          isEmpty: true,
+          parent: rootPage._id,
+          descendantCount: 1,
+        },
+        {
+          path: '/norm_parent_by_path_D/norm_parent_by_path_E',
+          grant: Page.GRANT_PUBLIC,
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+          parent: pageIdD,
+        },
+        {
+          path: '/norm_parent_by_path_D/norm_parent_by_path_F',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [rootUser._id],
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+        },
+        {
+          _id: pageIdG,
+          path: '/norm_parent_by_path_G',
+          grant: Page.GRANT_PUBLIC,
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+          parent: rootPage._id,
+          descendantCount: 1,
+        },
+        {
+          path: '/norm_parent_by_path_G/norm_parent_by_path_H',
+          grant: Page.GRANT_PUBLIC,
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+          parent: pageIdG,
+        },
+        {
+          path: '/norm_parent_by_path_G/norm_parent_by_path_I',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [rootUser._id],
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+        },
+      ]);
+    });
+
+    test('should fail when the user is not allowed to edit the target page found by path', async() => {
+      const pageTestUser1 = await Page.findOne(ownedByTestUser1({ path: '/norm_parent_by_path_A' }));
+
+      expect(pageTestUser1).not.toBeNull();
+
+      await expect(normalizeParentByPath('/norm_parent_by_path_A', rootUser)).rejects.toThrowError();
+    });
+
+    test('should normalize all granted pages under the path when no page exists at the path', async() => {
+      const _pageB = await Page.findOne({ path: '/norm_parent_by_path_B' });
+      const _pageBC = await Page.findOne(root({ path: '/norm_parent_by_path_B/norm_parent_by_path_C' }));
+
+      expect(_pageB).toBeNull();
+      expect(_pageBC).not.toBeNull();
+
+      await normalizeParentByPath('/norm_parent_by_path_B', rootUser);
+
+      const pagesB = await Page.find({ path: '/norm_parent_by_path_B' }); // did not exist before running normalizeParentByPath
+      const pageBC = await Page.findById(_pageBC._id);
+
+      // -- check count
+      expect(pagesB.length).toBe(1);
+
+      const pageB = pagesB[0];
+
+      // -- check existance
+      expect(pageB.path).toBe('/norm_parent_by_path_B');
+      expect(pageBC.path).toBe('/norm_parent_by_path_B/norm_parent_by_path_C');
+
+      // -- check parent
+      expect(pageB.parent).toStrictEqual(rootPage._id);
+      expect(pageBC.parent).toStrictEqual(pageB._id);
+
+      // -- check descendantCount
+      expect(pageB.descendantCount).toBe(1);
+      expect(pageBC.descendantCount).toBe(0);
+    });
+
+    test('should normalize all granted pages under the path when an empty page exists at the path', async() => {
+      const _emptyD = await Page.findOne({ path: '/norm_parent_by_path_D', ...empty, ...normalized });
+      const _pageDE = await Page.findOne(public({ path: '/norm_parent_by_path_D/norm_parent_by_path_E', ...normalized }));
+      const _pageDF = await Page.findOne(root({ path: '/norm_parent_by_path_D/norm_parent_by_path_F', ...notNormalized }));
+
+      expect(_emptyD).not.toBeNull();
+      expect(_pageDE).not.toBeNull();
+      expect(_pageDF).not.toBeNull();
+
+      await normalizeParentByPath('/norm_parent_by_path_D', rootUser);
+
+      const countD = await Page.count({ path: '/norm_parent_by_path_D' });
+
+      // -- check count
+      expect(countD).toBe(1);
+
+      const pageD = await Page.findById(_emptyD._id);
+      const pageDE = await Page.findById(_pageDE._id);
+      const pageDF = await Page.findById(_pageDF._id);
+
+      // -- check existance
+      expect(pageD.path).toBe('/norm_parent_by_path_D');
+      expect(pageDE.path).toBe('/norm_parent_by_path_D/norm_parent_by_path_E');
+      expect(pageDF.path).toBe('/norm_parent_by_path_D/norm_parent_by_path_F');
+
+      // -- check isEmpty of pageD
+      // pageD should not be empty because growi system will create a non-empty page while running normalizeParentByPath
+      expect(pageD.isEmpty).toBe(false);
+
+      // -- check parent
+      expect(pageD.parent).toStrictEqual(rootPage._id);
+      expect(pageDE.parent).toStrictEqual(pageD._id);
+      expect(pageDF.parent).toStrictEqual(pageD._id);
+
+      // -- check descendantCount
+      expect(pageD.descendantCount).toBe(2);
+      expect(pageDE.descendantCount).toBe(0);
+      expect(pageDF.descendantCount).toBe(0);
+    });
+
+    test('should normalize all granted pages under the path when a non-empty page exists at the path', async() => {
+      const _pageG = await Page.findOne(public({ path: '/norm_parent_by_path_G', ...normalized }));
+      const _pageGH = await Page.findOne(public({ path: '/norm_parent_by_path_G/norm_parent_by_path_H', ...normalized }));
+      const _pageGI = await Page.findOne(root({ path: '/norm_parent_by_path_G/norm_parent_by_path_I', ...notNormalized }));
+
+      expect(_pageG).not.toBeNull();
+      expect(_pageGH).not.toBeNull();
+      expect(_pageGI).not.toBeNull();
+
+      await normalizeParentByPath('/norm_parent_by_path_G', rootUser);
+
+      const countG = await Page.count({ path: '/norm_parent_by_path_G' });
+
+      // -- check count
+      expect(countG).toBe(1);
+
+      const pageG = await Page.findById(_pageG._id);
+      const pageGH = await Page.findById(_pageGH._id);
+      const pageGI = await Page.findById(_pageGI._id);
+
+      // -- check existance
+      expect(pageG.path).toBe('/norm_parent_by_path_G');
+      expect(pageGH.path).toBe('/norm_parent_by_path_G/norm_parent_by_path_H');
+      expect(pageGI.path).toBe('/norm_parent_by_path_G/norm_parent_by_path_I');
+
+      // -- check parent
+      expect(pageG.parent).toStrictEqual(rootPage._id);
+      expect(pageGH.parent).toStrictEqual(pageG._id);
+      expect(pageGI.parent).toStrictEqual(pageG._id);
+
+      // -- check descendantCount
+      expect(pageG.descendantCount).toBe(2);
+      expect(pageGH.descendantCount).toBe(0);
+      expect(pageGI.descendantCount).toBe(0);
+    });
+  });
+
 });
 });

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/codemirror-textlint",
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.6-RC.0",
+  "version": "5.0.8-RC.0",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/core",
   "name": "@growi/core",
-  "version": "5.0.6-RC.0",
+  "version": "5.0.8-RC.0",
   "description": "GROWI Core Libraries",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-attachment-refs",
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.0.6-RC.0",
+  "version": "5.0.8-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-lsx",
   "name": "@growi/plugin-lsx",
-  "version": "5.0.6-RC.0",
+  "version": "5.0.8-RC.0",
   "description": "GROWI plugin to list pages",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.6-RC.0",
+  "version": "5.0.8-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slack",
   "name": "@growi/slack",
-  "version": "5.0.6-RC.0",
+  "version": "5.0.8-RC.0",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",
   "typings": "dist/index.d.ts",

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "5.0.6-slackbot-proxy.0",
+  "version": "5.0.8-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.6-RC.0",
+    "@growi/slack": "^5.0.8-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/ui",
   "name": "@growi/ui",
-  "version": "5.0.6-RC.0",
+  "version": "5.0.8-RC.0",
   "description": "GROWI UI Libraries",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 914 - 1
yarn.lock

@@ -44,6 +44,904 @@
     call-me-maybe "^1.0.1"
     call-me-maybe "^1.0.1"
     z-schema "^4.2.3"
     z-schema "^4.2.3"
 
 
+"@aws-crypto/crc32@2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-2.0.0.tgz#4ad432a3c03ec3087c5540ff6e41e6565d2dc153"
+  integrity sha512-TvE1r2CUueyXOuHdEigYjIZVesInd9KN+K/TFFNfkkxRThiNxO6i4ZqqAVMoEjAamZZ1AA8WXJkjCz7YShHPQA==
+  dependencies:
+    "@aws-crypto/util" "^2.0.0"
+    "@aws-sdk/types" "^3.1.0"
+    tslib "^1.11.1"
+
+"@aws-crypto/crc32c@2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@aws-crypto/crc32c/-/crc32c-2.0.0.tgz#4235336ef78f169f6a05248906703b9b78da676e"
+  integrity sha512-vF0eMdMHx3O3MoOXUfBZry8Y4ZDtcuskjjKgJz8YfIDjLStxTZrYXk+kZqtl6A0uCmmiN/Eb/JbC/CndTV1MHg==
+  dependencies:
+    "@aws-crypto/util" "^2.0.0"
+    "@aws-sdk/types" "^3.1.0"
+    tslib "^1.11.1"
+
+"@aws-crypto/ie11-detection@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@aws-crypto/ie11-detection/-/ie11-detection-2.0.0.tgz#bb6c2facf8f03457e949dcf0921477397ffa4c6e"
+  integrity sha512-pkVXf/dq6PITJ0jzYZ69VhL8VFOFoPZLZqtU/12SGnzYuJOOGNfF41q9GxdI1yqC8R13Rq3jOLKDFpUJFT5eTA==
+  dependencies:
+    tslib "^1.11.1"
+
+"@aws-crypto/sha1-browser@2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@aws-crypto/sha1-browser/-/sha1-browser-2.0.0.tgz#71e735df20ea1d38f59259c4b1a2e00ca74a0eea"
+  integrity sha512-3fIVRjPFY8EG5HWXR+ZJZMdWNRpwbxGzJ9IH9q93FpbgCH8u8GHRi46mZXp3cYD7gealmyqpm3ThZwLKJjWJhA==
+  dependencies:
+    "@aws-crypto/ie11-detection" "^2.0.0"
+    "@aws-crypto/supports-web-crypto" "^2.0.0"
+    "@aws-sdk/types" "^3.1.0"
+    "@aws-sdk/util-locate-window" "^3.0.0"
+    "@aws-sdk/util-utf8-browser" "^3.0.0"
+    tslib "^1.11.1"
+
+"@aws-crypto/sha256-browser@2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-browser/-/sha256-browser-2.0.0.tgz#741c9024df55ec59b51e5b1f5d806a4852699fb5"
+  integrity sha512-rYXOQ8BFOaqMEHJrLHul/25ckWH6GTJtdLSajhlqGMx0PmSueAuvboCuZCTqEKlxR8CQOwRarxYMZZSYlhRA1A==
+  dependencies:
+    "@aws-crypto/ie11-detection" "^2.0.0"
+    "@aws-crypto/sha256-js" "^2.0.0"
+    "@aws-crypto/supports-web-crypto" "^2.0.0"
+    "@aws-crypto/util" "^2.0.0"
+    "@aws-sdk/types" "^3.1.0"
+    "@aws-sdk/util-locate-window" "^3.0.0"
+    "@aws-sdk/util-utf8-browser" "^3.0.0"
+    tslib "^1.11.1"
+
+"@aws-crypto/sha256-js@2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-js/-/sha256-js-2.0.0.tgz#f1f936039bdebd0b9e2dd834d65afdc2aac4efcb"
+  integrity sha512-VZY+mCY4Nmrs5WGfitmNqXzaE873fcIZDu54cbaDaaamsaTOP1DBImV9F4pICc3EHjQXujyE8jig+PFCaew9ig==
+  dependencies:
+    "@aws-crypto/util" "^2.0.0"
+    "@aws-sdk/types" "^3.1.0"
+    tslib "^1.11.1"
+
+"@aws-crypto/sha256-js@^2.0.0":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-js/-/sha256-js-2.0.1.tgz#79e1e6cf61f652ef2089c08d471c722ecf1626a9"
+  integrity sha512-mbHTBSPBvg6o/mN/c18Z/zifM05eJrapj5ggoOIeHIWckvkv5VgGi7r/wYpt+QAO2ySKXLNvH2d8L7bne4xrMQ==
+  dependencies:
+    "@aws-crypto/util" "^2.0.1"
+    "@aws-sdk/types" "^3.1.0"
+    tslib "^1.11.1"
+
+"@aws-crypto/supports-web-crypto@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@aws-crypto/supports-web-crypto/-/supports-web-crypto-2.0.0.tgz#fd6cde30b88f77d5a4f57b2c37c560d918014f9e"
+  integrity sha512-Ge7WQ3E0OC7FHYprsZV3h0QIcpdyJLvIeg+uTuHqRYm8D6qCFJoiC+edSzSyFiHtZf+NOQDJ1q46qxjtzIY2nA==
+  dependencies:
+    tslib "^1.11.1"
+
+"@aws-crypto/util@^2.0.0", "@aws-crypto/util@^2.0.1":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@aws-crypto/util/-/util-2.0.1.tgz#976cf619cf85084ca85ec5eb947a6ac6b8b5c98c"
+  integrity sha512-JJmFFwvbm08lULw4Nm5QOLg8+lAQeC8aCXK5xrtxntYzYXCGfHwUJ4Is3770Q7HmICsXthGQ+ZsDL7C2uH3yBQ==
+  dependencies:
+    "@aws-sdk/types" "^3.1.0"
+    "@aws-sdk/util-utf8-browser" "^3.0.0"
+    tslib "^1.11.1"
+
+"@aws-sdk/abort-controller@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/abort-controller/-/abort-controller-3.55.0.tgz#779f487cceab7804f2d542925a1918fbe91b42ac"
+  integrity sha512-rCcTxJDEFnmvo/PgbhCRv24/Uv03lEGfRslKZq7SjaMcOubflS/ZXYaMEgsjYHgAT0zlpSsyCIkJXmhFaM7H7w==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/chunked-blob-reader-native@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/chunked-blob-reader-native/-/chunked-blob-reader-native-3.58.0.tgz#1db413c5c80b32e24f1b62b22e15e9ad74d75cda"
+  integrity sha512-+D3xnPD5985iphgAqgUerBDs371a2WzzoEVi7eHJUMMsP/gEnSTdSH0HNxsqhYv6CW4EdKtvDAQdAwA1VtCf2A==
+  dependencies:
+    "@aws-sdk/util-base64-browser" "3.58.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/chunked-blob-reader@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/chunked-blob-reader/-/chunked-blob-reader-3.55.0.tgz#db240c78e7c4c826e707f0ca32a4d221c41cf6a0"
+  integrity sha512-o/xjMCq81opAjSBjt7YdHJwIJcGVG5XIV9+C2KXcY5QwVimkOKPybWTv0mXPvSwSilSx+EhpLNhkcJuXdzhw4w==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/client-s3@^3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.58.0.tgz#7cf9c43a2060346333e74cbc6e7ef44a83391ec4"
+  integrity sha512-7TAqYFpFeaahLCdIxsdWLz3uRrzITFFFitbfVxQ+eaR6EMuH3VEhbGEZ66+zieWns9a1UXc11918vpwgu0zTtw==
+  dependencies:
+    "@aws-crypto/sha1-browser" "2.0.0"
+    "@aws-crypto/sha256-browser" "2.0.0"
+    "@aws-crypto/sha256-js" "2.0.0"
+    "@aws-sdk/client-sts" "3.58.0"
+    "@aws-sdk/config-resolver" "3.58.0"
+    "@aws-sdk/credential-provider-node" "3.58.0"
+    "@aws-sdk/eventstream-serde-browser" "3.58.0"
+    "@aws-sdk/eventstream-serde-config-resolver" "3.55.0"
+    "@aws-sdk/eventstream-serde-node" "3.58.0"
+    "@aws-sdk/fetch-http-handler" "3.58.0"
+    "@aws-sdk/hash-blob-browser" "3.58.0"
+    "@aws-sdk/hash-node" "3.55.0"
+    "@aws-sdk/hash-stream-node" "3.58.0"
+    "@aws-sdk/invalid-dependency" "3.55.0"
+    "@aws-sdk/md5-js" "3.58.0"
+    "@aws-sdk/middleware-bucket-endpoint" "3.58.0"
+    "@aws-sdk/middleware-content-length" "3.58.0"
+    "@aws-sdk/middleware-expect-continue" "3.58.0"
+    "@aws-sdk/middleware-flexible-checksums" "3.58.0"
+    "@aws-sdk/middleware-host-header" "3.58.0"
+    "@aws-sdk/middleware-location-constraint" "3.55.0"
+    "@aws-sdk/middleware-logger" "3.55.0"
+    "@aws-sdk/middleware-retry" "3.58.0"
+    "@aws-sdk/middleware-sdk-s3" "3.58.0"
+    "@aws-sdk/middleware-serde" "3.55.0"
+    "@aws-sdk/middleware-signing" "3.58.0"
+    "@aws-sdk/middleware-ssec" "3.55.0"
+    "@aws-sdk/middleware-stack" "3.55.0"
+    "@aws-sdk/middleware-user-agent" "3.58.0"
+    "@aws-sdk/node-config-provider" "3.58.0"
+    "@aws-sdk/node-http-handler" "3.58.0"
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/smithy-client" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/url-parser" "3.55.0"
+    "@aws-sdk/util-base64-browser" "3.58.0"
+    "@aws-sdk/util-base64-node" "3.55.0"
+    "@aws-sdk/util-body-length-browser" "3.55.0"
+    "@aws-sdk/util-body-length-node" "3.55.0"
+    "@aws-sdk/util-defaults-mode-browser" "3.55.0"
+    "@aws-sdk/util-defaults-mode-node" "3.58.0"
+    "@aws-sdk/util-stream-browser" "3.55.0"
+    "@aws-sdk/util-stream-node" "3.55.0"
+    "@aws-sdk/util-user-agent-browser" "3.58.0"
+    "@aws-sdk/util-user-agent-node" "3.58.0"
+    "@aws-sdk/util-utf8-browser" "3.55.0"
+    "@aws-sdk/util-utf8-node" "3.55.0"
+    "@aws-sdk/util-waiter" "3.55.0"
+    "@aws-sdk/xml-builder" "3.55.0"
+    entities "2.2.0"
+    fast-xml-parser "3.19.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/client-sso@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso/-/client-sso-3.58.0.tgz#0cc5152cf1246ddc726016aa8964c39237e2ad78"
+  integrity sha512-nS5G/OX8Bg4ajBa6+jLcbbr4PpEO+l5eJfGUzoJQwS4Zqa0lF/wC0kyjKm61gLp4JuvhrQskxIC/3IXUqB1XVQ==
+  dependencies:
+    "@aws-crypto/sha256-browser" "2.0.0"
+    "@aws-crypto/sha256-js" "2.0.0"
+    "@aws-sdk/config-resolver" "3.58.0"
+    "@aws-sdk/fetch-http-handler" "3.58.0"
+    "@aws-sdk/hash-node" "3.55.0"
+    "@aws-sdk/invalid-dependency" "3.55.0"
+    "@aws-sdk/middleware-content-length" "3.58.0"
+    "@aws-sdk/middleware-host-header" "3.58.0"
+    "@aws-sdk/middleware-logger" "3.55.0"
+    "@aws-sdk/middleware-retry" "3.58.0"
+    "@aws-sdk/middleware-serde" "3.55.0"
+    "@aws-sdk/middleware-stack" "3.55.0"
+    "@aws-sdk/middleware-user-agent" "3.58.0"
+    "@aws-sdk/node-config-provider" "3.58.0"
+    "@aws-sdk/node-http-handler" "3.58.0"
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/smithy-client" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/url-parser" "3.55.0"
+    "@aws-sdk/util-base64-browser" "3.58.0"
+    "@aws-sdk/util-base64-node" "3.55.0"
+    "@aws-sdk/util-body-length-browser" "3.55.0"
+    "@aws-sdk/util-body-length-node" "3.55.0"
+    "@aws-sdk/util-defaults-mode-browser" "3.55.0"
+    "@aws-sdk/util-defaults-mode-node" "3.58.0"
+    "@aws-sdk/util-user-agent-browser" "3.58.0"
+    "@aws-sdk/util-user-agent-node" "3.58.0"
+    "@aws-sdk/util-utf8-browser" "3.55.0"
+    "@aws-sdk/util-utf8-node" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/client-sts@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/client-sts/-/client-sts-3.58.0.tgz#31d03eecccac63bd572b252b53c64338f742fe99"
+  integrity sha512-2cHZsG2eXv/Zl0hvsG9+rdHEuAclMFfkma/3LC3RRwSuZXo1rXoIhFkzHfGfIbivdk738YAo7FT3ZYGlrsK4ow==
+  dependencies:
+    "@aws-crypto/sha256-browser" "2.0.0"
+    "@aws-crypto/sha256-js" "2.0.0"
+    "@aws-sdk/config-resolver" "3.58.0"
+    "@aws-sdk/credential-provider-node" "3.58.0"
+    "@aws-sdk/fetch-http-handler" "3.58.0"
+    "@aws-sdk/hash-node" "3.55.0"
+    "@aws-sdk/invalid-dependency" "3.55.0"
+    "@aws-sdk/middleware-content-length" "3.58.0"
+    "@aws-sdk/middleware-host-header" "3.58.0"
+    "@aws-sdk/middleware-logger" "3.55.0"
+    "@aws-sdk/middleware-retry" "3.58.0"
+    "@aws-sdk/middleware-sdk-sts" "3.58.0"
+    "@aws-sdk/middleware-serde" "3.55.0"
+    "@aws-sdk/middleware-signing" "3.58.0"
+    "@aws-sdk/middleware-stack" "3.55.0"
+    "@aws-sdk/middleware-user-agent" "3.58.0"
+    "@aws-sdk/node-config-provider" "3.58.0"
+    "@aws-sdk/node-http-handler" "3.58.0"
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/smithy-client" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/url-parser" "3.55.0"
+    "@aws-sdk/util-base64-browser" "3.58.0"
+    "@aws-sdk/util-base64-node" "3.55.0"
+    "@aws-sdk/util-body-length-browser" "3.55.0"
+    "@aws-sdk/util-body-length-node" "3.55.0"
+    "@aws-sdk/util-defaults-mode-browser" "3.55.0"
+    "@aws-sdk/util-defaults-mode-node" "3.58.0"
+    "@aws-sdk/util-user-agent-browser" "3.58.0"
+    "@aws-sdk/util-user-agent-node" "3.58.0"
+    "@aws-sdk/util-utf8-browser" "3.55.0"
+    "@aws-sdk/util-utf8-node" "3.55.0"
+    entities "2.2.0"
+    fast-xml-parser "3.19.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/config-resolver@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/config-resolver/-/config-resolver-3.58.0.tgz#c990541276ecdc76acf25f68f58cdb0d0d7eb07e"
+  integrity sha512-NXEwYw0JrXcvenu42QpNMQXK+6pgZ+6bDGfCgOfCC0FmyI+w/CuF36lApwm7InHvHazOaDlwArXm2pfntErKoA==
+  dependencies:
+    "@aws-sdk/signature-v4" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/util-config-provider" "3.55.0"
+    "@aws-sdk/util-middleware" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/credential-provider-env@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.55.0.tgz#5a1f5ddff54ea3f58f4a1a824b5b19a1f3618fc6"
+  integrity sha512-4AIIXEdvinLlWNFtrUbUgoB7dkuV04RTcTruVWI4Ub4WSsuSCa72ZU1vqyvcEAOgGGLBmcSaGTWByjiD2sGcGA==
+  dependencies:
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/credential-provider-imds@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.58.0.tgz#89d3963895f5e6150b74b5ba2010158d8576b95e"
+  integrity sha512-CdtnTQ9zqLx1FbXdbgjijLbMcIWOyQM03TFaLSCjI3FNbUwyt3T7StBU9tj/LtbypHhSdXyQBpzUtXTOMWCEhg==
+  dependencies:
+    "@aws-sdk/node-config-provider" "3.58.0"
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/url-parser" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/credential-provider-ini@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.58.0.tgz#16144b8a821766550fce4f96040c5e4ed115e77c"
+  integrity sha512-uM62hcHUVaHP1YFnbrjf2RlrRj1m/BvMPE+T5jdNRWdE3lvnunhEMawB26HZs9nQqCV6d25I8G9/fGWVL7g3Og==
+  dependencies:
+    "@aws-sdk/credential-provider-env" "3.55.0"
+    "@aws-sdk/credential-provider-imds" "3.58.0"
+    "@aws-sdk/credential-provider-sso" "3.58.0"
+    "@aws-sdk/credential-provider-web-identity" "3.55.0"
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/shared-ini-file-loader" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/credential-provider-node@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.58.0.tgz#f9816ce2c300edd102c0a43fd28274056452b70e"
+  integrity sha512-f0wzcgMYCQUrii6TLP2ggCxkQP4HH8PW8tbbWEgt4cdIXcjE9KEuxN5yOV6sFHzL3eJh0QM9Yaz8WzhWn6fT2A==
+  dependencies:
+    "@aws-sdk/credential-provider-env" "3.55.0"
+    "@aws-sdk/credential-provider-imds" "3.58.0"
+    "@aws-sdk/credential-provider-ini" "3.58.0"
+    "@aws-sdk/credential-provider-process" "3.58.0"
+    "@aws-sdk/credential-provider-sso" "3.58.0"
+    "@aws-sdk/credential-provider-web-identity" "3.55.0"
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/shared-ini-file-loader" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/credential-provider-process@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.58.0.tgz#ff6db03266428bb2074e9b32db8021efa1af6570"
+  integrity sha512-npgFqPUjMhUamf1FvJjBYUdpbWx8XWkKCwJsX73I7IYQAvAi2atCOkdtKq+4rds0VWAYu6vzlaI1tXgFxjOPNQ==
+  dependencies:
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/shared-ini-file-loader" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/credential-provider-sso@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.58.0.tgz#cc8bb71c41488e8be855fce7caf0a5dd1da79286"
+  integrity sha512-2qO34s9lJqvCC6zOF4UpopW6xURZpYfVC8xTUDpAUnvTOt4nS5hkx4vNyqPAXILoRHuFJsnlWsBH1UP5ZnBiZg==
+  dependencies:
+    "@aws-sdk/client-sso" "3.58.0"
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/shared-ini-file-loader" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/credential-provider-web-identity@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.55.0.tgz#21aebe5b4ad7a5b4abaf8df9aabfba0994ece357"
+  integrity sha512-aKnXfZNGohTuF9rCGYLg4JEIOvWIZ/sb66XMq7bOUrx13KRPDwL/eUQL8quS5jGRLpjXVNvrS17AFf65GbdUBg==
+  dependencies:
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/eventstream-marshaller@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/eventstream-marshaller/-/eventstream-marshaller-3.58.0.tgz#32d83d006b26f1488e4001cfc1899800428c0dc2"
+  integrity sha512-vTdVFLIHGZTx/Anp9GpkTXVuvwSCNOecTutU5Py4i6fATgefWiSutc5Xc/FLujBSc0EhAXDGZIcTMpZC7jUpeg==
+  dependencies:
+    "@aws-crypto/crc32" "2.0.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/util-hex-encoding" "3.58.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/eventstream-serde-browser@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/eventstream-serde-browser/-/eventstream-serde-browser-3.58.0.tgz#ce9bf8060335743d66c9de2bb751cc506cc494a5"
+  integrity sha512-oR5yoOoJrTSUKwbxZSt37bZgMXUUSsOub96E6SOb8wh8TMq2f0wvqeO8A+aaxY487gKpzuVUClp7jSQ9LgiVcw==
+  dependencies:
+    "@aws-sdk/eventstream-marshaller" "3.58.0"
+    "@aws-sdk/eventstream-serde-universal" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/eventstream-serde-config-resolver@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.55.0.tgz#02fe0ea00b46d8a9fdb021946146d9cb2545dd0d"
+  integrity sha512-NTJHLq1sbXyXAaJucKvcdN3Svr/fM2TjHEC3l8P/torFjIsX1+Ykpi8tZt8KsX8RjoUTTfKylh41AjJq0K9X4Q==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/eventstream-serde-node@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/eventstream-serde-node/-/eventstream-serde-node-3.58.0.tgz#c0bf378827aef87e7a25f8e72f64911383af10a4"
+  integrity sha512-U1DnRVfvKOXty+Bei6oqhRWFzGWzxl0OFHtev9GzC7BE/E6s4Gn695o+NO+9IwQgjOlc/JsGyAcWevq3MDxymg==
+  dependencies:
+    "@aws-sdk/eventstream-marshaller" "3.58.0"
+    "@aws-sdk/eventstream-serde-universal" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/eventstream-serde-universal@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/eventstream-serde-universal/-/eventstream-serde-universal-3.58.0.tgz#c52090981acbb551c3bf30d4af0327e5f85a7f19"
+  integrity sha512-w7czmMNvCCspJi8Ij0lTByCiuYBhyNzYTM1wv33vtF7dL+FJgi4W4c5WFAOtvpsPulobY013TWCjPJG+V0IPGQ==
+  dependencies:
+    "@aws-sdk/eventstream-marshaller" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/fetch-http-handler@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.58.0.tgz#5e102283f0e9a29b5d4d5cf42508a79635b3779a"
+  integrity sha512-timF3FjPV5Bd+Kgph83LIKVlPCFObVYzious1a6doeLAT6YFwZpRrWbfP/HzS+DCoYiwUsH69oVJ91BoV66oyA==
+  dependencies:
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/querystring-builder" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/util-base64-browser" "3.58.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/hash-blob-browser@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/hash-blob-browser/-/hash-blob-browser-3.58.0.tgz#03a4e78932d0e00ff1f452d528a9c3e64bc9fff3"
+  integrity sha512-fdp12BqypRxwvevbJSl/sUhXJRi4Ghv6JKEXAHI1klkR6xY1GRORO5SHWltVY/xl373ERMol5o/n+ra/7jcx/g==
+  dependencies:
+    "@aws-sdk/chunked-blob-reader" "3.55.0"
+    "@aws-sdk/chunked-blob-reader-native" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/hash-node@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/hash-node/-/hash-node-3.55.0.tgz#ea58e9b6f2147c59ad4e41e83bd6864df59b331e"
+  integrity sha512-2UdYwY/++AlzWEAFaK9wOed2QSxbzV527vmqKjReLHpPKPrSIlooUxlTH3LU6Y6WVDAzDRtLK43KUVXTLgGK1A==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/util-buffer-from" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/hash-stream-node@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/hash-stream-node/-/hash-stream-node-3.58.0.tgz#f0f3fc45e069834600264deed2fa4d0da42773b1"
+  integrity sha512-y7HEeC3OiuXCRqsHnKDn5yef8UAbnegD9r+OM9bdD+3e6FLAL8Rq7hQTOpwIAiPXuD7HKx8h98s9JLvkwTOBkg==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/invalid-dependency@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/invalid-dependency/-/invalid-dependency-3.55.0.tgz#5406c80e4be534700b92b61c21a74efd754c9492"
+  integrity sha512-delH0lV+78fdD/8MXIt9kTLS6IwHvdhqq9dw/ow5VjTUw+xBwUlfPfZplaai+3hKTKWh6a2WZCeDasNItBv9aA==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/is-array-buffer@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/is-array-buffer/-/is-array-buffer-3.55.0.tgz#c46122c5636f01d5895e5256a587768c3425ea7a"
+  integrity sha512-NbiPHVYuPxdqdFd6FxzzN3H1BQn/iWA3ri3Ry7AyLeP/tGs1yzEWMwf8BN8TSMALI0GXT6Sh0GDWy3Ok5xB6DA==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/md5-js@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/md5-js/-/md5-js-3.58.0.tgz#a7ecf5cc8a81ce247fd620f8c981802d0427737f"
+  integrity sha512-V5f4Re+CLn3aDF1nrmDqdUtcqBHCyxxD2s2Ot+hZ2JFit+OtJggo1cI03ldTrQpG79rwHG+bHqL2VvNQP7Aj9A==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/util-utf8-browser" "3.55.0"
+    "@aws-sdk/util-utf8-node" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-bucket-endpoint@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.58.0.tgz#cec84100ff776862e3bbd4bd596a1e869ad81e5e"
+  integrity sha512-zocLfFzj+NQjXLGZKPJBAYWWldAKBJkGzGVpTfrYx9bxxHTA70Gu+3sx+Xe+iOu8dtQT0OAnIX0wGudOPnTGNg==
+  dependencies:
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/util-arn-parser" "3.55.0"
+    "@aws-sdk/util-config-provider" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-content-length@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-content-length/-/middleware-content-length-3.58.0.tgz#9418b8c5f4437c9f5f7860e85c36468e93a302f7"
+  integrity sha512-h/BypPkhjv2CpCUbXA8Fa2s7V2GPiz9l11XhYK+sKSuQvQ7Lbq6VhaKaLqfeD3gLVZHgJZSLGl2btdHV1qHNNA==
+  dependencies:
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-expect-continue@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.58.0.tgz#92be232561ef27ab41bf46feb0f689e11f695516"
+  integrity sha512-nx6X6qLPwvbJrGoPxXSu4tsOek2eRnnjk78hhRUDfxFewpHJQLSPlyNKkXAo+C3syVALe6RJRmUYu5bShY6FfA==
+  dependencies:
+    "@aws-sdk/middleware-header-default" "3.58.0"
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-flexible-checksums@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.58.0.tgz#ff9f7d37e0261517d9abbff99d44940dad9c9865"
+  integrity sha512-R8S3U1boaIb7+kYhLJBks7rv/eaGj7I5T/2CgmcGY1BJBUU0h0arjPC7eeA/5wV29EHapoxVYQvJda//706rCw==
+  dependencies:
+    "@aws-crypto/crc32" "2.0.0"
+    "@aws-crypto/crc32c" "2.0.0"
+    "@aws-sdk/is-array-buffer" "3.55.0"
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-header-default@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-header-default/-/middleware-header-default-3.58.0.tgz#c72190df982601048126f452f3805858f1a11a4b"
+  integrity sha512-7F+CdLLauMmNbwFGYrE2pKsgTKY8G2PgazHmaE9s3FySEFcGPWmiEAG8sVImfZooj8gxGFQMLr97nanWjhSq2Q==
+  dependencies:
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-host-header@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.58.0.tgz#c7fe87ed16306e328e780bbed282dbf31d605236"
+  integrity sha512-q/UKGcanm9e6DBRNN6UKhVqLvpRRdZWbmmPCeDNr4HqhCmgT6i1OvWdhAMOnT++hvCX8DpTsIXzNSlY6zWAxBg==
+  dependencies:
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-location-constraint@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.55.0.tgz#cb7e91df4269bb2e64ee2d83a49973f152ef9094"
+  integrity sha512-OvCKwBFbl8Gbfk0HGX00pkdORJN8BPuH/O5l3+mOBWuwILPuckRP5WGnL+1HT/gu4hHS6h1lpxUrPxUOoeKIAg==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-logger@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.55.0.tgz#83adc985a3a98493519384565e0c1a06552b8704"
+  integrity sha512-PtRbVrxEzDmeV9prBIP4/9or7R5Dj66mjbFSvNRGZ0n+UBfBFfVRfNrhQPNzQpfV9A3KVl9YyWCVXDSW+/rk9Q==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-retry@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-retry/-/middleware-retry-3.58.0.tgz#967518e5b9e55546dcb5de0dfe5784df71807d72"
+  integrity sha512-sfSq+t0Yy47DQwrWGpA8iOx9sd26l4l1JDVTwHNi7+OKD4ClRPVCEdw3bTbbyYz/PV4f9AEfAZ6jwtSff4wkGw==
+  dependencies:
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/service-error-classification" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/util-middleware" "3.55.0"
+    tslib "^2.3.1"
+    uuid "^8.3.2"
+
+"@aws-sdk/middleware-sdk-s3@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.58.0.tgz#8e9138d5f613df556b05fe3fb2094e6a49fd0085"
+  integrity sha512-vOTPOdhZpNJo4v54evg6JnFz14hK8IW2u8B+12iV5ZQ4zJom6VowzFmIOUn+KIsw/6SrwEX9tFb0aXLlVRw27Q==
+  dependencies:
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/signature-v4" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/util-arn-parser" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-sdk-sts@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.58.0.tgz#5b433a49d2aeb10120805d0f13f6700153d55ec9"
+  integrity sha512-HUz7MhcsSDDTGygOwL61l4voc0pZco06J3z06JjTX19D5XxcQ7hSCtkHHHz0oMb9M1himVSiEon2tjhjsnB99g==
+  dependencies:
+    "@aws-sdk/middleware-signing" "3.58.0"
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/signature-v4" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-serde@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-serde/-/middleware-serde-3.55.0.tgz#326a0696255868a9dfca7c482a616897e9d54fdf"
+  integrity sha512-NkEbTDrSZcC2NhuvfjXHKJEl0xgI2B5tMAwi/rMOq/TEnARwVUL9qAy+5lgeiPCqebiNllWatARrFgAaYf0VeA==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-signing@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-signing/-/middleware-signing-3.58.0.tgz#996828122526ec5f95e6e898a6573791db4cd5e1"
+  integrity sha512-4FXubHB66GbhyZUlo6YPQoWpYfED15GNbEmHbJLSONzrVzZR3IkViSPLasDngVm1a050JqKuqNkFYGJBP4No/Q==
+  dependencies:
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/signature-v4" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-ssec@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-ssec/-/middleware-ssec-3.55.0.tgz#2f8c8593fb2a2719a0863d48b0ef6baecbd08011"
+  integrity sha512-HTdA23hksOphQe0TmYORsa/kMNnKRGbdh0VJcsDGHQScJXzJ+C//THwfcoklff0XZfC+vGh93PECBWqixMELZw==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-stack@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-stack/-/middleware-stack-3.55.0.tgz#e99ffb0bdd6861ec3b5a667561dc41dfcb44d36b"
+  integrity sha512-ouD+wFz8W2R0ZQ8HrbhgN8tg1jyINEg9lPEEXY79w1Q5sf94LJ90XKAMVk02rw3dJalUWjLHf0OQe1/qxZfHyA==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/middleware-user-agent@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.58.0.tgz#c60b83f61ed385989e0be5dc80b05a8d5626bbf8"
+  integrity sha512-1c69bIWM63JwXijXvb9IWwcwQ/gViKMZ1lhxv52NvdG5VSxWXXsFJ2jETEXZoAypwT97Hmf3xo9SYuaHcKoq+g==
+  dependencies:
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/node-config-provider@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/node-config-provider/-/node-config-provider-3.58.0.tgz#1a138c571f6b2608cff49a64f4f2936971734f1e"
+  integrity sha512-AMcPqPhKxo/3/yOMS9PsKlI0GWp2/8eD6gSlhzdBpznPCKplyqXOSnSX7wS814Cyh373hFSjCaOrCOA9/EYtDg==
+  dependencies:
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/shared-ini-file-loader" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/node-http-handler@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/node-http-handler/-/node-http-handler-3.58.0.tgz#bb633b51a205181657bfc59b24b7bf1720b7e652"
+  integrity sha512-D9xVZG2nfo4GbPsby3JuBiAhpqXTFk1+CfuQU0AZv0gQvE3fFTCnB3za83jo7JV/pyRPU+s+/LHIpxCWUHzStg==
+  dependencies:
+    "@aws-sdk/abort-controller" "3.55.0"
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/querystring-builder" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/property-provider@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/property-provider/-/property-provider-3.55.0.tgz#0eabe5e84d9258c85c2c5e44bcb09379ae9429d2"
+  integrity sha512-o7cKFJSHq5WOhwPsspYrzNto35oKKZvESZuWDtLxaZKSI6l7zpA366BI4kDG6Tc9i2+teV553MbxyZ9eya5A8g==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/protocol-http@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/protocol-http/-/protocol-http-3.58.0.tgz#170798abcc97884d4beabc4dbbdfe3b41acd2d0a"
+  integrity sha512-0yFFRPbR+CCa9eOQBBQ2qtrIDLYqSMN0y7G4iqVM8wQdIw7n3QK1PsTI3RNPGJ3Oi2krFTw5uUKqQQZPZEBuVQ==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/querystring-builder@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/querystring-builder/-/querystring-builder-3.55.0.tgz#7d6d4e2c597eb3d636bd3a368b494dac175ba329"
+  integrity sha512-/ZAXNipt9nRR8k+eowwukE/YjXnQ49p5w/MkaQxsBk3IuIf7MAcgVg8glHr0igH84GfUQ7ZVP8v+G2S3tKUG+Q==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/util-uri-escape" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/querystring-parser@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/querystring-parser/-/querystring-parser-3.55.0.tgz#ea35642c1b8324dd896d45185f99ad9d6c3af6d2"
+  integrity sha512-e+2FLgo+eDx7oh7ap5HngN9XSVMxredAVztLHxCcSN0lFHHHzMa8b2SpXbaowUxQHh7ziymSqvOrPYFQ71Filg==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/s3-request-presigner@^3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.58.0.tgz#83a1bdf9fa4f2a7dc1e250da5356a4a58e301b1d"
+  integrity sha512-rkwRSLSuTJaW3+rJgfjtJ8VCbuUh3iXKeeml3QP7ldsHg/4knUxEMr/Ja0PIkVrbPts2g4KGr4ZlppvxC9b4lA==
+  dependencies:
+    "@aws-sdk/middleware-sdk-s3" "3.58.0"
+    "@aws-sdk/protocol-http" "3.58.0"
+    "@aws-sdk/signature-v4" "3.58.0"
+    "@aws-sdk/smithy-client" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/util-create-request" "3.58.0"
+    "@aws-sdk/util-format-url" "3.58.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/service-error-classification@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/service-error-classification/-/service-error-classification-3.55.0.tgz#4a85d2d947102c50076bd2af295f62abd74e26ab"
+  integrity sha512-HdjnDyarsa1Avq1MJurkLyEe9c3eRa76dPmK4TmRGgwJ+tInEzGHL0rBW7V8xBK+PDF+fJQ71hvm8jPYmzvBwQ==
+
+"@aws-sdk/shared-ini-file-loader@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.58.0.tgz#321f80f34ef3f15ab40b756fb5ee2797812748c7"
+  integrity sha512-ARDKQerIzgNs/MFNdCEuK2lgRJ1lneAaJw0p9O1LkJUvcSibvkSATwny7vwJMueOf+ae1Pf+8+54OMNIt0nTkQ==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/signature-v4@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4/-/signature-v4-3.58.0.tgz#0d81dd317f9bf35bc0de670c0e534d7793f8e170"
+  integrity sha512-flEo8p3XkzWoBDqnIUQre4jLuT5aLnmfQNI8c2uSjyJ3OBxpJ0iS1cDu3E++d1/pN6Q8o0KOmr2ypHeiyBOujw==
+  dependencies:
+    "@aws-sdk/is-array-buffer" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    "@aws-sdk/util-hex-encoding" "3.58.0"
+    "@aws-sdk/util-middleware" "3.55.0"
+    "@aws-sdk/util-uri-escape" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/smithy-client@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/smithy-client/-/smithy-client-3.55.0.tgz#bf1f5a64d1d2374c291338a52f6c75c6d67e8148"
+  integrity sha512-YgBpqg6R3Qg8CH9biOP1N1lYTvh8VLGD6AoDGgy/R1dQSqRQuxgKANLl3DOVcZnIZLsw4TdB0m7U+ZPtirPR1Q==
+  dependencies:
+    "@aws-sdk/middleware-stack" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/types@3.55.0", "@aws-sdk/types@^3.1.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.55.0.tgz#d524d567e2b2722f2d6be83e2417dd6d46ce1490"
+  integrity sha512-wrDZjuy1CVAYxDCbm3bWQIKMGfNs7XXmG0eG4858Ixgqmq2avsIn5TORy8ynBxcXn9aekV/+tGEQ7BBSYzIVNQ==
+
+"@aws-sdk/url-parser@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/url-parser/-/url-parser-3.55.0.tgz#03b47a45c591d52c9d00dc40c630b91094991fe7"
+  integrity sha512-qrTwN5xIgTLreqLnZ+x3cAudjNKfxi6srW1H/px2mk4lb2U9B4fpGjZ6VU+XV8U2kR+YlT8J6Jo5iwuVGfC91A==
+  dependencies:
+    "@aws-sdk/querystring-parser" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-arn-parser@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-arn-parser/-/util-arn-parser-3.55.0.tgz#6672eb2975e798a460bedfaf6b5618d4e4b262e1"
+  integrity sha512-76KJxp4MRWufHYWys7DFl64znr5yeJ3AIQNAPCKKw1sP0hzO7p6Kx0PaJnw9x+CPSzOrT4NbuApL6/srYhKDGg==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/util-base64-browser@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-base64-browser/-/util-base64-browser-3.58.0.tgz#e213f91a5d40dd2d048d340f1ab192ca86c1f40c"
+  integrity sha512-0ebsXIZNpu/fup9OgsFPnRKfCFbuuI9PPRzvP6twzLxUB0c/aix6Co7LGHFKcRKHZdaykoJMXArf8eHj2Nzv1Q==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/util-base64-node@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-base64-node/-/util-base64-node-3.55.0.tgz#da9a3fd6752be49163572144793e6b23d0186ff4"
+  integrity sha512-UQ/ZuNoAc8CFMpSiRYmevaTsuRKzLwulZTnM8LNlIt9Wx1tpNvqp80cfvVj7yySKROtEi20wq29h31dZf1eYNQ==
+  dependencies:
+    "@aws-sdk/util-buffer-from" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-body-length-browser@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.55.0.tgz#9c2637097501032f6a1afddb76687415fe9b44b6"
+  integrity sha512-Ei2OCzXQw5N6ZkTMZbamUzc1z+z1R1Ja5tMEagz5BxuX4vWdBObT+uGlSzL8yvTbjoPjnxWA2aXyEqaUP3JS8Q==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/util-body-length-node@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-body-length-node/-/util-body-length-node-3.55.0.tgz#67049bbb6c62d794a1bb5a13b9a678988c925489"
+  integrity sha512-lU1d4I+9wJwydduXs0SxSfd+mHKjxeyd39VwOv6i2KSwWkPbji9UQqpflKLKw+r45jL7+xU/zfeTUg5Tt/3Gew==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/util-buffer-from@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-buffer-from/-/util-buffer-from-3.55.0.tgz#e7c927974b07a29502aa1ad58509b91d0d7cf0f7"
+  integrity sha512-uVzKG1UgvnV7XX2FPTylBujYMKBPBaq/qFBxfl0LVNfrty7YjpfieQxAe6yRLD+T0Kir/WDQwGvYC+tOYG3IGA==
+  dependencies:
+    "@aws-sdk/is-array-buffer" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-config-provider@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-config-provider/-/util-config-provider-3.55.0.tgz#720c6c0ac1aa8d14be29d1dee25e01eb4925c0ce"
+  integrity sha512-30dzofQQfx6tp1jVZkZ0DGRsT0wwC15nEysKRiAcjncM64A0Cm6sra77d0os3vbKiKoPCI/lMsFr4o3533+qvQ==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/util-create-request@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-create-request/-/util-create-request-3.58.0.tgz#c104e158b94c11cf92de5aa7535214f0b1e5b2a3"
+  integrity sha512-EP6HLQHc8RxjSJ95Ca9Ppp5F0aS6QNSQg03hbjcpmKK42h8dtuXFp5qGAu35VxUg6IRztN2x7lpa+b3d/SdDoQ==
+  dependencies:
+    "@aws-sdk/middleware-stack" "3.55.0"
+    "@aws-sdk/smithy-client" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-defaults-mode-browser@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.55.0.tgz#c2dc19c908264f643f2f345017efd7addd3824e4"
+  integrity sha512-OS3gAwR84bHz7ObhjsSJM+grfeaBq3leGrj7xiX4BH3C8J+c10GMo3fqx1pV8Fq5F+9lMmhHpfOocD63SN5Q8A==
+  dependencies:
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    bowser "^2.11.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-defaults-mode-node@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.58.0.tgz#57bb445172f10b681f34a7d382d420b9053b2122"
+  integrity sha512-KNUCp0MXI+z3Z3pQCKDkx3Stdy1TXDjcUB+ZJFxRTJGIuBYwX4fV6G8s/zeFJi5Qv1ztR3CJ9fWJGsrx9mQ5EA==
+  dependencies:
+    "@aws-sdk/config-resolver" "3.58.0"
+    "@aws-sdk/credential-provider-imds" "3.58.0"
+    "@aws-sdk/node-config-provider" "3.58.0"
+    "@aws-sdk/property-provider" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-format-url@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-format-url/-/util-format-url-3.58.0.tgz#eed1eebddf124ce05fd376d4416f825d892777be"
+  integrity sha512-nhxomsG+OIBqpIyc2AU88J3+dTap0H5R1D2lNAsSZk07kuu2B1H4qAXIlWPkXyxTi9uL9aykBMuCosECD062NA==
+  dependencies:
+    "@aws-sdk/querystring-builder" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-hex-encoding@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.58.0.tgz#d999eb19329933a94563881540a06d7ac7f515f5"
+  integrity sha512-Rl+jXUzk/FJkOLYfUVYPhKa2aUmTpeobRP31l8IatQltSzDgLyRHO35f6UEs7Ztn5s1jbu/POatLAZ2WjbgVyg==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/util-locate-window@^3.0.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-locate-window/-/util-locate-window-3.55.0.tgz#a4136a20ee1bfcb73967a6614caf769ef79db070"
+  integrity sha512-0sPmK2JaJE2BbTcnvybzob/VrFKCXKfN4CUKcvn0yGg/me7Bz+vtzQRB3Xp+YSx+7OtWxzv63wsvHoAnXvgxgg==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/util-middleware@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-middleware/-/util-middleware-3.55.0.tgz#22acf3ae45e3bbe9c1cc39d84e14aafb842fdcf0"
+  integrity sha512-82fW2XV+rUalv8lkd4VlhpPp6xnXO5n9sckMp1N+TrQ+p8eqxqT0+o8n1/6s9Qsnkw64Y3m6+EfCdc8/uFOY2g==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/util-stream-browser@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-stream-browser/-/util-stream-browser-3.55.0.tgz#2a85bcbbe1b0645580d7bdb2c2d8242ac25e5435"
+  integrity sha512-3f/zQsAqexJpKssCL0adTjG8WO+NPQ63E3TingyKpnCnHQPEnqPdya5I5OLGzZ0WR0iUWRtpuW0MtuDabyLDWw==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-stream-node@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-stream-node/-/util-stream-node-3.55.0.tgz#2b8588d9a7f3c9fa582df706b01d0911b20f1b87"
+  integrity sha512-brCK3iENvXEL7BK5eDAdkZ2VuBSvXj7DH9EQezxl4Ntrj1lvb+McOk9WoU/o7yzE7A/bzEJEoNQAPi+VPNbb/w==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-uri-escape@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-uri-escape/-/util-uri-escape-3.55.0.tgz#ee57743c628a1c9f942dfe73205ce890ec011916"
+  integrity sha512-mmdDLUpFCN2nkfwlLdOM54lTD528GiGSPN1qb8XtGLgZsJUmg3uJSFIN2lPeSbEwJB3NFjVas/rnQC48i7mV8w==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/util-user-agent-browser@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.58.0.tgz#3f46000a3d9c18d1bef6ae88682defa0c3863832"
+  integrity sha512-aJpqCvT09giJRg5xFTBDBRAVF0k0yq3OEf6UTuiOVf5azlL2MGp6PJ/xkJp9Z06PuQQkwBJ/2nIQZemo02a5Sw==
+  dependencies:
+    "@aws-sdk/types" "3.55.0"
+    bowser "^2.11.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-user-agent-node@3.58.0":
+  version "3.58.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.58.0.tgz#ea821601b0d2c7d81239ad0de60964f3967f06ac"
+  integrity sha512-VlbY/nzWdN2pfLUHqKvnlGBQ6tEeV4jyK9ggAD2Szjj0bkYvaaKwpBKswQmuJpi5/J2v7Bo4ayBLnqDL7PgzLA==
+  dependencies:
+    "@aws-sdk/node-config-provider" "3.58.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-utf8-browser@3.55.0", "@aws-sdk/util-utf8-browser@^3.0.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.55.0.tgz#a045bf1a93f6e0ff9c846631b168ea55bbb37668"
+  integrity sha512-ljzqJcyjfJpEVSIAxwtIS8xMRUly84BdjlBXyp6cu4G8TUufgjNS31LWdhyGhgmW5vYBNr+LTz0Kwf6J+ou7Ug==
+  dependencies:
+    tslib "^2.3.1"
+
+"@aws-sdk/util-utf8-node@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-utf8-node/-/util-utf8-node-3.55.0.tgz#44cf9f9c8624d144afd65ab8a1786e33134add15"
+  integrity sha512-FsFm7GFaC7j0tlPEm/ri8bU2QCwFW5WKjxUg8lm1oWaxplCpKGUsmcfPJ4sw58GIoyoGu4QXBK60oCWosZYYdQ==
+  dependencies:
+    "@aws-sdk/util-buffer-from" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/util-waiter@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/util-waiter/-/util-waiter-3.55.0.tgz#0e48a8ce98931f99cfbcad750222fd1f0b237fda"
+  integrity sha512-Do34MKPFSC/+zVN6vY+FZ+0WN61hzga4nPoAC590AOjs8rW6/H6sDN6Gz1KAZbPnuQUZfvsIJjMxN7lblXHJkQ==
+  dependencies:
+    "@aws-sdk/abort-controller" "3.55.0"
+    "@aws-sdk/types" "3.55.0"
+    tslib "^2.3.1"
+
+"@aws-sdk/xml-builder@3.55.0":
+  version "3.55.0"
+  resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.55.0.tgz#8e14012ab3ed27cf68964abf1326d06c686b3511"
+  integrity sha512-BH+i5S2FLprmfSeIuGy3UbNtEoJPVjh8arl5+LV3i2KY/+TmrS4yT8JtztDlDxHF0cMtNLZNO0KEPtsACS6SOg==
+  dependencies:
+    tslib "^2.3.1"
+
 "@azu/format-text@^1.0.1":
 "@azu/format-text@^1.0.1":
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/@azu/format-text/-/format-text-1.0.1.tgz#6967350a94640f6b02855169bd897ce54d6cebe2"
   resolved "https://registry.yarnpkg.com/@azu/format-text/-/format-text-1.0.1.tgz#6967350a94640f6b02855169bd897ce54d6cebe2"
@@ -4776,6 +5674,11 @@ bowser@^1.7.3:
   resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a"
   resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a"
   integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==
   integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==
 
 
+bowser@^2.11.0:
+  version "2.11.0"
+  resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f"
+  integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==
+
 brace-expansion@^1.1.7:
 brace-expansion@^1.1.7:
   version "1.1.11"
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -7788,6 +8691,11 @@ entities@1.0:
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.0.0.tgz#b2987aa3821347fcde642b24fdfc9e4fb712bf26"
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.0.0.tgz#b2987aa3821347fcde642b24fdfc9e4fb712bf26"
   integrity sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=
   integrity sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=
 
 
+entities@2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
+  integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
+
 entities@^2.0.0, entities@~2.0.0:
 entities@^2.0.0, entities@~2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4"
   resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4"
@@ -8796,6 +9704,11 @@ fast-text-encoding@^1.0.0:
   resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.0.tgz#3e5ce8293409cfaa7177a71b9ca84e1b1e6f25ef"
   resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.0.tgz#3e5ce8293409cfaa7177a71b9ca84e1b1e6f25ef"
   integrity sha512-R9bHCvweUxxwkDwhjav5vxpFvdPGlVngtqmx4pIZfSUhM/Q4NiIUHB456BAf+Q1Nwu3HEZYONtu+Rya+af4jiQ==
   integrity sha512-R9bHCvweUxxwkDwhjav5vxpFvdPGlVngtqmx4pIZfSUhM/Q4NiIUHB456BAf+Q1Nwu3HEZYONtu+Rya+af4jiQ==
 
 
+fast-xml-parser@3.19.0:
+  version "3.19.0"
+  resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-3.19.0.tgz#cb637ec3f3999f51406dd8ff0e6fc4d83e520d01"
+  integrity sha512-4pXwmBplsCPv8FOY1WRakF970TjNGnGnfbOnLqjlYvMiF1SR3yOHyxMR/YCXpPTOspNF5gwudqktIP4VsWkvBg==
+
 fastest-levenshtein@^1.0.12:
 fastest-levenshtein@^1.0.12:
   version "1.0.12"
   version "1.0.12"
   resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz#9990f7d3a88cc5a9ffd1f1745745251700d497e2"
   resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz#9990f7d3a88cc5a9ffd1f1745745251700d497e2"
@@ -20389,7 +21302,7 @@ tslib@2.3.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
   integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
   integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
 
 
-tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0:
+tslib@^1.10.0, tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.0:
   version "1.14.1"
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==