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

Merge branch 'dev/7.0.x' into fix/139755-current-page-item-in-page-tree-is-not-active

Shun Miyazawa 2 лет назад
Родитель
Сommit
9d2de32df4
100 измененных файлов с 1898 добавлено и 1263 удалено
  1. 1 0
      apps/app/.eslintrc.js
  2. 0 0
      apps/app/_obsolete/src/components/Navbar/GlobalSearch.module.scss
  3. 1 1
      apps/app/_obsolete/src/components/Navbar/GlobalSearch.tsx
  4. 1 2
      apps/app/_obsolete/src/components/PageEditor/MarkdownLinkUtil.js
  5. 2 2
      apps/app/_obsolete/src/components/PageEditorByHackmd.tsx
  6. 13 5
      apps/app/package.json
  7. 9 7
      apps/app/public/static/locales/en_US/admin.json
  8. 6 3
      apps/app/public/static/locales/en_US/translation.json
  9. 9 7
      apps/app/public/static/locales/ja_JP/admin.json
  10. 6 3
      apps/app/public/static/locales/ja_JP/translation.json
  11. 9 7
      apps/app/public/static/locales/zh_CN/admin.json
  12. 6 3
      apps/app/public/static/locales/zh_CN/translation.json
  13. 10 0
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  14. 2 1
      apps/app/src/client/services/side-effects/page-updated.ts
  15. 123 0
      apps/app/src/client/services/use-create-page-and-transit.tsx
  16. 1 1
      apps/app/src/client/services/use-on-template-button-clicked.ts
  17. 1 1
      apps/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx
  18. 2 2
      apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  19. 24 3
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  20. 37 0
      apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx
  21. 3 5
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  22. 24 12
      apps/app/src/components/Common/PagePathHierarchicalLink/CollapsedParentsDropdown.tsx
  23. 8 10
      apps/app/src/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx
  24. 1 1
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  25. 1 1
      apps/app/src/components/Layout/SearchResultLayout.tsx
  26. 20 14
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  27. 0 58
      apps/app/src/components/Navbar/hooks.tsx
  28. 62 30
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  29. 5 1
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  30. 2 3
      apps/app/src/components/PageComment/CommentEditor.tsx
  31. 7 3
      apps/app/src/components/PageDeleteModal.tsx
  32. 27 9
      apps/app/src/components/PageDuplicateModal.tsx
  33. 0 2
      apps/app/src/components/PageEditor/EditorNavbarBottom.module.scss
  34. 4 4
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  35. 2 3
      apps/app/src/components/PageEditor/LinkEditModal.tsx
  36. 34 37
      apps/app/src/components/PageEditor/PageEditor.tsx
  37. 4 0
      apps/app/src/components/PageManagement/ApiErrorMessage.jsx
  38. 11 14
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  39. 3 2
      apps/app/src/components/PageTags/PageTags.tsx
  40. 1 1
      apps/app/src/components/PrivateLegacyPages.tsx
  41. 5 5
      apps/app/src/components/SavePageControls.tsx
  42. 42 20
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  43. 1 1
      apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx
  44. 11 4
      apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx
  45. 4 1
      apps/app/src/components/Sidebar/Sidebar.tsx
  46. 0 4
      apps/app/src/components/Sidebar/SidebarNav/SidebarNav.module.scss
  47. 1 1
      apps/app/src/components/Skeleton.tsx
  48. 0 1
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  49. 1 1
      apps/app/src/interfaces/editor-settings.ts
  50. 1 1
      apps/app/src/interfaces/page-operation.ts
  51. 1 0
      apps/app/src/interfaces/page-tag-relation.ts
  52. 1 1
      apps/app/src/interfaces/page.ts
  53. 0 1
      apps/app/src/interfaces/websocket.ts
  54. 1 1
      apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js
  55. 28 0
      apps/app/src/migrations/20231223155127-non-null-granted-groups.js
  56. 38 12
      apps/app/src/pages/[[...path]].page.tsx
  57. 6 1
      apps/app/src/pages/admin/audit-log.page.tsx
  58. 4 19
      apps/app/src/pages/share/[[...path]].page.tsx
  59. 18 0
      apps/app/src/pages/utils/commons.ts
  60. 13 5
      apps/app/src/server/crowi/index.js
  61. 1 1
      apps/app/src/server/events/activity.ts
  62. 6 3
      apps/app/src/server/models/GlobalNotificationSetting.ts
  63. 5 2
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  64. 5 2
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  65. 19 0
      apps/app/src/server/models/GlobalNotificationSetting/consts.ts
  66. 1 0
      apps/app/src/server/models/config.ts
  67. 5 1
      apps/app/src/server/models/index.ts
  68. 1 1
      apps/app/src/server/models/interfaces/page-operation.ts
  69. 1 25
      apps/app/src/server/models/obsolete-page.js
  70. 0 180
      apps/app/src/server/models/page-tag-relation.js
  71. 208 0
      apps/app/src/server/models/page-tag-relation.ts
  72. 8 4
      apps/app/src/server/models/page.ts
  73. 3 4
      apps/app/src/server/models/tag.ts
  74. 1 1
      apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts
  75. 7 6
      apps/app/src/server/routes/apiv3/notification-setting.js
  76. 13 6
      apps/app/src/server/routes/apiv3/page-listing.ts
  77. 234 0
      apps/app/src/server/routes/apiv3/page/cteate-page.ts
  78. 17 15
      apps/app/src/server/routes/apiv3/page/index.js
  79. 21 201
      apps/app/src/server/routes/apiv3/pages/index.js
  80. 5 0
      apps/app/src/server/routes/apiv3/security-settings/index.js
  81. 2 1
      apps/app/src/server/routes/comment.js
  82. 0 2
      apps/app/src/server/routes/index.js
  83. 11 257
      apps/app/src/server/routes/page.js
  84. 3 3
      apps/app/src/server/routes/tag.js
  85. 10 10
      apps/app/src/server/service/global-notification/global-notification-mail.js
  86. 15 17
      apps/app/src/server/service/global-notification/global-notification-slack.js
  87. 25 10
      apps/app/src/server/service/growi-bridge/index.ts
  88. 22 0
      apps/app/src/server/service/growi-bridge/unzip-stream-utils.ts
  89. 9 4
      apps/app/src/server/service/import.js
  90. 12 0
      apps/app/src/server/service/normalize-data/index.ts
  91. 31 0
      apps/app/src/server/service/normalize-data/rename-duplicate-root-pages.ts
  92. 147 16
      apps/app/src/server/service/page-grant.ts
  93. 2 2
      apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts
  94. 271 133
      apps/app/src/server/service/page/index.ts
  95. 11 1
      apps/app/src/server/service/page/page-service.ts
  96. 11 14
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  97. 22 0
      apps/app/src/server/service/socket-io.js
  98. 2 3
      apps/app/src/server/service/user-notification/index.ts
  99. 73 0
      apps/app/src/server/service/yjs-connection-manager.ts
  100. 0 1
      apps/app/src/stores/context.tsx

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

@@ -27,6 +27,7 @@ module.exports = {
       },
     ]],
     '@typescript-eslint/no-var-requires': 'off',
+    '@typescript-eslint/consistent-type-imports': 'warn',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     '@typescript-eslint/no-use-before-define': ['warn'],

+ 0 - 0
apps/app/src/components/Navbar/GlobalSearch.module.scss → apps/app/_obsolete/src/components/Navbar/GlobalSearch.module.scss


+ 1 - 1
apps/app/src/components/Navbar/GlobalSearch.tsx → apps/app/_obsolete/src/components/Navbar/GlobalSearch.tsx

@@ -17,7 +17,7 @@ import {
 import { useCurrentPagePath } from '~/stores/page';
 import { useGlobalSearchFormRef } from '~/stores/ui';
 
-import SearchForm from '../SearchForm';
+import SearchForm from '../../../../src/components/SearchForm';
 
 import styles from './GlobalSearch.module.scss';
 

+ 1 - 2
apps/app/src/components/PageEditor/MarkdownLinkUtil.js → apps/app/_obsolete/src/components/PageEditor/MarkdownLinkUtil.js

@@ -1,5 +1,4 @@
-import Linker from '~/client/models/Linker';
-
+import Linker from '@growi/editor/src/services/link-util/Linker';
 /**
  * Utility for markdown link
  */

+ 2 - 2
apps/app/_obsolete/src/components/PageEditorByHackmd.tsx

@@ -98,7 +98,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
     if (grantData == null) {
       return;
     }
-    const grantedGroups = grantData.grantedGroups?.map((group) => {
+    const userRelatedGrantedGroups = grantData.userRelatedGrantedGroups?.map((group) => {
       return { item: group.id, type: group.type };
     });
     const optionsToSave = {
@@ -106,7 +106,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       pageTags: pageTags ?? [],
-      grantUserGroupIds: grantedGroups,
+      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
     };
     return optionsToSave;
   }, [grantData, isSlackEnabled, pageTags]);

+ 13 - 5
apps/app/package.json

@@ -35,11 +35,12 @@
     "prelint:swagger2openapi": "yarn openapi:v3",
     "test": "run-p test:*",
     "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
-    "test:vitest": "run-p vitest:run vitest:run:integ",
+    "test:vitest": "run-p vitest:run vitest:run:integ vitest:run:components",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "vitest:run": "vitest run config src --coverage",
     "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
+    "vitest:run:components": "vitest run -c vitest.config.components.ts src --coverage",
     "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
@@ -195,7 +196,7 @@
     "remark-toc": "^8.0.1",
     "remark-wiki-link": "^1.0.4",
     "sanitize-filename": "^1.6.3",
-    "socket.io": "^4.2.0",
+    "socket.io": "^4.7.2",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
     "superjson": "^1.9.1",
@@ -206,12 +207,14 @@
     "universal-bunyan": "^0.9.2",
     "unstated": "^2.1.1",
     "unzip-stream": "^0.3.1",
-    "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "validator": "^13.7.0",
     "ws": "^8.3.0",
-    "xss": "^1.0.14"
+    "xss": "^1.0.14",
+    "y-mongodb-provider": "^0.1.7",
+    "y-socket.io": "^1.1.0",
+    "yjs": "^13.6.7"
   },
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
@@ -226,12 +229,15 @@
     "@next/bundle-analyzer": "^13.2.3",
     "@swc-node/jest": "^1.6.2",
     "@swc/jest": "^0.2.24",
+    "@testing-library/react": "^14.1.2",
+    "@testing-library/user-event": "^14.5.2",
     "@types/express": "^4.17.11",
     "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "@types/throttle-debounce": "^5.0.1",
     "@types/url-join": "^4.0.2",
     "@types/unzip-stream": "^0.3.4",
+    "@vitejs/plugin-react": "^4.2.1",
     "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
@@ -247,6 +253,7 @@
     "font-awesome": "^4.7.0",
     "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",
+    "happy-dom": "^13.2.0",
     "i18next-hmr": "^1.11.0",
     "jest": "^29.5.0",
     "jest-date-mock": "^1.0.8",
@@ -272,6 +279,7 @@
     "socket.io-client": "^4.2.0",
     "source-map-loader": "^4.0.1",
     "swagger2openapi": "^7.0.8",
-    "tsc-alias": "^1.2.9"
+    "tsc-alias": "^1.2.9",
+    "y-codemirror.next": "^0.3.2"
   }
 }

+ 9 - 7
apps/app/public/static/locales/en_US/admin.json

@@ -38,10 +38,12 @@
     "page_delete": "Page Delete",
     "page_delete_completely": "Page Delete Completely",
     "other_options": "Other options",
-    "deletion_explain": "Restricts users who can trash the selected single page.",
-    "complete_deletion_explain": "Restricts users who can completely delete  selected single page.",
-    "recursive_deletion_explain": "Restricts users who can trash pages including descendants.",
-    "recursive_complete_deletion_explain": "Restricts users who can completely delete pages including descendants.",
+    "deletion_explanation": "Restricts users who can trash the selected single page.",
+    "complete_deletion_explanation": "Restricts users who can completely delete  selected single page.",
+    "recursive_deletion_explanation": "Restricts users who can trash pages including descendants.",
+    "recursive_complete_deletion_explanation": "Restricts users who can completely delete pages including descendants.",
+    "is_all_group_membership_required_for_page_complete_deletion": "Users other than admin and page author are required to belong to all groups that are granted page access",
+    "is_all_group_membership_required_for_page_complete_deletion_explanation": "Effective when page access settings is set to \"Only specific groups\".",
     "inherit": "Inherit(Use the same setting as for a single page)",
     "admin_only": "Admin only",
     "admin_and_author": "Admin and author",
@@ -857,12 +859,12 @@
     "return": "Return",
     "clear": "Clear",
     "activity_expiration_date": "Audit Log expiration date",
-    "activity_expiration_date_explain": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
+    "activity_expiration_date_explanation": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
     "fixed_by_env_var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
     "available_action_list": "Search / View All Available Actions",
-    "available_action_list_explain": "List of actions that can be searched/viewed in the current settings",
+    "available_action_list_explanation": "List of actions that can be searched/viewed in the current settings",
     "action_list": "Action List",
-    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
+    "disable_mode_explanation": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 6 - 3
apps/app/public/static/locales/en_US/translation.json

@@ -328,7 +328,8 @@
     "already_exists": "Page with the path already exists.",
     "outdated": "Page is updated someone and now outdated.",
     "user_not_admin": "Only admin user can delete",
-    "single_deletion_empty_pages": "Empty pages cannot be single deleted"
+    "single_deletion_empty_pages": "Empty pages cannot be single deleted",
+    "complete_deletion_not_allowed_for_user": "You are not allowed to delete this page completely"
   },
   "page_history": {
     "revision_list": "Revision list",
@@ -389,10 +390,12 @@
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
-      "Same page already exists": "Same page already exists"
+      "Same page already exists": "Same page already exists",
+      "Only duplicate user related resources": "Only duplicate user related resources"
     },
     "help": {
-      "recursive": "Duplicate children of under this path recursively"
+      "recursive": "Duplicate children of under this path recursively",
+      "only_user_related_resources": "This will only duplicate pages that the user has permission to view. If the page permission is set to \"Only specific groups\", only user related groups will be set to the page duplicate."
     }
   },
   "duplicated_pages": "{{fromPath}} has been duplicated",

+ 9 - 7
apps/app/public/static/locales/ja_JP/admin.json

@@ -47,10 +47,12 @@
     "page_delete": "ゴミ箱に入れる",
     "page_delete_completely": "完全に削除する",
     "other_options": "その他のオプション",
-    "deletion_explain": "ページをゴミ箱に入れることができるユーザーを制限します。",
-    "complete_deletion_explain": "ページを完全削除することができるユーザーを制限します。",
-    "recursive_deletion_explain": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
-    "recursive_complete_deletion_explain": "子孫を含めたページを完全削除することができるユーザーを制限します。",
+    "deletion_explanation": "ページをゴミ箱に入れることができるユーザーを制限します。",
+    "complete_deletion_explanation": "ページを完全削除することができるユーザーを制限します。",
+    "recursive_deletion_explanation": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
+    "recursive_complete_deletion_explanation": "子孫を含めたページを完全削除することができるユーザーを制限します。",
+    "is_all_group_membership_required_for_page_complete_deletion": "管理者とページ作者以外はページに対する権限を持つ全てのグループに所属している必要がある",
+    "is_all_group_membership_required_for_page_complete_deletion_explanation": "ページの権限設定が「特定のグループのみ」の場合有効になります。",
     "inherit": "単体のみと同じ",
     "admin_only": "管理者のみ可能",
     "admin_and_author": "管理者とページ作者が可能",
@@ -867,12 +869,12 @@
     "return": "戻る",
     "clear": "クリア",
     "activity_expiration_date": "監査ログの有効期限",
-    "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
+    "activity_expiration_date_explanation": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
     "available_action_list": "検索 / 表示 可能なアクション一覧",
-    "available_action_list_explain": "現在の設定で検索 / 表示 可能なアクション一覧です",
+    "available_action_list_explanation": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
-    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "disable_mode_explanation": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
     "docs_url": {
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 6 - 3
apps/app/public/static/locales/ja_JP/translation.json

@@ -361,7 +361,8 @@
     "already_exists": "そのパスを持つページは既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
     "user_not_admin": "権限のあるユーザーのみが削除できます",
-    "single_deletion_empty_pages": "空ページの単体削除はできません"
+    "single_deletion_empty_pages": "空ページの単体削除はできません",
+    "complete_deletion_not_allowed_for_user": "ページを完全に削除する権限がありません"
   },
   "page_history": {
     "revision_list": "更新履歴",
@@ -422,10 +423,12 @@
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に複製",
       "Duplicate without exist path": "存在するパス以外を複製する",
-      "Same page already exists": "同じページがすでに存在します"
+      "Same page already exists": "同じページがすでに存在します",
+      "Only duplicate user related resources": "ユーザに関連のあるリソースのみを複製する"
     },
     "help": {
-      "recursive": "配下のページも複製します"
+      "recursive": "配下のページも複製します",
+      "only_user_related_resources": "ユーザが閲覧可能なページのみを複製します。また、閲覧権限が「特定グループのみ」で設定されている場合、複製後のページにはユーザが所属するグループのみを閲覧可能なグループとして設定します。"
     }
   },
   "duplicated_pages": "{{fromPath}} を複製しました",

+ 9 - 7
apps/app/public/static/locales/zh_CN/admin.json

@@ -47,10 +47,12 @@
     "page_delete": "删除",
     "page_delete_completely": "彻底删除",
     "other_options": "其他选项",
-    "deletion_explain": "限制用户对选定的单一页面进行垃圾处理。",
-    "complete_deletion_explain": "限制可以完全删除所选单页的用户。",
-    "recursive_deletion_explain": "限制用户可以捣毁包括子孙在内的页面。",
-    "recursive_complete_deletion_explain": "限制可以完全删除页面的用户,包括子孙。",
+    "deletion_explanation": "限制用户对选定的单一页面进行垃圾处理。",
+    "complete_deletion_explanation": "限制可以完全删除所选单页的用户。",
+    "recursive_deletion_explanation": "限制用户可以捣毁包括子孙在内的页面。",
+    "recursive_complete_deletion_explanation": "限制可以完全删除页面的用户,包括子孙。",
+    "is_all_group_membership_required_for_page_complete_deletion": "除管理员和页面作者之外的用户必须属于被授予页面访问权限的所有组",
+    "is_all_group_membership_required_for_page_complete_deletion_explanation": "如果页面权限设置为\"仅限特定群体\",则会启用此功能。",
     "inherit": "继承(使用与单页相同的设置)。",
 		"admin_only": "仅管理员",
 		"admin_and_author": "管理员|作者",
@@ -866,12 +868,12 @@
     "return": "返回",
     "clear": "清除",
     "activity_expiration_date": "审计日志的到期日",
-    "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
+    "activity_expiration_date_explanation": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "available_action_list": "搜索/查看 所有可用的行动",
-    "available_action_list_explain": "在当前配置中可以搜索/查看的行动列表",
+    "available_action_list_explanation": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
-    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "disable_mode_explanation": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 6 - 3
apps/app/public/static/locales/zh_CN/translation.json

@@ -318,7 +318,8 @@
 		"already_exists": "具有该路径的页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"user_not_admin": "仅管理员用户可以删除",
-    "single_deletion_empty_pages": "空的页面不能被单一删除"
+    "single_deletion_empty_pages": "空的页面不能被单一删除",
+    "complete_deletion_not_allowed_for_user": "您无权永久删除该页面"
   },
   "page_history": {
     "revision_list": "修订清单",
@@ -379,10 +380,12 @@
 			"Current page name": "Current page name",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
-      "Same page already exists": "Same page already exists"
+      "Same page already exists": "Same page already exists",
+      "Only duplicate user related resources": "Only duplicate user related resources"
     },
     "help": {
-      "recursive": "Duplicate children of under this path recursively"
+      "recursive": "Duplicate children of under this path recursively",
+      "only_user_related_resources": "This will only duplicate pages that the user has permission to view. If the page permission is set to \"Only specific groups\", only user related groups will be set to the page duplicate."
     }
   },
   "duplicated_pages": "{{fromPath}} 已重复",

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

@@ -32,6 +32,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
       currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
+      isAllGroupMembershipRequiredForPageCompleteDeletion: true,
       previousPageRecursiveDeletionAuthority: null,
       previousPageRecursiveCompleteDeletionAuthority: null,
       expandOtherOptionsForDeletion: false,
@@ -73,6 +74,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
       currentPageRecursiveDeletionAuthority: generalSetting.pageRecursiveDeletionAuthority,
       currentPageRecursiveCompleteDeletionAuthority: generalSetting.pageRecursiveCompleteDeletionAuthority,
+      isAllGroupMembershipRequiredForPageCompleteDeletion: generalSetting.isAllGroupMembershipRequiredForPageCompleteDeletion,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
@@ -154,6 +156,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ currentPageRecursiveCompleteDeletionAuthority: val });
   }
 
+  /**
+   * Switch isAllGroupMembershipRequiredForPageCompleteDeletion
+   */
+  switchIsAllGroupMembershipRequiredForPageCompleteDeletion() {
+    this.setState({ isAllGroupMembershipRequiredForPageCompleteDeletion: !this.state.isAllGroupMembershipRequiredForPageCompleteDeletion });
+  }
+
   /**
    * Change previousPageRecursiveDeletionAuthority
    */
@@ -225,6 +234,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       pageCompleteDeletionAuthority: this.state.currentPageCompleteDeletionAuthority,
       pageRecursiveDeletionAuthority: this.state.currentPageRecursiveDeletionAuthority,
       pageRecursiveCompleteDeletionAuthority: this.state.currentPageRecursiveCompleteDeletionAuthority,
+      isAllGroupMembershipRequiredForPageCompleteDeletion: this.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,

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

@@ -1,9 +1,10 @@
 import { useCallback, useEffect } from 'react';
 
+import { useGlobalSocket } from '@growi/core/dist/swr';
+
 import { SocketEventName } from '~/interfaces/websocket';
 import { useCurrentPageId } from '~/stores/page';
 import { useSetRemoteLatestPageData, type RemoteRevisionData } from '~/stores/remote-latest-page';
-import { useGlobalSocket } from '~/stores/websocket';
 
 export const usePageUpdatedEffect = (): void => {
 

+ 123 - 0
apps/app/src/client/services/use-create-page-and-transit.tsx

@@ -0,0 +1,123 @@
+import { useCallback } from 'react';
+
+import { useRouter } from 'next/router';
+
+import { createPage } from '~/client/services/page-operation';
+import { useIsNotFound, useSWRxCurrentPage } from '~/stores/page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
+
+/**
+ * Invoked when creation and transition has finished
+ */
+type OnCreated = () => void;
+/**
+ * Invoked when either creation or transition has aborted
+ */
+type OnAborted = () => void;
+/**
+ * Invoked when an error is occured
+ */
+type OnError = (err) => void;
+/**
+ * Always invoked after processing is terminated
+ */
+type OnTerminated = () => void;
+
+type CreatePageAndTransitOpts = {
+  onCreationStart?: OnCreated,
+  onCreated?: OnCreated,
+  onAborted?: OnAborted,
+  onError?: OnError,
+  onTerminated?: OnTerminated,
+}
+
+type CreatePageAndTransit = (
+  pagePath: string | undefined,
+  // grant?: number,
+  // grantUserGroupId?: string,
+  opts?: CreatePageAndTransitOpts,
+) => Promise<void>;
+
+export const useCreatePageAndTransit = (): CreatePageAndTransit => {
+
+  const router = useRouter();
+
+  const { data: isNotFound } = useIsNotFound();
+  const { data: currentPage, isLoading } = useSWRxCurrentPage();
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  // const {
+  //   path: currentPagePath,
+  //   grant: currentPageGrant,
+  //   grantedGroups: currentPageGrantedGroups,
+  // } = currentPage ?? {};
+
+  return useCallback(async(pagePath, opts = {}) => {
+    if (isLoading) {
+      return;
+    }
+
+    const {
+      onCreationStart, onCreated, onAborted, onError, onTerminated,
+    } = opts;
+
+    if (isNotFound == null || !isNotFound || pagePath == null) {
+      mutateEditorMode(EditorMode.Editor);
+
+      onAborted?.();
+      onTerminated?.();
+      return;
+    }
+
+    try {
+      onCreationStart?.();
+
+      /**
+       * !! NOTICE !! - Verification of page createable or not is checked on the server side.
+       * since the new page path is not generated on the client side.
+       * need shouldGeneratePath flag.
+       */
+      // const shouldCreateUnderRoot = currentPagePath == null || currentPageGrant == null;
+      // const parentPath = shouldCreateUnderRoot
+      //   ? '/'
+      //   : currentPagePath;
+
+      // const params = {
+      //   isSlackEnabled: false,
+      //   slackChannels: '',
+      //   grant: shouldCreateUnderRoot ? 1 : currentPageGrant,
+      //   grantUserGroupIds: shouldCreateUnderRoot ? undefined : currentPageGrantedGroups,
+      //   shouldGeneratePath: true,
+      // };
+
+      // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
+      // const response = await createPage(parentPath, '', params);
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+        // grant,
+        // grantUserGroupId,
+      };
+
+      const response = await createPage(pagePath, '', params);
+
+      await router.push(`${response.page.id}#edit`);
+      mutateEditorMode(EditorMode.Editor);
+
+      onCreated?.();
+    }
+    catch (err) {
+      logger.warn(err);
+      onError?.(err);
+    }
+    finally {
+      onTerminated?.();
+    }
+
+  }, [isLoading, isNotFound, mutateEditorMode, router]);
+};

+ 1 - 1
apps/app/src/client/services/use-on-template-button-clicked.ts

@@ -4,7 +4,7 @@ import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { useRouter } from 'next/router';
 
 import { createPage, exist } from '~/client/services/page-operation';
-import { LabelType } from '~/interfaces/template';
+import type { LabelType } from '~/interfaces/template';
 
 export const useOnTemplateButtonClicked = (
     currentPagePath?: string,

+ 1 - 1
apps/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx

@@ -16,7 +16,7 @@ export const AuditLogDisableMode: FC = () => {
               <h1 className="text-center">{t('audit_log_management.audit_log')}</h1>
               <h3
                 // eslint-disable-next-line react/no-danger
-                dangerouslySetInnerHTML={{ __html: t('audit_log_management.disable_mode_explain') }}
+                dangerouslySetInnerHTML={{ __html: t('audit_log_management.disable_mode_explanation') }}
               />
             </div>
           </div>

+ 2 - 2
apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -21,7 +21,7 @@ export const AuditLogSettings: FC = () => {
     <>
       <h4 className="mt-4">{t('admin:audit_log_management.activity_expiration_date')}</h4>
       <p className="form-text text-muted">
-        {t('admin:audit_log_management.activity_expiration_date_explain')}
+        {t('admin:audit_log_management.activity_expiration_date_explanation')}
       </p>
       <p className="alert alert-warning col-6">
         <span className="material-symbols-outlined">error</span>
@@ -50,7 +50,7 @@ export const AuditLogSettings: FC = () => {
         </a>
       </h4>
       <p className="form-text text-muted">
-        {t('admin:audit_log_management.available_action_list_explain')}
+        {t('admin:audit_log_management.available_action_list_explanation')}
       </p>
       <p className="mt-1">
         <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>

+ 24 - 3
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -238,14 +238,14 @@ class SecuritySetting extends React.Component {
           </button>
         </div>
         <p className="form-text text-muted small">
-          {t(`security_settings.${getDeletionTypeForT(deletionType)}_explain`)}
+          {t(`security_settings.${getDeletionTypeForT(deletionType)}_explanation`)}
         </p>
       </div>
     );
   }
 
   renderPageDeletePermission(currentState, setState, deletionType, isButtonDisabled) {
-    const { t } = this.props;
+    const { t, adminGeneralSecurityContainer } = this.props;
 
     const expantDeleteOptionsState = this.expantDeleteOptionsState(deletionType);
 
@@ -265,7 +265,28 @@ class SecuritySetting extends React.Component {
           {
             !isRecursiveDeletion(deletionType)
               ? (
-                <>{this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}</>
+                <>
+                  {this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
+                  {currentState === PageDeleteConfigValue.Anyone && deletionType === DeletionType.CompleteDeletion && (
+                    <>
+                      <input
+                        id="isAllGroupMembershipRequiredForPageCompleteDeletionCheckbox"
+                        className="form-check-input"
+                        type="checkbox"
+                        checked={adminGeneralSecurityContainer.state.isAllGroupMembershipRequiredForPageCompleteDeletion}
+                        onChange={() => { adminGeneralSecurityContainer.switchIsAllGroupMembershipRequiredForPageCompleteDeletion() }}
+                      />
+                      <label className="form-check-label" htmlFor="isAllGroupMembershipRequiredForPageCompleteDeletionCheckbox">
+                        {t('security_settings.is_all_group_membership_required_for_page_complete_deletion')}
+                      </label>
+                      <p
+                        className="form-text text-muted small mt-2"
+                      >
+                        {t('security_settings.is_all_group_membership_required_for_page_complete_deletion_explanation')}
+                      </p>
+                    </>
+                  )}
+                </>
               )
               : (
                 <>

+ 37 - 0
apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx

@@ -0,0 +1,37 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
+
+import { PageItemControl } from './PageItemControl';
+
+
+describe('PageItemControl.tsx', () => {
+  it('Should trigger onClickRenameMenuItem() when clicking the rename button with pageInfo.isDeletable being "false"', async() => {
+    // setup
+    const onClickRenameMenuItemMock = vi.fn();
+
+    const pageInfo = {
+      isMovable: true,
+      isV5Compatible: true,
+      isEmpty: false,
+      isDeletable: false,
+      isAbleToDeleteCompletely: true,
+      isRevertible: true,
+    };
+
+    const props = {
+      pageId: 'dummy-page-id',
+      isEnableActions: true,
+      pageInfo,
+      onClickRenameMenuItem: onClickRenameMenuItemMock,
+    };
+
+    render(<PageItemControl {...props} />);
+
+    // when
+    const openPageMoveRenameModalButton = screen.getByTestId('open-page-move-rename-modal-btn');
+    await waitFor(() => userEvent.click(openPageMoveRenameModalButton, { pointerEventsCheck: PointerEventsCheckLevel.Never }));
+
+    // then
+    expect(onClickRenameMenuItemMock).toHaveBeenCalled();
+  });
+});

+ 3 - 5
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -85,8 +85,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
       return;
     }
-
-    if (!pageInfo?.isDeletable) {
+    if (!pageInfo?.isMovable) {
       logger.warn('This page could not be renamed.');
       return;
     }
@@ -177,10 +176,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
           <DropdownItem
             onClick={renameItemClickedHandler}
-            disabled={!pageInfo.isDeletable}
             data-testid="open-page-move-rename-modal-btn"
             className="grw-page-control-dropdown-item"
           >
@@ -232,7 +230,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* divider */}
         {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && pageInfo.isDeletable && (
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem

+ 24 - 12
apps/app/src/components/Common/PagePathHierarchicalLink/CollapsedParentsDropdown.tsx

@@ -1,12 +1,25 @@
+import { useMemo } from 'react';
+
+import Link from 'next/link';
 import {
   DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown,
 } from 'reactstrap';
 
-import LinkedPagePath from '~/models/linked-page-path';
-
+import type LinkedPagePath from '~/models/linked-page-path';
 
 import styles from './CollapsedParentsDropdown.module.scss';
 
+const getAncestorPathAndPathNames = (linkedPagePath: LinkedPagePath) => {
+  const pathAndPathName: Array<{ path: string, pathName: string }> = [];
+  let currentLinkedPagePath = linkedPagePath;
+
+  while (currentLinkedPagePath.parent != null) {
+    pathAndPathName.unshift({ path: currentLinkedPagePath.path, pathName: currentLinkedPagePath.pathName });
+    currentLinkedPagePath = currentLinkedPagePath.parent;
+  }
+
+  return pathAndPathName;
+};
 
 type Props = {
   linkedPagePath: LinkedPagePath,
@@ -15,20 +28,19 @@ type Props = {
 export const CollapsedParentsDropdown = (props: Props): JSX.Element => {
   const { linkedPagePath } = props;
 
+  const ancestorPathAndPathNames = useMemo(() => getAncestorPathAndPathNames(linkedPagePath), [linkedPagePath]);
+
   return (
     <UncontrolledDropdown className="d-inline-block">
       <DropdownToggle color="transparent">...</DropdownToggle>
       <DropdownMenu className={`dropdown-menu ${styles['collapsed-parents-dropdown-menu']}`} container="body">
-        {/* TODO: generate DropdownItems */}
-        <DropdownItem>
-          <a role="menuitem">foo</a>
-        </DropdownItem>
-        <DropdownItem>
-          <a role="menuitem">bar</a>
-        </DropdownItem>
-        <DropdownItem>
-          <a role="menuitem">baz</a>
-        </DropdownItem>
+        {ancestorPathAndPathNames.map(data => (
+          <DropdownItem key={data.path}>
+            <Link href={data.path} legacyBehavior>
+              <a role="menuitem">{data.pathName}</a>
+            </Link>
+          </DropdownItem>
+        ))}
       </DropdownMenu>
     </UncontrolledDropdown>
   );

+ 8 - 10
apps/app/src/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx

@@ -1,11 +1,12 @@
 import { useEffect } from 'react';
 
+import { useSearchModal } from '~/features/search/client/stores/search';
 import { useIsEditable } from '~/stores/context';
-import { useGlobalSearchFormRef } from '~/stores/ui';
+
 
 const FocusToGlobalSearch = (props) => {
   const { data: isEditable } = useIsEditable();
-  const { data: globalSearchFormRef } = useGlobalSearchFormRef();
+  const { data: searchModalData, open: openSearchModal } = useSearchModal();
 
   // setup effect
   useEffect(() => {
@@ -13,16 +14,13 @@ const FocusToGlobalSearch = (props) => {
       return;
     }
 
-    // ignore when dom that has 'modal in' classes exists
-    if (document.getElementsByClassName('modal in').length > 0) {
-      return;
+    if (!searchModalData.isOpened) {
+      openSearchModal();
+      // remove this
+      props.onDeleteRender();
     }
 
-    globalSearchFormRef.current.focus();
-
-    // remove this
-    props.onDeleteRender();
-  }, [globalSearchFormRef, isEditable, props]);
+  }, [isEditable, openSearchModal, props, searchModalData.isOpened]);
 
   return null;
 };

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

@@ -5,6 +5,7 @@ import React, {
 import path from 'path';
 
 import type { Nullable, IPageHasId, IPageToDeleteWithMeta } from '@growi/core';
+import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { debounce } from 'throttle-debounce';
@@ -22,7 +23,6 @@ import {
 } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
 import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
-import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
 import { ItemNode, type TreeItemProps } from '../TreeItem';

+ 1 - 1
apps/app/src/components/Layout/SearchResultLayout.tsx

@@ -1,4 +1,4 @@
-import React, { ReactNode } from 'react';
+import React, { type ReactNode } from 'react';
 
 import { BasicLayout } from '~/components/Layout/BasicLayout';
 

+ 20 - 14
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -3,9 +3,10 @@ import React, { type ReactNode, useCallback, useState } from 'react';
 import type { IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
-import { EditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
+import { toastError } from '~/client/util/toastr';
+import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
-import { useOnPageEditorModeButtonClicked } from './hooks';
+import { useCreatePageAndTransit } from '../../client/services/use-create-page-and-transit';
 
 import styles from './PageEditorModeManager.module.scss';
 
@@ -15,7 +16,7 @@ type PageEditorModeButtonProps = {
   editorMode: EditorMode,
   children?: ReactNode,
   isBtnDisabled?: boolean,
-  onClick?: (mode: EditorMode) => void,
+  onClick?: () => void,
 }
 const PageEditorModeButton = React.memo((props: PageEditorModeButtonProps) => {
   const {
@@ -34,7 +35,7 @@ const PageEditorModeButton = React.memo((props: PageEditorModeButtonProps) => {
     <button
       type="button"
       className={classNames.join(' ')}
-      onClick={() => onClick?.(editorMode)}
+      onClick={onClick}
       data-testid={`${editorMode}-button`}
     >
       {children}
@@ -60,21 +61,26 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     // grantUserGroupId,
   } = props;
 
-  const { t } = useTranslation();
+  const { t } = useTranslation('common');
   const [isCreating, setIsCreating] = useState(false);
 
+  const { mutate: mutateEditorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
-  const onPageEditorModeButtonClicked = useOnPageEditorModeButtonClicked(setIsCreating, path);
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
-  const pageEditorModeButtonClickedHandler = useCallback((viewType: EditorMode) => {
-    if (_isBtnDisabled) {
-      return;
-    }
+  const createPageAndTransit = useCreatePageAndTransit();
 
-    onPageEditorModeButtonClicked?.(viewType);
-  }, [_isBtnDisabled, onPageEditorModeButtonClicked]);
+  const editButtonClickedHandler = useCallback(() => {
+    createPageAndTransit(
+      path,
+      {
+        onCreationStart: () => { setIsCreating(true) },
+        onError: () => { toastError(t('toaster.create_failed', { target: path })) },
+        onTerminated: () => { setIsCreating(false) },
+      },
+    );
+  }, [createPageAndTransit, path, t]);
 
   return (
     <>
@@ -89,7 +95,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             currentEditorMode={editorMode}
             editorMode={EditorMode.View}
             isBtnDisabled={_isBtnDisabled}
-            onClick={pageEditorModeButtonClickedHandler}
+            onClick={() => mutateEditorMode(EditorMode.View)}
           >
             <span className="material-symbols-outlined fs-4">play_arrow</span>{t('View')}
           </PageEditorModeButton>
@@ -99,7 +105,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             currentEditorMode={editorMode}
             editorMode={EditorMode.Editor}
             isBtnDisabled={_isBtnDisabled}
-            onClick={pageEditorModeButtonClickedHandler}
+            onClick={editButtonClickedHandler}
           >
             <span className="material-symbols-outlined me-1 fs-5">edit_square</span>{t('Edit')}
           </PageEditorModeButton>

+ 0 - 58
apps/app/src/components/Navbar/hooks.tsx

@@ -1,58 +0,0 @@
-import { useCallback } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
-
-import { createPage } from '~/client/services/page-operation';
-import { toastError } from '~/client/util/toastr';
-import { useIsNotFound } from '~/stores/page';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
-
-export const useOnPageEditorModeButtonClicked = (
-    setIsCreating:React.Dispatch<React.SetStateAction<boolean>>,
-    path?: string,
-    // grant?: number,
-    // grantUserGroupId?: string,
-): (editorMode: EditorMode) => Promise<void> => {
-  const router = useRouter();
-  const { t } = useTranslation('commons');
-  const { data: isNotFound } = useIsNotFound();
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  return useCallback(async(editorMode: EditorMode) => {
-    if (isNotFound == null || path == null) {
-      return;
-    }
-
-    if (editorMode === EditorMode.Editor && isNotFound) {
-      try {
-        setIsCreating(true);
-
-        const params = {
-          isSlackEnabled: false,
-          slackChannels: '',
-          grant: 4,
-          // grant,
-          // grantUserGroupId,
-        };
-
-        const response = await createPage(path, '', params);
-
-        // Should not mutateEditorMode as it might prevent transitioning during mutation
-        router.push(`${response.page.id}#edit`);
-      }
-      catch (err) {
-        logger.warn(err);
-        toastError(t('toaster.create_failed', { target: path }));
-      }
-      finally {
-        setIsCreating(false);
-      }
-    }
-
-    mutateEditorMode(editorMode);
-  }, [isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
-};

+ 62 - 30
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useState, useCallback } from 'react';
 
-import { PageGrant } from '@growi/core';
+import { PageGrant, GroupType } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -31,7 +31,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
 
   const [isGroupSelectModalShown, setIsGroupSelectModalShown] = useState(false);
-  const [selectedGroup, setSelectedGroup] = useState<PopulatedGrantedGroup | undefined>(undefined);
+  const [selectedGroups, setSelectedGroups] = useState<PopulatedGrantedGroup[]>([]);
 
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
@@ -42,14 +42,23 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   useEffect(() => {
     if (isOpen) {
       setSelectedGrant(PageGrant.GRANT_RESTRICTED);
-      setSelectedGroup(undefined);
+      setSelectedGroups([]);
       setShowModalAlert(false);
     }
   }, [isOpen]);
 
+  const groupListItemClickHandler = (group: PopulatedGrantedGroup) => {
+    if (selectedGroups.find(g => g.item._id === group.item._id) != null) {
+      setSelectedGroups(selectedGroups.filter(g => g.item._id !== group.item._id));
+    }
+    else {
+      setSelectedGroups([...selectedGroups, group]);
+    }
+  };
+
   const submit = async() => {
     // Validate input values
-    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroups.length === 0) {
       setShowModalAlert(true);
       return;
     }
@@ -59,7 +68,9 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
-        grantedGroups: selectedGroup?.item._id != null ? [{ item: selectedGroup?.item._id, type: selectedGroup.type }] : null,
+        userRelatedGrantedGroups: selectedGroups.length !== 0 ? selectedGroups.map((g) => {
+          return { item: g.item._id, type: g.type };
+        }) : null,
       });
 
       toastSuccess(t('Successfully updated'));
@@ -88,10 +99,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
 
     if (grantData.grant === 5) {
-      if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
+      if (grantData.userRelatedGrantedGroups == null || grantData.userRelatedGrantedGroups.length === 0) {
         return t('fix_page_grant.modal.grant_label.isForbidden');
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroups[0].name})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.userRelatedGrantedGroups.map(g => g.name).join(', ')})`;
     }
 
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -180,31 +191,17 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                   <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
+                    onClick={() => setIsGroupSelectModalShown(true)}
                   >
                     <span className="float-start ms-2">
                       {
-                        selectedGroup == null
+                        selectedGroups.length === 0
                           ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroup.item.name
+                          : selectedGroups.map(g => g.item.name).join(', ')
                       }
                     </span>
                   </button>
-                  <div className="dropdown-menu">
-                    {
-                      applicableGroups != null && applicableGroups.map(g => (
-                        <button
-                          key={g.item._id}
-                          className="dropdown-item"
-                          type="button"
-                          onClick={() => setSelectedGroup(g)}
-                        >
-                          {g.item.name}
-                        </button>
-                      ))
-                    }
-                  </div>
                 </div>
               </div>
               {
@@ -227,12 +224,47 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpen} toggle={close}>
-      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
-        { t('fix_page_grant.modal.title') }
-      </ModalHeader>
-      {renderModalBodyAndFooter()}
-    </Modal>
+    <>
+      <Modal size="lg" isOpen={isOpen} toggle={close}>
+        <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+          { t('fix_page_grant.modal.title') }
+        </ModalHeader>
+        {renderModalBodyAndFooter()}
+      </Modal>
+      {applicableGroups != null && (
+        <Modal
+          isOpen={isGroupSelectModalShown}
+          toggle={() => setIsGroupSelectModalShown(false)}
+        >
+          <ModalHeader tag="h4" toggle={() => setIsGroupSelectModalShown(false)} className="bg-purple text-light">
+            {t('user_group.select_group')}
+          </ModalHeader>
+          <ModalBody>
+            <>
+              { applicableGroups.map((group) => {
+                const groupIsGranted = selectedGroups?.find(g => g.item._id === group.item._id) != null;
+                const activeClass = groupIsGranted ? 'active' : '';
+
+                return (
+                  <button
+                    className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
+                    type="button"
+                    key={group.item._id}
+                    onClick={() => groupListItemClickHandler(group)}
+                  >
+                    <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
+                    <h5 className="d-inline-block ml-3">{group.item.name}</h5>
+                    {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
+                    {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
+                  </button>
+                );
+              }) }
+              <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsGroupSelectModalShown(false)}>{t('Done')}</button>
+            </>
+          </ModalBody>
+        </Modal>
+      )}
+    </>
   );
 };
 

+ 5 - 1
apps/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -14,6 +14,10 @@ export const PageGrantAlert = (): JSX.Element => {
     return <></>;
   }
 
+  const populatedGrantedGroups = () => {
+    return pageData.grantedGroups.filter(group => isPopulated(group.item));
+  };
+
   const renderAlertContent = () => {
     const getGrantLabel = () => {
       if (pageData.grant === 2) {
@@ -35,7 +39,7 @@ export const PageGrantAlert = (): JSX.Element => {
           <>
             <i className="icon-fw icon-organization"></i>
             <strong>{
-              isPopulated(pageData.grantedGroups[0].item) && pageData.grantedGroups[0].item.name
+              populatedGrantedGroups().map(g => g.item.name).join(', ')
             }
             </strong>
           </>

+ 2 - 3
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -14,7 +14,7 @@ import {
 
 import { apiPostForm } from '~/client/util/apiv1-client';
 import { toastError } from '~/client/util/toastr';
-import { IEditorMethods } from '~/interfaces/editor-methods';
+import type { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment, useSWRxEditingCommentsNum } from '~/stores/comment';
 import {
   useCurrentUser, useIsSlackConfigured,
@@ -27,7 +27,6 @@ import { useNextThemes } from '~/stores/use-next-themes';
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
-import Editor from '../PageEditor/Editor';
 
 import { CommentPreview } from './CommentPreview';
 
@@ -83,7 +82,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.COMMENT);
   const { resolvedTheme } = useNextThemes();
-  mutateResolvedTheme(resolvedTheme);
+  mutateResolvedTheme({ themeData: resolvedTheme });
 
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [comment, setComment] = useState(commentBody ?? '');

+ 7 - 3
apps/app/src/components/PageDeleteModal.tsx

@@ -2,7 +2,6 @@ import React, {
   useState, FC, useMemo, useEffect,
 } from 'react';
 
-import { isIPageInfoForEntity } from '@growi/core';
 import type {
   HasObjectId,
   IPageInfoForEntity, IPageToDeleteWithMeta, IDataWithMeta,
@@ -42,6 +41,11 @@ const deleteIconAndKey = {
   },
 };
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const isIPageInfoForEntityForDeleteModal = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
+  return pageInfo != null && 'isDeletable' in pageInfo && 'isAbleToDeleteCompletely' in pageInfo;
+};
+
 const PageDeleteModal: FC = () => {
   const { t } = useTranslation();
 
@@ -50,14 +54,14 @@ const PageDeleteModal: FC = () => {
   const isOpened = deleteModalData?.isOpened ?? false;
 
   const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
-    .filter(p => !isIPageInfoForEntity(p.meta));
+    .filter(p => !isIPageInfoForEntityForDeleteModal(p.meta));
   const notOperatablePageIds = notOperatablePages.map(p => p.data._id);
 
   const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
 
   // inject IPageInfo to operate
   let injectedPages: IDataWithMeta<HasObjectId & { path: string }, IPageInfoForEntity>[] | null = null;
-  if (deleteModalData?.pages != null && notOperatablePageIds.length > 0) {
+  if (deleteModalData?.pages != null) {
     injectedPages = injectTo(deleteModalData?.pages);
   }
 

+ 27 - 9
apps/app/src/components/PageDuplicateModal.tsx

@@ -37,6 +37,7 @@ const PageDuplicateModal = (): JSX.Element => {
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
+  const [onlyDuplicateUserRelatedResources, setOnlyDuplicateUserRelatedResources] = useState(false);
 
   const updateSubordinatedList = useCallback(async() => {
     if (page == null) {
@@ -114,7 +115,9 @@ const PageDuplicateModal = (): JSX.Element => {
 
     const { pageId, path } = page;
     try {
-      const { data } = await apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
+      const { data } = await apiv3Post('/pages/duplicate', {
+        pageId, pageNameInput, isRecursively: isDuplicateRecursively, onlyDuplicateUserRelatedResources,
+      });
       const onDuplicated = duplicateModalData?.opts?.onDuplicated;
       const fromPath = path;
       const toPath = data.page.path;
@@ -127,7 +130,7 @@ const PageDuplicateModal = (): JSX.Element => {
     catch (err) {
       setErrs(err);
     }
-  }, [closeDuplicateModal, duplicateModalData?.opts?.onDuplicated, isDuplicateRecursively, page, pageNameInput]);
+  }, [closeDuplicateModal, duplicateModalData?.opts?.onDuplicated, isDuplicateRecursively, page, pageNameInput, onlyDuplicateUserRelatedResources]);
 
   useEffect(() => {
     if (isOpened) {
@@ -193,7 +196,7 @@ const PageDuplicateModal = (): JSX.Element => {
           <p className="text-danger">Error: Target path is duplicated.</p>
         ) }
 
-        <div className="form-check form-check-warning mb-3">
+        <div className="form-check form-check-warning">
           <input
             className="form-check-input"
             name="recursively"
@@ -204,7 +207,7 @@ const PageDuplicateModal = (): JSX.Element => {
           />
           <label className="form-label form-check-label" htmlFor="cbDuplicateRecursively">
             { t('modal_duplicate.label.Recursively') }
-            <p className="form-text text-muted mt-0">{ t('modal_duplicate.help.recursive') }</p>
+            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
           </label>
 
           <div>
@@ -220,15 +223,30 @@ const PageDuplicateModal = (): JSX.Element => {
                 />
                 <label className="form-label form-check-label" htmlFor="cbDuplicatewithoutExistRecursively">
                   { t('modal_duplicate.label.Duplicate without exist path') }
+                  <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
                 </label>
               </div>
             )}
           </div>
-          <div>
-            {isDuplicateRecursively && existingPaths.length !== 0 && (
-              <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
-            ) }
-          </div>
+        </div>
+
+        <div className="form-check form-check-warning mb-3">
+          <input
+            className="form-check-input"
+            id="cbOnlyDuplicateUserRelatedResources"
+            type="checkbox"
+            checked={onlyDuplicateUserRelatedResources}
+            onChange={() => setOnlyDuplicateUserRelatedResources(!onlyDuplicateUserRelatedResources)}
+          />
+          <label className="form-label form-check-label" htmlFor="cbOnlyDuplicateUserRelatedResources">
+            { t('modal_duplicate.label.Only duplicate user related resources') }
+            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.only_user_related_resources') }</p>
+          </label>
+        </div>
+        <div>
+          {isDuplicateRecursively && existingPaths.length !== 0 && (
+            <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
+          ) }
         </div>
       </>
     );

+ 0 - 2
apps/app/src/components/PageEditor/EditorNavbarBottom.module.scss

@@ -4,8 +4,6 @@
 
 @include mixins.editing() {
   .grw-editor-navbar-bottom :global {
-    height: var.$grw-editor-navbar-bottom-height;
-
     .grw-grant-selector {
       @include bs.media-breakpoint-down(sm) {
         .btn .label {

+ 4 - 4
apps/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -4,12 +4,12 @@ import dynamic from 'next/dynamic';
 import { Collapse, Button } from 'reactstrap';
 
 
-import { SavePageControlsProps } from '~/components/SavePageControls';
+import type { SavePageControlsProps } from '~/components/SavePageControls';
 import { useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import {
-  useDrawerOpened, useEditorMode, useIsDeviceLargerThanLg, useIsDeviceLargerThanMd,
+  useEditorMode, useIsDeviceLargerThanLg, useIsDeviceLargerThanMd,
 } from '~/stores/ui';
 
 
@@ -93,11 +93,11 @@ const EditorNavbarBottom = (): JSX.Element => {
         </Collapse>
       )
       }
-      <div className={`flex-expand-horiz align-items-center border-top px-2 px-md-3 ${moduleClass}`}>
+      <div className={`flex-expand-horiz align-items-center px-2 px-md-3 ${moduleClass}`}>
         <form>
           { isDeviceLargerThanMd && <OptionsSelector /> }
         </form>
-        <form className="flex-nowrap ms-auto">
+        <form className="row row-cols-lg-auto g-3 align-items-center ms-auto">
           {/* Responsive Design for the SlackNotification */}
           {/* Button or the normal Slack banner */}
           {isSlackConfigured && (!isDeviceLargerThanMd ? (

+ 2 - 3
apps/app/src/components/PageEditor/LinkEditModal.tsx

@@ -2,6 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react';
 
 import path from 'path';
 
+import Linker from '@growi/editor/src/services/link-util/Linker';
+import { useLinkEditModal } from '@growi/editor/src/stores/use-link-edit-modal';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
@@ -13,10 +15,7 @@ import {
 } from 'reactstrap';
 import validator from 'validator';
 
-
-import Linker from '~/client/models/Linker';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { useLinkEditModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
 import { usePreviewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';

+ 34 - 37
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -2,10 +2,11 @@ import React, {
   useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
 } from 'react';
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 import nodePath from 'path';
 
 import type { IPageHasId } from '@growi/core';
+import { useGlobalSocket } from '@growi/core/dist/swr';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
   CodeMirrorEditorMain, GlobalCodeMirrorEditorKey, AcceptedUploadFileType,
@@ -21,10 +22,10 @@ import { useShouldExpandContent } from '~/client/services/layout';
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { OptionsToSave } from '~/interfaces/page-operation';
+import type { OptionsToSave } from '~/interfaces/page-operation';
 import { SocketEventName } from '~/interfaces/websocket';
 import {
-  useDefaultIndentSize,
+  useDefaultIndentSize, useCurrentUser,
   useCurrentPathname, useIsEnabledAttachTitleHeader,
   useIsEditable, useIsUploadAllFileAllowed, useIsUploadEnabled, useIsIndentSizeForced,
 } from '~/stores/context';
@@ -53,7 +54,6 @@ import {
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 import { useNextThemes } from '~/stores/use-next-themes';
-import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
 import { PageHeader } from '../PageHeader/PageHeader';
@@ -120,6 +120,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { mutate: mutateRemoteRevisionId } = useRemoteRevisionBody();
   const { mutate: mutateRemoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
   const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
+  const { data: user } = useCurrentUser();
 
   const { data: socket } = useGlobalSocket();
 
@@ -135,7 +136,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
   const { resolvedTheme } = useNextThemes();
-  mutateResolvedTheme(resolvedTheme);
+  mutateResolvedTheme({ themeData: resolvedTheme });
 
   // TODO: remove workaround
   // for https://redmine.weseek.co.jp/issues/125923
@@ -174,15 +175,16 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const setMarkdownPreviewWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string) => {
     setMarkdownToPreview(value);
   })), []);
-  const mutateIsEnabledUnsavedWarningWithDebounce = useMemo(() => debounce(600, throttle(900, (value: string) => {
-    // Displays an unsaved warning alert
-    mutateIsEnabledUnsavedWarning(value !== initialValueRef.current);
-  })), [mutateIsEnabledUnsavedWarning]);
+  // const mutateIsEnabledUnsavedWarningWithDebounce = useMemo(() => debounce(600, throttle(900, (value: string) => {
+  //   // Displays an unsaved warning alert
+  //   mutateIsEnabledUnsavedWarning(value !== initialValueRef.current);
+  // })), [mutateIsEnabledUnsavedWarning]);
 
   const markdownChangedHandler = useCallback((value: string) => {
     setMarkdownPreviewWithDebounce(value);
-    mutateIsEnabledUnsavedWarningWithDebounce(value);
-  }, [mutateIsEnabledUnsavedWarningWithDebounce, setMarkdownPreviewWithDebounce]);
+    // mutateIsEnabledUnsavedWarningWithDebounce(value);
+  // }, [mutateIsEnabledUnsavedWarningWithDebounce, setMarkdownPreviewWithDebounce]);
+  }, [setMarkdownPreviewWithDebounce]);
 
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
@@ -218,7 +220,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     if (grantData == null) {
       return;
     }
-    const grantedGroups = grantData.grantedGroups?.map((group) => {
+    const userRelatedGrantedGroups = grantData.userRelatedGrantedGroups?.map((group) => {
       return { item: group.id, type: group.type };
     });
     const optionsToSave = {
@@ -226,7 +228,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       // pageTags: pageTags ?? [],
-      grantUserGroupIds: grantedGroups,
+      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
     };
     return optionsToSave;
   }, [grantData, isSlackEnabled]);
@@ -407,17 +409,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   }, [mutateCurrentPage, mutateEditingMarkdown, mutateIsConflict, mutateTagsInfo, syncTagsInfoForEditor]);
 
-
-  // initialize
-  useEffect(() => {
-    if (initialValue == null) {
-      return;
-    }
-    codeMirrorEditor?.initDoc(initialValue);
-    setMarkdownToPreview(initialValue);
-    mutateIsEnabledUnsavedWarning(false);
-  }, [codeMirrorEditor, initialValue, mutateIsEnabledUnsavedWarning]);
-
   // initial caret line
   useEffect(() => {
     codeMirrorEditor?.setCaretLine();
@@ -456,19 +447,21 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     }
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
 
-  // when transitioning to a different page, if the initialValue is the same,
-  // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
-  const onRouterChangeComplete = useCallback(() => {
-    codeMirrorEditor?.initDoc(initialValue);
-    codeMirrorEditor?.setCaretLine();
-  }, [codeMirrorEditor, initialValue]);
 
-  useEffect(() => {
-    router.events.on('routeChangeComplete', onRouterChangeComplete);
-    return () => {
-      router.events.off('routeChangeComplete', onRouterChangeComplete);
-    };
-  }, [onRouterChangeComplete, router.events]);
+  // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
+  // // when transitioning to a different page, if the initialValue is the same,
+  // // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
+  // const onRouterChangeComplete = useCallback(() => {
+  //   codeMirrorEditor?.initDoc(ydoc?.getText('codemirror').toString());
+  //   codeMirrorEditor?.setCaretLine();
+  // }, [codeMirrorEditor, ydoc]);
+
+  // useEffect(() => {
+  //   router.events.on('routeChangeComplete', onRouterChangeComplete);
+  //   return () => {
+  //     router.events.off('routeChangeComplete', onRouterChangeComplete);
+  //   };
+  // }, [onRouterChangeComplete, router.events]);
 
   if (!isEditable) {
     return <></>;
@@ -501,9 +494,13 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             onChange={markdownChangedHandler}
             onSave={saveWithShortcut}
             onUpload={uploadHandler}
+            acceptedFileType={acceptedFileType}
             onScroll={scrollEditorHandlerThrottle}
             indentSize={currentIndentSize ?? defaultIndentSize}
-            acceptedFileType={acceptedFileType}
+            userName={user?.name}
+            pageId={pageId ?? undefined}
+            initialValue={initialValue}
+            onOpenEditor={markdown => setMarkdownToPreview(markdown)}
             editorTheme={editorSettings?.theme}
           />
         </div>

+ 4 - 0
apps/app/src/components/PageManagement/ApiErrorMessage.jsx

@@ -30,6 +30,10 @@ const ApiErrorMessage = (props) => {
         return (
           <strong><i className="icon-fw icon-ban"></i>{ t('page_api_error.user_not_admin') }</strong>
         );
+      case 'complete_deletion_not_allowed_for_user':
+        return (
+          <strong><i className="icon-fw icon-ban"></i>{ t('page_api_error.complete_deletion_not_allowed_for_user') }</strong>
+        );
       case 'outdated':
         return (
           <>

+ 11 - 14
apps/app/src/components/PageSideContents/PageSideContents.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { Suspense, useCallback } from 'react';
 
 import { getIdForRef, type IPageHasId, type IPageInfoForOperation } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -37,7 +37,7 @@ type TagsProps = {
 const Tags = (props: TagsProps): JSX.Element => {
   const { pageId, revisionId } = props;
 
-  const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
+  const { data: tagsInfoData } = useSWRxTagsInfo(pageId, { suspense: true });
 
   const { data: showTagLabel } = useIsAbleToShowTagLabel();
   const { data: isGuestUser } = useIsGuestUser();
@@ -51,7 +51,7 @@ const Tags = (props: TagsProps): JSX.Element => {
     openTagEditModal(tagsInfoData.tags, pageId, revisionId);
   }, [pageId, revisionId, tagsInfoData, openTagEditModal]);
 
-  if (!showTagLabel) {
+  if (!showTagLabel || tagsInfoData == null) {
     return <></>;
   }
 
@@ -59,16 +59,11 @@ const Tags = (props: TagsProps): JSX.Element => {
 
   return (
     <div className="grw-taglabels-container">
-      { tagsInfoData?.tags != null
-        ? (
-          <PageTags
-            tags={tagsInfoData.tags}
-            isTagLabelsDisabled={isTagLabelsDisabled}
-            onClickEditTagsButton={onClickEditTagsButton}
-          />
-        )
-        : <PageTagsSkeleton />
-      }
+      <PageTags
+        tags={tagsInfoData.tags}
+        isTagLabelsDisabled={isTagLabelsDisabled}
+        onClickEditTagsButton={onClickEditTagsButton}
+      />
     </div>
   );
 };
@@ -97,7 +92,9 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   return (
     <>
       {/* Tags */}
-      <Tags pageId={page._id} revisionId={getIdForRef(page.revision)} />
+      <Suspense fallback={<PageTagsSkeleton />}>
+        <Tags pageId={page._id} revisionId={getIdForRef(page.revision)} />
+      </Suspense>
 
       <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
         {/* Page list */}

+ 3 - 2
apps/app/src/components/PageTags/PageTags.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import { Skeleton } from '../Skeleton';
 
@@ -23,7 +24,7 @@ export const PageTags:FC<Props> = (props: Props) => {
   } = props;
 
   if (tags == null) {
-    return <PageTagsSkeleton />;
+    return <></>;
   }
 
   const printNoneClass = tags.length === 0 ? 'd-print-none' : '';

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

@@ -2,6 +2,7 @@ import React, {
   useCallback, useMemo, useRef, useState, useEffect,
 } from 'react';
 
+import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalHeader, ModalBody, ModalFooter,
@@ -22,7 +23,6 @@ import { mutatePageTree, useSWRxV5MigrationStatus } from '~/stores/page-listing'
 import {
   useSWRxSearch,
 } from '~/stores/search';
-import { useGlobalSocket } from '~/stores/websocket';
 
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
 import PaginationWrapper from './PaginationWrapper';

+ 5 - 5
apps/app/src/components/SavePageControls.tsx

@@ -1,6 +1,6 @@
 import React, { useCallback } from 'react';
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 
 import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-path-utils';
 import { useTranslation } from 'next-i18next';
@@ -67,7 +67,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
     return null;
   }
 
-  const { grant, grantedGroups } = grantData;
+  const { grant, userRelatedGrantedGroups } = grantData;
 
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = t('Update');
@@ -82,14 +82,14 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
             <GrantSelector
               grant={grant}
               disabled={isGrantSelectorDisabledPage}
-              grantedGroups={grantedGroups}
+              userRelatedGrantedGroups={userRelatedGrantedGroups}
               onUpdateGrant={updateGrantHandler}
             />
           </div>
         )
       }
 
-      <UncontrolledButtonDropdown direction="up">
+      <UncontrolledButtonDropdown direction="up" size="sm">
         <Button
           id="caret"
           data-testid="save-page-btn"
@@ -104,7 +104,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
           {labelSubmitButton}
         </Button>
         <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} />
-        <DropdownMenu end>
+        <DropdownMenu container="body" end>
           <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
             {labelOverwriteScopes}
           </DropdownItem>

+ 42 - 20
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -34,7 +34,7 @@ const AVAILABLE_GRANTS = [
 type Props = {
   disabled?: boolean,
   grant: number,
-  grantedGroups?: {
+  userRelatedGrantedGroups?: {
     id: string,
     name: string,
     type: GroupType,
@@ -51,7 +51,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
   const {
     disabled,
-    grantedGroups,
+    userRelatedGrantedGroups,
     onUpdateGrant,
     grant: currentGrant,
   } = props;
@@ -80,18 +80,23 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
 
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, grantedGroups: undefined });
+      onUpdateGrant({ grant, userRelatedGrantedGroups: undefined });
     }
   }, [onUpdateGrant, showSelectGroupModal]);
 
   const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
     if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
-      onUpdateGrant({ grant: 5, grantedGroups: [{ id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type }] });
+      let userRelatedGrantedGroupsCopy = userRelatedGrantedGroups != null ? [...userRelatedGrantedGroups] : [];
+      const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
+      if (userRelatedGrantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
+        userRelatedGrantedGroupsCopy.push(grantGroupInfo);
+      }
+      else {
+        userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
+      }
+      onUpdateGrant({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy });
     }
-
-    // hide modal
-    setIsSelectGroupModalShown(false);
-  }, [onUpdateGrant]);
+  }, [onUpdateGrant, userRelatedGrantedGroups]);
 
   /**
    * Render grant selector DOM.
@@ -102,7 +107,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     let dropdownToggleLabelElm;
 
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
-      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantedGroups != null && grantedGroups.length > 0)
+      const label = ((opt.grant === 5 && opt.reselectLabel != null) && userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0)
         ? opt.reselectLabel // when grantGroup is selected
         : opt.label;
 
@@ -123,11 +128,19 @@ export const GrantSelector = (props: Props): JSX.Element => {
     });
 
     // add specified group option
-    if (grantedGroups != null && grantedGroups.length > 0) {
+    if (userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0) {
       const labelElm = (
         <span>
           <i className="icon icon-fw icon-organization"></i>
-          <span className="label">{grantedGroups[0].name}</span>
+          <span className="label">
+            {userRelatedGrantedGroups.length > 1
+              ? (
+                <span>
+                  {`${userRelatedGrantedGroups[0].name}... `}
+                  <span className="badge badge-purple">+{userRelatedGrantedGroups.length - 1}</span>
+                </span>
+              ) : userRelatedGrantedGroups[0].name}
+          </span>
         </span>
       );
 
@@ -139,17 +152,17 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
     return (
       <div className="grw-grant-selector mb-0" data-testid="grw-grant-selector">
-        <UncontrolledDropdown direction="up">
+        <UncontrolledDropdown direction="up" size="sm">
           <DropdownToggle color={dropdownToggleBtnColor} caret className="d-flex justify-content-between align-items-center" disabled={disabled}>
             {dropdownToggleLabelElm}
           </DropdownToggle>
-          <DropdownMenu>
+          <DropdownMenu container="body">
             {dropdownMenuElems}
           </DropdownMenu>
         </UncontrolledDropdown>
       </div>
     );
-  }, [changeGrantHandler, currentGrant, disabled, grantedGroups, t]);
+  }, [changeGrantHandler, currentGrant, disabled, userRelatedGrantedGroups, t]);
 
   /**
    * Render select grantgroup modal.
@@ -180,20 +193,30 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
 
     return (
-      <div className="list-group">
+      <>
         { myUserGroups.map((group) => {
+          const groupIsGranted = userRelatedGrantedGroups?.find(g => g.id === group.item._id) != null;
+          const activeClass = groupIsGranted ? 'active' : '';
+
           return (
-            <button key={group.item._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
-              <h5 className="d-inline-block">{group.item.name}</h5>
+            <button
+              className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
+              type="button"
+              key={group.item._id}
+              onClick={() => groupListItemClickHandler(group)}
+            >
+              <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
+              <h5 className="d-inline-block ml-3">{group.item.name}</h5>
               {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
           );
         }) }
-      </div>
+        <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsSelectGroupModalShown(false)}>{t('Done')}</button>
+      </>
     );
 
-  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t]);
+  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, userRelatedGrantedGroups]);
 
   return (
     <>
@@ -202,7 +225,6 @@ export const GrantSelector = (props: Props): JSX.Element => {
       {/* render modal */}
       { !disabled && currentUser != null && (
         <Modal
-          className="select-grant-group"
           isOpen={isSelectGroupModalShown}
           toggle={() => setIsSelectGroupModalShown(false)}
         >

+ 1 - 1
apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx

@@ -12,7 +12,7 @@ export const DropendToggle = (): JSX.Element => {
   return (
     <DropdownToggle
       color="primary"
-      className={`position-absolute ${moduleClass}`}
+      className={`position-absolute z-1 ${moduleClass}`}
       aria-expanded={false}
     >
       <Hexagon />

+ 11 - 4
apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx

@@ -5,6 +5,7 @@ import { useRouter } from 'next/router';
 
 import { createPage, exist } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 export const useOnNewButtonClicked = (
     currentPagePath?: string,
@@ -18,6 +19,8 @@ export const useOnNewButtonClicked = (
   const router = useRouter();
   const [isPageCreating, setIsPageCreating] = useState(false);
 
+  const { mutate: mutateEditorMode } = useEditorMode();
+
   const onClickHandler = useCallback(async() => {
     if (isLoading) return;
 
@@ -45,7 +48,8 @@ export const useOnNewButtonClicked = (
       // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
       const response = await createPage(parentPath, '', params);
 
-      router.push(`/${response.page.id}#edit`);
+      await router.push(`/${response.page.id}#edit`);
+      mutateEditorMode(EditorMode.Editor);
     }
     catch (err) {
       toastError(err);
@@ -53,7 +57,7 @@ export const useOnNewButtonClicked = (
     finally {
       setIsPageCreating(false);
     }
-  }, [currentPageGrant, currentPageGrantedGroups, currentPagePath, isLoading, router]);
+  }, [currentPageGrant, currentPageGrantedGroups, currentPagePath, isLoading, mutateEditorMode, router]);
 
   return { onClickHandler, isPageCreating };
 };
@@ -67,6 +71,8 @@ export const useOnTodaysButtonClicked = (
   const router = useRouter();
   const [isPageCreating, setIsPageCreating] = useState(false);
 
+  const { mutate: mutateEditorMode } = useEditorMode();
+
   const onClickHandler = useCallback(async() => {
     if (todaysPath == null) {
       return;
@@ -88,7 +94,8 @@ export const useOnTodaysButtonClicked = (
         await createPage(todaysPath, '', params);
       }
 
-      router.push(`${todaysPath}#edit`);
+      await router.push(`${todaysPath}#edit`);
+      mutateEditorMode(EditorMode.Editor);
     }
     catch (err) {
       toastError(err);
@@ -96,7 +103,7 @@ export const useOnTodaysButtonClicked = (
     finally {
       setIsPageCreating(false);
     }
-  }, [router, todaysPath]);
+  }, [mutateEditorMode, router, todaysPath]);
 
   return { onClickHandler, isPageCreating };
 };

+ 4 - 1
apps/app/src/components/Sidebar/Sidebar.tsx

@@ -6,6 +6,7 @@ import React, {
 import dynamic from 'next/dynamic';
 
 import { SidebarMode } from '~/interfaces/ui';
+import { useIsSearchPage } from '~/stores/context';
 import {
   useDrawerOpened,
   useCollapsedContentsOpened,
@@ -181,6 +182,8 @@ export const Sidebar = (): JSX.Element => {
     isDrawerMode, isCollapsedMode, isDockMode,
   } = useSidebarMode();
 
+  const { data: isSearchPage } = useIsSearchPage();
+
   // css styles
   const grwSidebarClass = styles['grw-sidebar'];
   // eslint-disable-next-line no-nested-ternary
@@ -204,7 +207,7 @@ export const Sidebar = (): JSX.Element => {
           <span className="material-symbols-outlined">reorder</span>
         </DrawerToggler>
       ) }
-      { sidebarMode != null && !isDockMode() && <AppTitleOnSubnavigation /> }
+      { sidebarMode != null && !isDockMode() && !isSearchPage && <AppTitleOnSubnavigation /> }
       <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
         <ResizableContainer>
           { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }

+ 0 - 4
apps/app/src/components/Sidebar/SidebarNav/SidebarNav.module.scss

@@ -3,10 +3,6 @@
 @use '~/styles/variables' as var;
 
 .grw-sidebar-nav :global {
-  // set position and z-index to prevent dropdowns covered by other element
-  position: relative;
-  z-index: bs.$zindex-fixed;
-
   width: var.$grw-sidebar-nav-width;
 
   border-right : 1px solid var(--bs-border-color);

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

@@ -12,7 +12,7 @@ export const Skeleton = (props: SkeletonProps): JSX.Element => {
 
   return (
     <div className={`${additionalClass ?? ''}`}>
-      <div className={`grw-skeleton h-100 w-100 ${roundedPill && 'rounded-pill'}`}></div>
+      <div className={`grw-skeleton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
     </div>
   );
 };

+ 0 - 1
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx

@@ -149,7 +149,6 @@ export const SyncExecution = ({
       </form>
 
       <Modal
-        className="select-grant-group"
         isOpen={isAlertModalOpen}
         toggle={() => setIsAlertModalOpen(false)}
       >

+ 1 - 1
apps/app/src/interfaces/editor-settings.ts

@@ -1,4 +1,4 @@
-export const DEFAULT_THEME = 'elegant';
+export const DEFAULT_THEME = 'DefaultLight';
 
 const KeyMapMode = {
   default: 'default',

+ 1 - 1
apps/app/src/interfaces/page-operation.ts

@@ -33,6 +33,6 @@ export type OptionsToSave = {
   isSlackEnabled: boolean;
   slackChannels: string;
   grant: number;
-  // grantUserGroupIds?: IGrantedGroup[];
+  // userRelatedGrantUserGroupIds?: IGrantedGroup[];
   // isSyncRevisionToHackmd?: boolean;
 };

+ 1 - 0
apps/app/src/interfaces/page-tag-relation.ts

@@ -3,4 +3,5 @@ import type { IPage, ITag } from '@growi/core';
 export type IPageTagRelation = {
   relatedPage: IPage,
   relatedTag: ITag,
+  isPageTrashed: boolean,
 }

+ 1 - 1
apps/app/src/interfaces/page.ts

@@ -10,7 +10,7 @@ export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData
 
 export type IPageGrantData = {
   grant: number,
-  grantedGroups?: {
+  userRelatedGrantedGroups?: {
     id: string,
     name: string,
     type: GroupType,

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

@@ -44,7 +44,6 @@ export const SocketEventName = {
   PageCreated: 'page:create',
   PageUpdated: 'page:update',
   PageDeleted: 'page:delete',
-
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 

+ 1 - 1
apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js

@@ -1,6 +1,6 @@
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:remove-basic-auth-related-config');
+const logger = loggerFactory('growi:granted-group-to-array-of-objects');
 
 module.exports = {
   async up(db, client) {

+ 28 - 0
apps/app/src/migrations/20231223155127-non-null-granted-groups.js

@@ -0,0 +1,28 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:non-null-granted-groups');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+
+    const pageCollection = await db.collection('pages');
+
+    await pageCollection.updateMany(
+      { grantedGroups: { $eq: null } },
+      [
+        {
+          $set: {
+            grantedGroups: [],
+          },
+        },
+      ],
+    );
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    // No rollback
+  },
+};

+ 38 - 12
apps/app/src/pages/[[...path]].page.tsx

@@ -1,10 +1,11 @@
-import React, { ReactNode, useEffect } from 'react';
-
+import type { ReactNode } from 'react';
+import React, { useEffect } from 'react';
 
 import EventEmitter from 'events';
 
-import { isIPageInfoForEntity } from '@growi/core';
+import { isIPageInfoForEntity, isPopulated } from '@growi/core';
 import type {
+  GroupType,
   IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision,
 } from '@growi/core';
 import {
@@ -23,6 +24,7 @@ import superjson from 'superjson';
 import { useEditorModeClassName } from '~/client/services/layout';
 import { PageView } from '~/components/Page/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
+import { SupportedAction, type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { IPageGrantData } from '~/interfaces/page';
@@ -58,7 +60,7 @@ import { DisplaySwitcher } from '../components/Page/DisplaySwitcher';
 import type { NextPageWithLayout } from './_app.page';
 import type { CommonProps } from './utils/commons';
 import {
-  getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, useInitSidebarConfig, skipSSR,
+  getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, useInitSidebarConfig, skipSSR, addActivity,
 } from './utils/commons';
 
 
@@ -224,12 +226,11 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   const { pageWithMeta } = props;
 
   const pageId = pageWithMeta?.data._id;
-  const pagePath = pageWithMeta?.data.path ?? props.currentPathname;
   const revisionBody = pageWithMeta?.data.revision?.body;
 
   useCurrentPathname(props.currentPathname);
 
-  useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
+  const { data: currentPage } = useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
 
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
@@ -315,6 +316,10 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
     mutateTemplateBodyData(props.templateBodyData);
   }, [props.templateBodyData, mutateTemplateBodyData]);
 
+  // If the data on the page changes without router.push, pageWithMeta remains old because getServerSideProps() is not executed
+  // So preferentially take page data from useSWRxCurrentPage
+  const pagePath = currentPage?.path ?? pageWithMeta?.data.path ?? props.currentPathname;
+
   const title = generateCustomTitleForPage(props, pagePath);
 
   return (
@@ -406,7 +411,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
 
   const Page = crowi.model('Page') as PageModel;
   const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
-  const { pageService, configManager } = crowi;
+  const { pageService, configManager, pageGrantService } = crowi;
 
   let currentPathname = props.currentPathname;
 
@@ -460,16 +465,20 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     // apply parent page grant, without groups that user isn't related to
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     if (ancestor != null) {
-      const userRelatedGrantedGroups = await pageService.getUserRelatedGrantedGroups(ancestor, user);
-      props.grantData = {
-        grant: ancestor.grant,
-        grantedGroups: userRelatedGrantedGroups.map((group) => {
+      ancestor.populate('grantedGroups.item');
+      const userRelatedGrantedGroups = (await pageGrantService.getUserRelatedGrantedGroups(ancestor, user)).map((group) => {
+        if (isPopulated(group.item)) {
           return {
             id: group.item._id,
             name: group.item.name,
             type: group.type,
           };
-        }),
+        }
+        return null;
+      }).filter((info): info is NonNullable<{id: string, name: string, type: GroupType}> => info != null);
+      props.grantData = {
+        grant: ancestor.grant,
+        userRelatedGrantedGroups,
       };
     }
   }
@@ -596,6 +605,22 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
   props._nextI18Next = nextI18NextConfig._nextI18Next;
 }
 
+const getAction = (props: Props): SupportedActionType => {
+  if (props.isNotCreatable) {
+    return SupportedAction.ACTION_PAGE_NOT_CREATABLE;
+  }
+  if (props.isForbidden) {
+    return SupportedAction.ACTION_PAGE_FORBIDDEN;
+  }
+  if (props.isNotFound) {
+    return SupportedAction.ACTION_PAGE_NOT_FOUND;
+  }
+  if (pagePathUtils.isUsersHomepage(props.pageWithMeta?.data.path ?? '')) {
+    return SupportedAction.ACTION_PAGE_USER_HOME_VIEW;
+  }
+  return SupportedAction.ACTION_PAGE_VIEW;
+};
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest;
   const { user } = req;
@@ -639,6 +664,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   injectServerConfigurations(context, props);
   await injectNextI18NextConfigurations(context, props, ['translation']);
 
+  addActivity(context, getAction(props));
   return {
     props,
   };

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

@@ -8,7 +8,9 @@ import Head from 'next/head';
 import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
-import { useCurrentUser, useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
+import {
+  useCurrentUser, useAuditLogEnabled, useAuditLogAvailableActions, useActivityExpirationSeconds,
+} from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -19,6 +21,7 @@ const ForbiddenPage = dynamic(() => import('~/components/Admin/ForbiddenPage').t
 
 type Props = CommonProps & {
   auditLogEnabled: boolean,
+  activityExpirationSeconds: number,
   auditLogAvailableActions: SupportedActionType[],
 };
 
@@ -26,6 +29,7 @@ type Props = CommonProps & {
 const AdminAuditLogPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('admin');
   useAuditLogEnabled(props.auditLogEnabled);
+  useActivityExpirationSeconds(props.activityExpirationSeconds);
   useAuditLogAvailableActions(props.auditLogAvailableActions);
   useCurrentUser(props.currentUser ?? null);
 
@@ -53,6 +57,7 @@ const injectServerConfigurations = async(context: GetServerSidePropsContext, pro
   const { activityService } = crowi;
 
   props.auditLogEnabled = crowi.configManager.getConfig('crowi', 'app:auditLogEnabled');
+  props.activityExpirationSeconds = crowi.configManager.getConfig('crowi', 'app:activityExpirationSeconds');
   props.auditLogAvailableActions = activityService.getAvailableActions(false);
 };
 

+ 4 - 19
apps/app/src/pages/share/[[...path]].page.tsx

@@ -12,7 +12,8 @@ import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { ShareLinkPageView } from '~/components/ShareLinkPageView';
-import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
+import type { SupportedActionType } from '~/interfaces/activity';
+import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
@@ -26,8 +27,9 @@ import { useCurrentPageId, useIsNotFound, useSWRMUTxCurrentPage } from '~/stores
 import loggerFactory from '~/utils/logger';
 
 import type { NextPageWithLayout } from '../_app.page';
+import type { CommonProps } from '../utils/commons';
 import {
-  getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig, CommonProps, skipSSR,
+  getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig, skipSSR, addActivity,
 } from '../utils/commons';
 
 const logger = loggerFactory('growi:next-page:share');
@@ -201,23 +203,6 @@ function getAction(props: Props): SupportedActionType {
 
   return action;
 }
-
-async function addActivity(context: GetServerSidePropsContext, action: SupportedActionType): Promise<void> {
-  const req: CrowiRequest = context.req as CrowiRequest;
-
-  const parameters = {
-    ip: req.ip,
-    endpoint: req.originalUrl,
-    action,
-    user: req.user?._id,
-    snapshot: {
-      username: req.user?.username,
-    },
-  };
-
-  await req.crowi.activityService.createActivity(parameters);
-}
-
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest;
   const { crowi, params } = req;

+ 18 - 0
apps/app/src/pages/utils/commons.ts

@@ -5,9 +5,11 @@ import { isServer } from '@growi/core/dist/utils';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { SSRConfig, UserConfig } from 'next-i18next';
 
+
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
 import { detectLocaleFromBrowserAcceptLanguage } from '~/client/util/locale-utils';
+import { type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
@@ -183,3 +185,19 @@ export const skipSSR = async(page: PageDocument, ssrMaxRevisionBodyLength: numbe
 
   return ssrMaxRevisionBodyLength < latestRevisionBodyLength;
 };
+
+export const addActivity = async(context: GetServerSidePropsContext, action: SupportedActionType): Promise<void> => {
+  const req = context.req as CrowiRequest;
+
+  const parameters = {
+    ip: req.ip,
+    endpoint: req.originalUrl,
+    action,
+    user: req.user?._id,
+    snapshot: {
+      username: req.user?.username,
+    },
+  };
+
+  await req.crowi.activityService.createActivity(parameters);
+};

+ 13 - 5
apps/app/src/server/crowi/index.js

@@ -29,6 +29,7 @@ import { instanciate as instanciateExternalAccountService } from '../service/ext
 import { FileUploader, getUploader } from '../service/file-uploader'; // eslint-disable-line no-unused-vars
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
+import { normalizeData } from '../service/normalize-data';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
@@ -49,6 +50,12 @@ class Crowi {
   /** @type {AppService} */
   appService;
 
+  /** @type {import('../service/page').IPageService} */
+  pageService;
+
+  /** @type UserNotificationService */
+  userNotificationService;
+
   /** @type {FileUploader} */
   fileUploadService;
 
@@ -73,7 +80,6 @@ class Crowi {
     this.mailService = null;
     this.passportService = null;
     this.globalNotificationService = null;
-    this.userNotificationService = null;
     this.xssService = null;
     this.aclService = null;
     this.appService = null;
@@ -85,7 +91,6 @@ class Crowi {
     this.pluginService = null;
     this.searchService = null;
     this.socketIoService = null;
-    this.pageService = null;
     this.syncPageStatusService = null;
     this.cdnResourcesService = new CdnResourcesService();
     this.slackIntegrationService = null;
@@ -172,6 +177,8 @@ Crowi.prototype.init = async function() {
   ]);
 
   await this.autoInstall();
+
+  await normalizeData();
 };
 
 /**
@@ -712,12 +719,13 @@ Crowi.prototype.setupGrowiPluginService = async function() {
 };
 
 Crowi.prototype.setupPageService = async function() {
-  if (this.pageService == null) {
-    this.pageService = new PageService(this);
-  }
   if (this.pageGrantService == null) {
     this.pageGrantService = new PageGrantService(this);
   }
+  // initialize after pageGrantService since pageService uses pageGrantService in constructor
+  if (this.pageService == null) {
+    this.pageService = new PageService(this);
+  }
   if (this.pageOperationService == null) {
     this.pageOperationService = new PageOperationService(this);
     await this.pageOperationService.init();

+ 1 - 1
apps/app/src/server/events/activity.ts

@@ -1,6 +1,6 @@
 import loggerFactory from '~/utils/logger';
 
-import Crowi from '../crowi';
+import type Crowi from '../crowi';
 
 const logger = loggerFactory('growi:events:activity');
 

+ 6 - 3
apps/app/src/server/models/GlobalNotificationSetting.js → apps/app/src/server/models/GlobalNotificationSetting.ts

@@ -2,6 +2,7 @@
 /* eslint-disable no-return-await */
 
 const mongoose = require('mongoose');
+
 const GlobalNotificationSetting = require('./GlobalNotificationSetting/index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +11,7 @@ const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
 /**
  * global notifcation event master
  */
-GlobalNotificationSettingSchema.statics.EVENT = {
+export const GlobalNotificationSettingEvent = {
   PAGE_CREATE: 'pageCreate',
   PAGE_EDIT: 'pageEdit',
   PAGE_DELETE: 'pageDelete',
@@ -22,13 +23,15 @@ GlobalNotificationSettingSchema.statics.EVENT = {
 /**
  * global notifcation type master
  */
-GlobalNotificationSettingSchema.statics.TYPE = {
+export const GlobalNotificationSettingType = {
   MAIL: 'mail',
   SLACK: 'slack',
 };
 
-module.exports = function(crowi) {
+const factory = function(crowi) {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
   return mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
 };
+
+export default factory;

+ 5 - 2
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -1,4 +1,7 @@
-const mongoose = require('mongoose');
+import mongoose from 'mongoose';
+
+import { GlobalNotificationSettingType } from '../GlobalNotificationSetting';
+
 const GlobalNotificationSetting = require('./index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +13,7 @@ module.exports = function(crowi) {
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
   const GlobalNotificationMailSettingModel = GlobalNotificationSettingModel.discriminator(
-    GlobalNotificationSetting.schema.statics.TYPE.MAIL,
+    GlobalNotificationSettingType.MAIL,
     new mongoose.Schema({
       toEmail: String,
     }, {

+ 5 - 2
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -1,4 +1,7 @@
-const mongoose = require('mongoose');
+import mongoose from 'mongoose';
+
+import { GlobalNotificationSettingType } from '../GlobalNotificationSetting';
+
 const GlobalNotificationSetting = require('./index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +13,7 @@ module.exports = function(crowi) {
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
   const GlobalNotificationSlackSettingModel = GlobalNotificationSettingModel.discriminator(
-    GlobalNotificationSetting.schema.statics.TYPE.SLACK,
+    GlobalNotificationSettingType.SLACK,
     new mongoose.Schema({
       slackChannels: String,
     }, {

+ 19 - 0
apps/app/src/server/models/GlobalNotificationSetting/consts.ts

@@ -0,0 +1,19 @@
+/**
+ * global notifcation event master
+ */
+export const GlobalNotificationSettingEvent = {
+  PAGE_CREATE: 'pageCreate',
+  PAGE_EDIT: 'pageEdit',
+  PAGE_DELETE: 'pageDelete',
+  PAGE_MOVE: 'pageMove',
+  PAGE_LIKE: 'pageLike',
+  COMMENT: 'comment',
+};
+
+/**
+ * global notifcation type master
+ */
+export const GlobalNotificationSettingEventType = {
+  MAIL: 'mail',
+  SLACK: 'slack',
+};

+ 1 - 0
apps/app/src/server/models/config.ts

@@ -70,6 +70,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'security:pageCompleteDeletionAuthority' : undefined,
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
+  'security:isAllGroupMembershipRequiredForPageCompleteDeletion' : true,
   'security:disableLinkSharing' : false,
   'security:user-homepage-deletion:isEnabled': false,
   'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': false,

+ 5 - 1
apps/app/src/server/models/index.js → apps/app/src/server/models/index.ts

@@ -1,3 +1,4 @@
+import GlobalNotificationSettingFactory from './GlobalNotificationSetting';
 import Page from './page';
 
 export const modelsDependsOnCrowi = {
@@ -6,7 +7,7 @@ export const modelsDependsOnCrowi = {
   User: require('./user'),
   Revision: require('./revision'),
   Bookmark: require('./bookmark'),
-  GlobalNotificationSetting: require('./GlobalNotificationSetting'),
+  GlobalNotificationSetting: GlobalNotificationSettingFactory,
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   SlackAppIntegration: require('./slack-app-integration'),
@@ -19,5 +20,8 @@ export * as PageRedirect from './page-redirect';
 export * as ShareLink from './share-link';
 export * as Tag from './tag';
 export * as UserGroup from './user-group';
+export * as PageTagRelation from './page-tag-relation';
 
 export * from './serializers';
+
+export * from './GlobalNotificationSetting';

+ 1 - 1
apps/app/src/server/models/interfaces/page-operation.ts

@@ -23,7 +23,7 @@ export type IUserForResuming = {
 
 export type IOptionsForUpdate = {
   grant?: PageGrant,
-  grantUserGroupIds?: IGrantedGroup[],
+  userRelatedGrantUserGroupIds?: IGrantedGroup[],
   isSyncRevisionToHackmd?: boolean,
   overwriteScopesOfDescendants?: boolean,
 };

+ 1 - 25
apps/app/src/server/models/obsolete-page.js

@@ -639,30 +639,6 @@ export const getPageSchema = (crowi) => {
     return { templateBody, templateTags };
   };
 
-  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user, isV4 = false) {
-    const builder = new this.PageQueryBuilder(this.find());
-    builder.addConditionToListOnlyDescendants(parentPage.path);
-
-    if (isV4) {
-      builder.addConditionAsRootOrNotOnTree();
-    }
-    else {
-      builder.addConditionAsOnTree();
-    }
-
-    // add grant conditions
-    await addConditionToFilteringByViewerToEdit(builder, user);
-
-    const grant = parentPage.grant;
-
-    await builder.query.updateMany({}, {
-      grant,
-      grantedGroups: grant === PageGrant.GRANT_USER_GROUP ? parentPage.grantedGroups : null,
-      grantedUsers: grant === PageGrant.GRANT_OWNER ? [user._id] : null,
-    });
-
-  };
-
   pageSchema.statics.findListByPathsArray = async function(paths, includeEmpty = false) {
     const queryBuilder = new this.PageQueryBuilder(this.find(), includeEmpty);
     queryBuilder.addConditionToListByPathsArray(paths);
@@ -676,7 +652,7 @@ export const getPageSchema = (crowi) => {
         updateOne: {
           filter: { _id: page._id },
           update: {
-            grantedGroups: null,
+            grantedGroups: [],
             grant: this.GRANT_PUBLIC,
           },
         },

+ 0 - 180
apps/app/src/server/models/page-tag-relation.js

@@ -1,180 +0,0 @@
-import Tag from './tag';
-
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-const flatMap = require('array.prototype.flatmap');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  relatedPage: {
-    type: ObjectId,
-    ref: 'Page',
-    required: true,
-    index: true,
-  },
-  relatedTag: {
-    type: ObjectId,
-    ref: 'Tag',
-    required: true,
-    index: true,
-  },
-  isPageTrashed: {
-    type: Boolean,
-    default: false,
-    required: true,
-    index: true,
-  },
-});
-// define unique compound index
-schema.index({ relatedPage: 1, relatedTag: 1 }, { unique: true });
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-/**
- * PageTagRelation Class
- *
- * @class PageTagRelation
- */
-class PageTagRelation {
-
-  static async createTagListWithCount(option) {
-    const opt = option || {};
-    const sortOpt = opt.sortOpt || {};
-    const offset = opt.offset;
-    const limit = opt.limit;
-
-    const tags = await this.aggregate()
-      .match({ isPageTrashed: false })
-      .lookup({
-        from: 'tags',
-        localField: 'relatedTag',
-        foreignField: '_id',
-        as: 'tag',
-      })
-      .unwind('$tag')
-      .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
-      .sort(sortOpt)
-      .skip(offset)
-      .limit(limit);
-
-    const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
-
-    return { data: tags, totalCount };
-  }
-
-  static async findByPageId(pageId, options = {}) {
-    const isAcceptRelatedTagNull = options.nullable || null;
-    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
-    return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
-  }
-
-  static async listTagNamesByPage(pageId) {
-    const relations = await this.findByPageId(pageId);
-    return relations.map((relation) => { return relation.relatedTag.name });
-  }
-
-  /**
-   * @return {object} key: Page._id, value: array of tag names
-   */
-  static async getIdToTagNamesMap(pageIds) {
-    /**
-     * @see https://docs.mongodb.com/manual/reference/operator/aggregation/group/#pivot-data
-     *
-     * results will be:
-     * [
-     *   { _id: 58dca7b2c435b3480098dbbc, tagIds: [ 5da630f71a677515601e36d7, 5da77163ec786e4fe43e0e3e ]},
-     *   { _id: 58dca7b2c435b3480098dbbd, tagIds: [ ... ]},
-     *   ...
-     * ]
-     */
-    const results = await this.aggregate()
-      .match({ relatedPage: { $in: pageIds } })
-      .group({ _id: '$relatedPage', tagIds: { $push: '$relatedTag' } });
-
-    if (results.length === 0) {
-      return {};
-    }
-
-    results.flatMap = flatMap.shim(); // TODO: remove after upgrading to node v12
-
-    // extract distinct tag ids
-    const allTagIds = results
-      .flatMap(result => result.tagIds); // map + flatten
-    const distinctTagIds = Array.from(new Set(allTagIds));
-
-    // TODO: set IdToNameMap type by 93933
-    const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
-
-    // convert to map
-    const idToTagNamesMap = {};
-    results.forEach((result) => {
-      const tagNames = result.tagIds
-        .map(tagId => tagIdToNameMap[tagId])
-        .filter(tagName => tagName != null); // filter null object
-
-      idToTagNamesMap[result._id] = tagNames;
-    });
-
-    return idToTagNamesMap;
-  }
-
-  static async updatePageTags(pageId, tags) {
-    if (pageId == null || tags == null) {
-      throw new Error('args \'pageId\' and \'tags\' are required.');
-    }
-
-    // filter empty string
-    // eslint-disable-next-line no-param-reassign
-    tags = tags.filter((tag) => { return tag !== '' });
-
-    // get relations for this page
-    const relations = await this.findByPageId(pageId, { nullable: true });
-
-    const unlinkTagRelationIds = [];
-    const relatedTagNames = [];
-
-    relations.forEach((relation) => {
-      if (relation.relatedTag == null) {
-        unlinkTagRelationIds.push(relation._id);
-      }
-      else {
-        relatedTagNames.push(relation.relatedTag.name);
-        if (!tags.includes(relation.relatedTag.name)) {
-          unlinkTagRelationIds.push(relation._id);
-        }
-      }
-    });
-    const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
-    // find or create tags
-    const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
-    const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
-
-    // create relations
-    const bulkCreatePromise = this.insertMany(
-      tagEntities.map((relatedTag) => {
-        return {
-          relatedPage: pageId,
-          relatedTag,
-        };
-      }),
-    );
-
-    return Promise.all([bulkDeletePromise, bulkCreatePromise]);
-  }
-
-}
-
-module.exports = function() {
-  schema.loadClass(PageTagRelation);
-  const model = mongoose.model('PageTagRelation', schema);
-  return model;
-};

+ 208 - 0
apps/app/src/server/models/page-tag-relation.ts

@@ -0,0 +1,208 @@
+import type { ITag } from '@growi/core';
+import type { Document, Model } from 'mongoose';
+import mongoose, { ObjectId } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+
+import type { IPageTagRelation } from '~/interfaces/page-tag-relation';
+
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+import type { IdToNameMap } from './tag';
+import Tag from './tag';
+
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+
+// disable no-return-await for model functions
+/* eslint-disable no-return-await */
+
+const flatMap = require('array.prototype.flatmap');
+
+
+export interface PageTagRelationDocument extends IPageTagRelation, Document {
+}
+
+type CreateTagListWithCountOpts = {
+  sortOpt?: any,
+  offset?: number,
+  limit?: number,
+}
+type CreateTagListWithCountResult = {
+  data: ITag[],
+  totalCount: number
+}
+type CreateTagListWithCount = (this: PageTagRelationModel, opts?: CreateTagListWithCountOpts) => Promise<CreateTagListWithCountResult>;
+
+type GetIdToTagNamesMap = (this: PageTagRelationModel, pageIds: string[]) => Promise<IdToNameMap>;
+
+type UpdatePageTags = (this: PageTagRelationModel, pageId: string, tags: string[]) => Promise<void>
+
+export interface PageTagRelationModel extends Model<PageTagRelationDocument> {
+  createTagListWithCount: CreateTagListWithCount
+  findByPageId(pageId: string, options?: { nullable?: boolean }): Promise<PageTagRelationDocument[]>
+  listTagNamesByPage(pageId: string): Promise<PageTagRelationDocument[]>
+  getIdToTagNamesMap: GetIdToTagNamesMap
+  updatePageTags: UpdatePageTags
+}
+
+
+/*
+ * define schema
+ */
+const schema = new mongoose.Schema<PageTagRelationDocument, PageTagRelationModel>({
+  relatedPage: {
+    type: ObjectId,
+    ref: 'Page',
+    required: true,
+    index: true,
+  },
+  relatedTag: {
+    type: ObjectId,
+    ref: 'Tag',
+    required: true,
+    index: true,
+  },
+  isPageTrashed: {
+    type: Boolean,
+    default: false,
+    required: true,
+    index: true,
+  },
+});
+// define unique compound index
+schema.index({ relatedPage: 1, relatedTag: 1 }, { unique: true });
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+const createTagListWithCount: CreateTagListWithCount = async function(this, opts) {
+  const sortOpt = opts?.sortOpt || {};
+  const offset = opts?.offset ?? 0;
+  const limit = opts?.limit;
+
+  let query = this.aggregate()
+    .match({ isPageTrashed: false })
+    .lookup({
+      from: 'tags',
+      localField: 'relatedTag',
+      foreignField: '_id',
+      as: 'tag',
+    })
+    .unwind('$tag')
+    .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
+    .sort(sortOpt)
+    .skip(offset);
+
+  if (limit != null) {
+    query = query.limit(limit);
+  }
+
+  const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
+
+  return { data: await query.exec(), totalCount };
+};
+schema.statics.createTagListWithCount = createTagListWithCount;
+
+schema.statics.findByPageId = async function(pageId, options = {}) {
+  const isAcceptRelatedTagNull = options.nullable || null;
+  const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
+  return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
+};
+
+schema.statics.listTagNamesByPage = async function(pageId) {
+  const relations = await this.findByPageId(pageId);
+  return relations.map((relation) => { return relation.relatedTag.name });
+};
+
+
+const getIdToTagNamesMap: GetIdToTagNamesMap = async function(this, pageIds) {
+  /**
+   * @see https://docs.mongodb.com/manual/reference/operator/aggregation/group/#pivot-data
+   *
+   * results will be:
+   * [
+   *   { _id: 58dca7b2c435b3480098dbbc, tagIds: [ 5da630f71a677515601e36d7, 5da77163ec786e4fe43e0e3e ]},
+   *   { _id: 58dca7b2c435b3480098dbbd, tagIds: [ ... ]},
+   *   ...
+   * ]
+   */
+  const results = await this.aggregate<{ _id: ObjectId, tagIds: ObjectIdLike[] }>()
+    .match({ relatedPage: { $in: pageIds } })
+    .group({ _id: '$relatedPage', tagIds: { $push: '$relatedTag' } });
+
+  if (results.length === 0) {
+    return {};
+  }
+
+  results.flatMap = flatMap.shim(); // TODO: remove after upgrading to node v12
+
+  // extract distinct tag ids
+  const allTagIds = results
+    .flatMap(result => result.tagIds); // map + flatten
+  const distinctTagIds = Array.from(new Set(allTagIds));
+
+  // TODO: set IdToNameMap type by 93933
+  const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
+
+  // convert to map
+  const idToTagNamesMap = {};
+  results.forEach((result) => {
+    const tagNames = result.tagIds
+      .map(tagId => tagIdToNameMap[tagId.toString()])
+      .filter(tagName => tagName != null); // filter null object
+
+    idToTagNamesMap[result._id.toString()] = tagNames;
+  });
+
+  return idToTagNamesMap;
+};
+schema.statics.getIdToTagNamesMap = getIdToTagNamesMap;
+
+const updatePageTags: UpdatePageTags = async function(pageId, tags) {
+  if (pageId == null || tags == null) {
+    throw new Error('args \'pageId\' and \'tags\' are required.');
+  }
+
+  // filter empty string
+  // eslint-disable-next-line no-param-reassign
+  tags = tags.filter((tag) => { return tag !== '' });
+
+  // get relations for this page
+  const relations = await this.findByPageId(pageId, { nullable: true });
+
+  const unlinkTagRelationIds: string[] = [];
+  const relatedTagNames: string[] = [];
+
+  relations.forEach((relation) => {
+    if (relation.relatedTag == null) {
+      unlinkTagRelationIds.push(relation._id);
+    }
+    else {
+      relatedTagNames.push(relation.relatedTag.name);
+      if (!tags.includes(relation.relatedTag.name)) {
+        unlinkTagRelationIds.push(relation._id);
+      }
+    }
+  });
+  const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
+  // find or create tags
+  const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
+  const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
+
+  // create relations
+  const bulkCreatePromise = this.insertMany(
+    tagEntities.map((relatedTag) => {
+      return {
+        relatedPage: pageId,
+        relatedTag,
+      };
+    }),
+  );
+
+  await Promise.all([bulkDeletePromise, bulkCreatePromise]);
+};
+schema.statics.updatePageTags = updatePageTags;
+
+export default getOrCreateModel<PageTagRelationDocument, PageTagRelationModel>('PageTagRelation', schema);

+ 8 - 4
apps/app/src/server/models/page.ts

@@ -8,26 +8,25 @@ import {
   type IGrantedGroup,
   GroupType, type HasObjectId,
 } from '@growi/core';
+import type { ITag } from '@growi/core/dist/interfaces';
 import { isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
+import type { Model, Document, AnyObject } from 'mongoose';
 import mongoose, {
-  Schema, Model, Document, AnyObject,
+  Schema,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
-import { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
-import { UserGroupDocument } from './user-group';
 import UserGroupRelation from './user-group-relation';
 
 const logger = loggerFactory('growi:models:page');
@@ -76,6 +75,10 @@ export interface PageModel extends Model<PageDocument> {
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
+  findTemplate(path: string): Promise<{
+    templateBody?: string,
+    templateTags?: string[],
+  }>
 
   PageQueryBuilder: typeof PageQueryBuilder
 
@@ -127,6 +130,7 @@ const schema = new Schema<PageDocument, PageModel>({
       return arr.length === uniqueItemValues.size;
     }, 'grantedGroups contains non unique item'],
     default: [],
+    required: true,
   },
   creator: { type: ObjectId, ref: 'User', index: true },
   lastUpdateUser: { type: ObjectId, ref: 'User' },

+ 3 - 4
apps/app/src/server/models/tag.ts

@@ -1,8 +1,7 @@
-import {
-  Types, Model, Schema,
-} from 'mongoose';
+import type { Types, Model } from 'mongoose';
+import { Schema } from 'mongoose';
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 const mongoosePaginate = require('mongoose-paginate-v2');

+ 1 - 1
apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts

@@ -1,4 +1,4 @@
-import { Response } from 'express';
+import type { Response } from 'express';
 
 export interface ApiV3Response extends Response {
   apiv3(obj?: any, status?: number): any

+ 7 - 6
apps/app/src/server/routes/apiv3/notification-setting.js

@@ -1,6 +1,7 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { GlobalNotificationSettingType } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
@@ -283,11 +284,11 @@ module.exports = (crowi) => {
 
     let notification;
 
-    if (notifyType === GlobalNotificationSetting.TYPE.MAIL) {
+    if (notifyType === GlobalNotificationSettingType.MAIL) {
       notification = new GlobalNotificationMailSetting(crowi);
       notification.toEmail = toEmail;
     }
-    if (notifyType === GlobalNotificationSetting.TYPE.SLACK) {
+    if (notifyType === GlobalNotificationSettingType.SLACK) {
       notification = new GlobalNotificationSlackSetting(crowi);
       notification.slackChannels = slackChannels;
     }
@@ -350,8 +351,8 @@ module.exports = (crowi) => {
     } = req.body;
 
     const models = {
-      [GlobalNotificationSetting.TYPE.MAIL]: GlobalNotificationMailSetting,
-      [GlobalNotificationSetting.TYPE.SLACK]: GlobalNotificationSlackSetting,
+      [GlobalNotificationSettingType.MAIL]: GlobalNotificationMailSetting,
+      [GlobalNotificationSettingType.SLACK]: GlobalNotificationSlackSetting,
     };
 
     try {
@@ -368,11 +369,11 @@ module.exports = (crowi) => {
         setting = setting.toObject();
       }
 
-      if (notifyType === GlobalNotificationSetting.TYPE.MAIL) {
+      if (notifyType === GlobalNotificationSettingType.MAIL) {
         setting = GlobalNotificationMailSetting.hydrate(setting);
         setting.toEmail = toEmail;
       }
-      if (notifyType === GlobalNotificationSetting.TYPE.SLACK) {
+      if (notifyType === GlobalNotificationSettingType.SLACK) {
         setting = GlobalNotificationSlackSetting.hydrate(setting);
         setting.slackChannels = slackChannels;
       }

+ 13 - 6
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,5 +1,5 @@
 import type {
-  IPageInfoForListing, IPageInfo, IUserHasId,
+  IPageInfoForListing, IPageInfo,
 } from '@growi/core';
 import { isIPageInfoForEntity } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
@@ -8,12 +8,12 @@ import { query, oneOf } from 'express-validator';
 import mongoose from 'mongoose';
 
 
+import { IPageGrantService } from '~/server/service/page-grant';
 import loggerFactory from '~/utils/logger';
 
 import Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { PageModel } from '../../models/page';
-import PageService from '../../service/page';
 
 import { ApiV3Response } from './interfaces/apiv3-response';
 
@@ -75,7 +75,7 @@ const routerFactory = (crowi: Crowi): Router => {
   router.get('/ancestors-children', accessTokenParser, loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;
 
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
     try {
       const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
       return res.apiv3({ ancestorsChildren });
@@ -94,7 +94,7 @@ const routerFactory = (crowi: Crowi): Router => {
   router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { id, path } = req.query;
 
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
 
     try {
       const pages = await pageService.findChildrenByParentPathOrIdAndViewer((id || path)as string, req.user);
@@ -118,7 +118,9 @@ const routerFactory = (crowi: Crowi): Router => {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Bookmark = crowi.model('Bookmark');
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const pageGrantService: IPageGrantService = crowi.pageGrantService!;
 
     try {
       const pages = pageIds != null
@@ -140,16 +142,21 @@ const routerFactory = (crowi: Crowi): Router => {
       const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> = {};
 
       const isGuestUser = req.user == null;
+
+      const userRelatedGroups = await pageGrantService.getUserRelatedGroups(req.user);
+
       for (const page of pages) {
         // construct isIPageInfoForListing
         const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 
+        const canDeleteCompletely = pageService.canDeleteCompletely(page, req.user, false, userRelatedGroups); // use normal delete config
+
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
           // create IPageInfoForListing
           : {
             ...basicPageInfo,
-            isAbleToDeleteCompletely: pageService.canDeleteCompletely(page.path, (page.creator as IUserHasId)?._id, req.user, false), // use normal delete config
+            isAbleToDeleteCompletely: canDeleteCompletely,
             bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
             revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
           } as IPageInfoForListing;

+ 234 - 0
apps/app/src/server/routes/apiv3/page/cteate-page.ts

@@ -0,0 +1,234 @@
+import type {
+  IGrantedGroup,
+  IPage, IUser, IUserHasId, PageGrant,
+} from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
+import { addHeadingSlash, attachTitleHeader } from '@growi/core/dist/utils/path-utils';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+import mongoose from 'mongoose';
+
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import {
+  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely,
+} from '~/server/models';
+import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
+import type { PageDocument, PageModel } from '~/server/models/page';
+import PageTagRelation from '~/server/models/page-tag-relation';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:create-page');
+
+
+async function generateUniquePath(basePath: string, index = 1): Promise<string> {
+  const Page = mongoose.model<IPage>('Page');
+
+  const path = basePath + index;
+  const existingPageId = await Page.exists({ path, isEmpty: false });
+  if (existingPageId != null) {
+    return generateUniquePath(basePath, index + 1);
+  }
+  return path;
+}
+
+type ReqBody = {
+  path: string,
+
+  grant?: PageGrant,
+  grantUserGroupIds?: IGrantedGroup[],
+
+  body?: string,
+  overwriteScopesOfDescendants?: boolean,
+  isSlackEnabled?: boolean,
+  slackChannels?: any,
+  pageTags?: string[],
+  shouldGeneratePath?: boolean,
+}
+
+interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>('User');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  const activityEvent = crowi.event('activity');
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const globalNotificationService = crowi.getGlobalNotificationService();
+  const userNotificationService = crowi.getUserNotificationService();
+
+
+  async function saveTagsAction({ createdPage, pageTags }: { createdPage: PageDocument, pageTags: string[] }) {
+    if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
+      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      tagEvent.emit('update', createdPage, pageTags);
+      return PageTagRelation.listTagNamesByPage(createdPage.id);
+    }
+
+    return [];
+  }
+
+  const validator: ValidationChain[] = [
+    body('body').optional().isString()
+      .withMessage('body must be string or undefined'),
+    body('path').exists().not().isEmpty({ ignore_whitespace: true })
+      .withMessage('path is required'),
+    body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
+    body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
+    body('shouldGeneratePath').optional().isBoolean().withMessage('shouldGeneratePath is must be boolean or undefined'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    validator, apiV3FormValidator,
+    async(req: CreatePageRequest, res: ApiV3Response) => {
+      const {
+        body, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
+      } = req.body;
+
+      let { path, grant, grantUserGroupIds } = req.body;
+
+      // check whether path starts slash
+      path = addHeadingSlash(path);
+
+      if (shouldGeneratePath) {
+        try {
+          const rootPath = '/';
+          const defaultTitle = '/Untitled';
+          const basePath = path === rootPath ? defaultTitle : path + defaultTitle;
+          path = await generateUniquePath(basePath);
+
+          // if the generated path is not creatable, create the path under the root path
+          if (!isCreatablePage(path)) {
+            path = await generateUniquePath(defaultTitle);
+            // initialize grant data
+            grant = 1;
+            grantUserGroupIds = undefined;
+          }
+        }
+        catch (err) {
+          return res.apiv3Err(new ErrorV3('Failed to generate unique path'));
+        }
+      }
+
+      if (!isCreatablePage(path)) {
+        return res.apiv3Err(`Could not use the path '${path}'`);
+      }
+
+      if (isUserPage(path)) {
+        const isExistUser = await User.isExistUserByUserPagePath(path);
+        if (!isExistUser) {
+          return res.apiv3Err("Unable to create a page under a non-existent user's user page");
+        }
+      }
+
+      const options: IOptionsForCreate = { overwriteScopesOfDescendants };
+      if (grant != null) {
+        options.grant = grant;
+        options.grantUserGroupIds = grantUserGroupIds;
+      }
+
+      const isNoBodyPage = body === undefined;
+      let initialTags: string[] = [];
+      let initialBody = '';
+      if (isNoBodyPage) {
+        const isEnabledAttachTitleHeader = await configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
+        if (isEnabledAttachTitleHeader) {
+          initialBody += `${attachTitleHeader(path)}\n`;
+        }
+
+        const templateData = await Page.findTemplate(path);
+        if (templateData.templateTags != null) {
+          initialTags = templateData.templateTags;
+        }
+        if (templateData.templateBody != null) {
+          initialBody += `${templateData.templateBody}\n`;
+        }
+      }
+
+      let createdPage;
+      try {
+        createdPage = await crowi.pageService.create(
+          path,
+          body ?? initialBody,
+          req.user,
+          options,
+        );
+      }
+      catch (err) {
+        logger.error('Error occurred while creating a page.', err);
+        return res.apiv3Err(err);
+      }
+
+      const savedTags = await saveTagsAction({ createdPage, pageTags: isNoBodyPage ? initialTags : (pageTags ?? ['']) });
+
+      const result = {
+        page: serializePageSecurely(createdPage),
+        tags: savedTags,
+        revision: serializeRevisionSecurely(createdPage.revision),
+      };
+
+      const parameters = {
+        targetModel: SupportedTargetModel.MODEL_PAGE,
+        target: createdPage,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
+      res.apiv3(result, 201);
+
+      try {
+      // global notification
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, createdPage, req.user);
+      }
+      catch (err) {
+        logger.error('Create grobal notification failed', err);
+      }
+
+      // user notification
+      if (isSlackEnabled) {
+        try {
+          const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
+          results.forEach((result) => {
+            if (result.status === 'rejected') {
+              logger.error('Create user notification failed', result.reason);
+            }
+          });
+        }
+        catch (err) {
+          logger.error('Create user notification failed', err);
+        }
+      }
+
+      // create subscription
+      try {
+        await crowi.inAppNotificationService.createSubscription(req.user._id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
+      }
+      catch (err) {
+        logger.error('Failed to create subscription document', err);
+      }
+    },
+  ];
+};

+ 17 - 15
apps/app/src/server/routes/apiv3/page.js → apps/app/src/server/routes/apiv3/page/index.js

@@ -12,8 +12,10 @@ import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import { GlobalNotificationSettingEvent } from '~/server/models';
 import Subscription from '~/server/models/subscription';
 import UserGroup from '~/server/models/user-group';
+import { configManager } from '~/server/service/config-manager';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
@@ -166,16 +168,14 @@ const router = express.Router();
  *            example: 5e07345972560e001761fa63
  */
 module.exports = (crowi) => {
-  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const certifySharedPage = require('../../../middlewares/certify-shared-page')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
-  const configManager = crowi.configManager;
-
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const { Page, GlobalNotificationSetting } = crowi.models;
+  const { Page } = crowi.models;
   const { pageService, exportService } = crowi;
 
   const activityEvent = crowi.event('activity');
@@ -372,7 +372,7 @@ module.exports = (crowi) => {
     if (isLiked) {
       try {
         // global notification
-        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page, req.user);
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_LIKE, page, req.user);
       }
       catch (err) {
         logger.error('Like notification failed', err);
@@ -478,7 +478,8 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
     }
 
-    const { grantedUserGroups, grantedExternalUserGroups } = divideByType(grantedGroups);
+    const userRelatedGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(page, req.user);
+    const { grantedUserGroups, grantedExternalUserGroups } = divideByType(userRelatedGrantedGroups);
     const currentPageUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroups } });
     const currentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroups } });
     const grantedUserGroupData = currentPageUserGroups.map((group) => {
@@ -489,7 +490,7 @@ module.exports = (crowi) => {
     });
     const currentPageGrant = {
       grant,
-      grantedGroups: [...grantedUserGroupData, ...grantedExternalUserGroupData],
+      userRelatedGrantedGroups: [...grantedUserGroupData, ...grantedExternalUserGroupData],
     };
 
     // page doesn't have parent page
@@ -514,10 +515,11 @@ module.exports = (crowi) => {
       return res.apiv3({ isGrantNormalized, grantData });
     }
 
+    const userRelatedParentGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(parentPage, req.user);
     const {
       grantedUserGroups: parentGrantedUserGroupIds,
       grantedExternalUserGroups: parentGrantedExternalUserGroupIds,
-    } = divideByType(parentPage.grantedGroups);
+    } = divideByType(userRelatedParentGrantedGroups);
     const parentPageUserGroups = await UserGroup.find({ _id: { $in: parentGrantedUserGroupIds } });
     const parentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: parentGrantedExternalUserGroupIds } });
     const parentGrantedUserGroupData = parentPageUserGroups.map((group) => {
@@ -528,7 +530,7 @@ module.exports = (crowi) => {
     });
     const parentPageGrant = {
       grant: parentPage.grant,
-      grantedGroups: [...parentGrantedUserGroupData, ...parentGrantedExternalUserGroupData],
+      userRelatedGrantedGroups: [...parentGrantedUserGroupData, ...parentGrantedExternalUserGroupData],
     };
 
     const grantData = {
@@ -565,10 +567,10 @@ module.exports = (crowi) => {
 
   router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.params;
-    const { grant, grantedGroups } = req.body;
+    const { grant, userRelatedGrantedGroups } = req.body;
 
     // TODO: remove in https://redmine.weseek.co.jp/issues/136137
-    if (grantedGroups != null && grantedGroups.length > 1) {
+    if (userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 1) {
       return res.apiv3Err('Cannot grant multiple groups to page at the moment');
     }
 
@@ -584,7 +586,7 @@ module.exports = (crowi) => {
     let data;
     try {
       const shouldUseV4Process = false;
-      const grantData = { grant, grantedGroups };
+      const grantData = { grant, userRelatedGrantedGroups };
       data = await crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
     }
     catch (err) {

+ 21 - 201
apps/app/src/server/routes/apiv3/pages.js → apps/app/src/server/routes/apiv3/pages/index.js

@@ -2,25 +2,27 @@
 import { PageGrant } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { isCreatablePage, isTrashPage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
-import { normalizePath, addHeadingSlash, attachTitleHeader } from '@growi/core/dist/utils/path-utils';
+import { normalizePath, addHeadingSlash } from '@growi/core/dist/utils/path-utils';
+import express from 'express';
+import { body, query } from 'express-validator';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import { GlobalNotificationSettingEvent } from '~/server/models';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 
-import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
-import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
+import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import { serializePageSecurely } from '../../../models/serializers/page-serializer';
+import { serializeUserSecurely } from '../../../models/serializers/user-serializer';
+import { isV5ConversionError } from '../../../models/vo/v5-conversion-error';
+import { createPageHandlersFactory } from '../page/cteate-page';
 
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
-const express = require('express');
-const { body } = require('express-validator');
-const { query } = require('express-validator');
-const mongoose = require('mongoose');
-
 const router = express.Router();
 
 const LIMIT_FOR_LIST = 10;
@@ -144,40 +146,21 @@ const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
  */
 
 module.exports = (crowi) => {
-  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../../middlewares/admin-required')(crowi);
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
-  const PageTagRelation = crowi.model('PageTagRelation');
-  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
   const activityEvent = crowi.event('activity');
 
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const userNotificationService = crowi.getUserNotificationService();
-
-  const { serializePageSecurely } = require('../../models/serializers/page-serializer');
-  const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
-  const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const validator = {
-    createPage: [
-      body('body').optional().isString()
-        .withMessage('body must be string or undefined'),
-      body('path').exists().not().isEmpty({ ignore_whitespace: true })
-        .withMessage('path is required'),
-      body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
-      body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
-      body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
-      body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
-      body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
-      body('shouldGeneratePath').optional().isBoolean().withMessage('shouldGeneratePath is must be boolean or undefined'),
-    ],
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
@@ -222,33 +205,6 @@ module.exports = (crowi) => {
     ],
   };
 
-  async function createPageAction({
-    path, body, user, options,
-  }) {
-    const createdPage = await crowi.pageService.create(path, body, user, options);
-    return createdPage;
-  }
-
-  async function saveTagsAction({ createdPage, pageTags }) {
-    if (pageTags != null) {
-      const tagEvent = crowi.event('tag');
-      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
-      tagEvent.emit('update', createdPage, pageTags);
-      return PageTagRelation.listTagNamesByPage(createdPage.id);
-    }
-
-    return [];
-  }
-
-  async function generateUniquePath(basePath, index = 1) {
-    const path = basePath + index;
-    const existingPageId = await Page.exists({ path, isEmpty: false });
-    if (existingPageId != null) {
-      return generateUniquePath(basePath, index + 1);
-    }
-    return path;
-  }
-
   /**
    * @swagger
    *
@@ -304,137 +260,7 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
-    const {
-      // body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
-      body, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
-    } = req.body;
-
-    let { path, grant, grantUserGroupIds } = req.body;
-
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
-    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
-    // check whether path starts slash
-    path = addHeadingSlash(path);
-
-    if (shouldGeneratePath) {
-      try {
-        const rootPath = '/';
-        const defaultTitle = '/Untitled';
-        const basePath = path === rootPath ? defaultTitle : path + defaultTitle;
-        path = await generateUniquePath(basePath);
-
-        // if the generated path is not creatable, create the path under the root path
-        if (!isCreatablePage(path)) {
-          path = await generateUniquePath(defaultTitle);
-          // initialize grant data
-          grant = 1;
-          grantUserGroupIds = undefined;
-        }
-      }
-      catch (err) {
-        return res.apiv3Err(new ErrorV3('Failed to generate unique path'));
-      }
-    }
-
-    if (!isCreatablePage(path)) {
-      return res.apiv3Err(`Could not use the path '${path}'`);
-    }
-
-    if (isUserPage(path)) {
-      const isExistUser = await User.isExistUserByUserPagePath(path);
-      if (!isExistUser) {
-        return res.apiv3Err("Unable to create a page under a non-existent user's user page");
-      }
-    }
-
-    const options = { overwriteScopesOfDescendants };
-    if (grant != null) {
-      options.grant = grant;
-      options.grantUserGroupIds = grantUserGroupIds;
-    }
-
-    const isNoBodyPage = body === undefined;
-    let initialTags = [];
-    let initialBody = '';
-    if (isNoBodyPage) {
-      const isEnabledAttachTitleHeader = await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
-      if (isEnabledAttachTitleHeader) {
-        initialBody += `${attachTitleHeader(path)}\n`;
-      }
-
-      const templateData = await Page.findTemplate(path);
-      if (templateData?.templateTags != null) {
-        initialTags = templateData.templateTags;
-      }
-      if (templateData?.templateBody != null) {
-        initialBody += `${templateData.templateBody}\n`;
-      }
-    }
-
-    let createdPage;
-    try {
-      createdPage = await createPageAction({
-        path, body: isNoBodyPage ? initialBody : body, user: req.user, options,
-      });
-    }
-    catch (err) {
-      logger.error('Error occurred while creating a page.', err);
-      return res.apiv3Err(err);
-    }
-
-    const savedTags = await saveTagsAction({ createdPage, pageTags: isNoBodyPage ? initialTags : pageTags });
-
-    const result = {
-      page: serializePageSecurely(createdPage),
-      tags: savedTags,
-      revision: serializeRevisionSecurely(createdPage.revision),
-    };
-
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: createdPage,
-      action: SupportedAction.ACTION_PAGE_CREATE,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
-
-    res.apiv3(result, 201);
-
-    try {
-      // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
-    }
-    catch (err) {
-      logger.error('Create grobal notification failed', err);
-    }
-
-    // user notification
-    if (isSlackEnabled) {
-      try {
-        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
-        results.forEach((result) => {
-          if (result.status === 'rejected') {
-            logger.error('Create user notification failed', result.reason);
-          }
-        });
-      }
-      catch (err) {
-        logger.error('Create user notification failed', err);
-      }
-    }
-
-    // create subscription
-    try {
-      await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
-    }
-    catch (err) {
-      logger.error('Failed to create subscription document', err);
-    }
-  });
-
+  router.post('/', createPageHandlersFactory(crowi));
 
   /**
    * @swagger
@@ -471,7 +297,6 @@ module.exports = (crowi) => {
         }
       });
 
-      const PageTagRelation = mongoose.model('PageTagRelation');
       const ids = result.pages.map((page) => { return page._id });
       const relations = await PageTagRelation.find({ relatedPage: { $in: ids } }).populate('relatedTag');
 
@@ -622,7 +447,7 @@ module.exports = (crowi) => {
 
     try {
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, renamedPage, req.user, {
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_MOVE, renamedPage, req.user, {
         oldPath: page.path,
       });
     }
@@ -802,7 +627,7 @@ module.exports = (crowi) => {
    */
   router.post('/duplicate', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
     async(req, res) => {
-      const { pageId, isRecursively } = req.body;
+      const { pageId, isRecursively, onlyDuplicateUserRelatedResources } = req.body;
 
       const newPagePath = normalizePath(req.body.pageNameInput);
 
@@ -826,11 +651,6 @@ module.exports = (crowi) => {
 
       const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
-      // TODO: remove in https://redmine.weseek.co.jp/issues/136139
-      if (page.grantedGroups != null && page.grantedGroups.length > 1) {
-        return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-      }
-
       const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
       if (page == null || isEmptyAndNotRecursively) {
         res.code = 'Page is not found';
@@ -838,14 +658,14 @@ module.exports = (crowi) => {
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
       }
 
-      const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
+      const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively, onlyDuplicateUserRelatedResources);
       const result = { page: serializePageSecurely(newParentPage) };
 
       // copy the page since it's used and updated in crowi.pageService.duplicate
       const copyPage = { ...page };
       copyPage.path = newPagePath;
       try {
-        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, copyPage, req.user);
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, copyPage, req.user);
       }
       catch (err) {
         logger.error('Create grobal notification failed', err);

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

@@ -357,6 +357,8 @@ module.exports = (crowi) => {
         pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
         pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
+        isAllGroupMembershipRequiredForPageCompleteDeletion:
+        await configManager.getConfig('crowi', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
@@ -627,6 +629,7 @@ module.exports = (crowi) => {
       'security:pageRecursiveDeletionAuthority': req.body.pageRecursiveDeletionAuthority,
       'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
+      'security:isAllGroupMembershipRequiredForPageCompleteDeletion': req.body.isAllGroupMembershipRequiredForPageCompleteDeletion,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
       'security:user-homepage-deletion:isEnabled': req.body.isUsersHomepageDeletionEnabled,
@@ -660,6 +663,8 @@ module.exports = (crowi) => {
         pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
         pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
+        isAllGroupMembershipRequiredForPageCompleteDeletion:
+        await configManager.getConfig('crowi', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),

+ 2 - 1
apps/app/src/server/routes/comment.js

@@ -3,6 +3,7 @@ import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
 import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent } from '../models';
 import { preNotifyService } from '../service/pre-notify';
 
 /**
@@ -281,7 +282,7 @@ module.exports = function(crowi, app) {
 
     // global notification
     try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.COMMENT, page, req.user, {
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.COMMENT, page, req.user, {
         comment: createdComment,
       });
     }

+ 0 - 2
apps/app/src/server/routes/index.js

@@ -121,7 +121,6 @@ module.exports = function(crowi, app) {
   apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  apiV1Router.get('/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, page.api.update);
   apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
@@ -130,7 +129,6 @@ module.exports = function(crowi, app) {
   apiV1Router.post('/pages.remove'       , loginRequiredStrictly , excludeReadOnlyUser, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
   apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , excludeReadOnlyUser, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
   apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , excludeReadOnlyUser, page.api.unlink); // (Avoid from API Token)
-  apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, page.api.duplicate);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
   apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
   apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);

+ 11 - 257
apps/app/src/server/routes/page.js

@@ -5,7 +5,9 @@ import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import XssOption from '~/services/xss/xssOption';
 import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent } from '../models';
 import { PathAlreadyExistsError } from '../models/errors';
+import PageTagRelation from '../models/page-tag-relation';
 import UpdatePost from '../models/update-post';
 import { preNotifyService } from '../service/pre-notify';
 
@@ -137,12 +139,9 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
 
-  const { pathUtils, pagePathUtils } = require('@growi/core/dist/utils');
+  const { pagePathUtils } = require('@growi/core/dist/utils');
 
   const Page = crowi.model('Page');
-  const User = crowi.model('User');
-  const PageTagRelation = crowi.model('PageTagRelation');
-  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const PageRedirect = mongoose.model('PageRedirect');
 
   const ApiResponse = require('../util/apiResponse');
@@ -221,171 +220,6 @@ module.exports = function(crowi, app) {
   actions.api = api;
   actions.validator = validator;
 
-  /**
-   * @swagger
-   *
-   *    /pages.list:
-   *      get:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: listPages
-   *        summary: /pages.list
-   *        description: Get list of pages
-   *        parameters:
-   *          - in: query
-   *            name: path
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/path'
-   *          - in: query
-   *            name: user
-   *            schema:
-   *              $ref: '#/components/schemas/User/properties/username'
-   *          - in: query
-   *            name: limit
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
-   *          - in: query
-   *            name: offset
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get list of pages.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    pages:
-   *                      type: array
-   *                      items:
-   *                        $ref: '#/components/schemas/Page'
-   *                      description: page list
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /pages.list List pages by user
-   * @apiName ListPage
-   * @apiGroup Page
-   *
-   * @apiParam {String} path
-   * @apiParam {String} user
-   */
-  api.list = async function(req, res) {
-    const username = req.query.user || null;
-    const path = req.query.path || null;
-    const limit = +req.query.limit || 50;
-    const offset = parseInt(req.query.offset) || 0;
-
-    const queryOptions = { offset, limit: limit + 1 };
-
-    // Accepts only one of these
-    if (username === null && path === null) {
-      return res.json(ApiResponse.error('Parameter user or path is required.'));
-    }
-    if (username !== null && path !== null) {
-      return res.json(ApiResponse.error('Parameter user or path is required.'));
-    }
-
-    try {
-      let result = null;
-      if (path == null) {
-        const user = await User.findUserByUsername(username);
-        if (user === null) {
-          throw new Error('The user not found.');
-        }
-        result = await Page.findListByCreator(user, req.user, queryOptions);
-      }
-      else {
-        result = await Page.findListByStartWith(path, req.user, queryOptions);
-      }
-
-      if (result.pages.length > limit) {
-        result.pages.pop();
-      }
-
-      result.pages.forEach((page) => {
-        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
-        }
-      });
-
-      return res.json(ApiResponse.success(result));
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-  };
-
-  // TODO If everything that depends on this route, delete it too
-  api.create = async function(req, res) {
-    const body = req.body.body || null;
-    let pagePath = req.body.path || null;
-    const grant = req.body.grant || null;
-    const grantUserGroupIds = req.body.grantUserGroupIds || null;
-    const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
-    const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
-    const slackChannels = req.body.slackChannels || null;
-
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
-    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
-    if (body === null || pagePath === null) {
-      return res.json(ApiResponse.error('Parameters body and path are required.'));
-    }
-
-    // check whether path starts slash
-    pagePath = pathUtils.addHeadingSlash(pagePath);
-
-    // check page existence
-    const isExist = await Page.count({ path: pagePath }) > 0;
-    if (isExist) {
-      return res.json(ApiResponse.error('Page exists', 'already_exists'));
-    }
-
-    const options = { overwriteScopesOfDescendants };
-    if (grant != null) {
-      options.grant = grant;
-      options.grantUserGroupIds = grantUserGroupIds;
-    }
-
-    const createdPage = await crowi.pageService.create(pagePath, body, req.user, options);
-
-    const result = {
-      page: serializePageSecurely(createdPage),
-      revision: serializeRevisionSecurely(createdPage.revision),
-    };
-    res.json(ApiResponse.success(result));
-
-    // global notification
-    try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
-    }
-    catch (err) {
-      logger.error('Create notification failed', err);
-    }
-
-    // user notification
-    if (isSlackEnabled) {
-      try {
-        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
-        results.forEach((result) => {
-          if (result.status === 'rejected') {
-            logger.error('Create user notification failed', result.reason);
-          }
-        });
-      }
-      catch (err) {
-        logger.error('Create user notification failed', err);
-      }
-    }
-  };
-
   /**
    * @swagger
    *
@@ -449,16 +283,11 @@ module.exports = function(crowi, app) {
     const pageId = req.body.page_id || null;
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;
-    const grantUserGroupIds = req.body.grantUserGroupIds || null;
+    const userRelatedGrantUserGroupIds = req.body.userRelatedGrantUserGroupIds || null;
     const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
 
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136140
-    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
     if (pageId === null || pageBody === null || revisionId === null) {
       return res.json(ApiResponse.error('page_id, body and revision_id are required.'));
     }
@@ -486,7 +315,7 @@ module.exports = function(crowi, app) {
     const options = { overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
-      options.grantUserGroupIds = grantUserGroupIds;
+      options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
     }
 
     const previousRevision = await Revision.findById(revisionId);
@@ -507,7 +336,7 @@ module.exports = function(crowi, app) {
 
     // global notification
     try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_EDIT, page, req.user);
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, page, req.user);
     }
     catch (err) {
       logger.error('Edit notification failed', err);
@@ -758,8 +587,10 @@ module.exports = function(crowi, app) {
 
     try {
       if (isCompletely) {
-        if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
-          return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
+        const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(req.user);
+        const canDeleteCompletely = crowi.pageService.canDeleteCompletely(page, req.user, isRecursively, userRelatedGroups);
+        if (!canDeleteCompletely) {
+          return res.json(ApiResponse.error('You cannot delete this page completely', 'complete_deletion_not_allowed_for_user'));
         }
 
         if (pagePathUtils.isUsersHomepage(page.path)) {
@@ -815,7 +646,7 @@ module.exports = function(crowi, app) {
 
     try {
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_DELETE, page, req.user);
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_DELETE, page, req.user);
     }
     catch (err) {
       logger.error('Delete notification failed', err);
@@ -870,83 +701,6 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages.duplicate:
-   *      post:
-   *        tags: [Pages]
-   *        operationId: duplicatePage
-   *        summary: /pages.duplicate
-   *        description: Duplicate page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
-   *                  new_path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
-   *                required:
-   *                  - page_id
-   *        responses:
-   *          200:
-   *            description: Succeeded to duplicate page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    tags:
-   *                      $ref: '#/components/schemas/Tags'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /pages.duplicate Duplicate page
-   * @apiName DuplicatePage
-   * @apiGroup Page
-   *
-   * @apiParam {String} page_id Page Id.
-   * @apiParam {String} new_path New path name.
-   */
-  api.duplicate = async function(req, res) {
-    const pageId = req.body.page_id;
-    let newPagePath = pathUtils.normalizePath(req.body.new_path);
-
-    const page = await Page.findByIdAndViewer(pageId, req.user);
-
-    if (page == null) {
-      return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
-    }
-
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136139
-    if (page.grantedGroups != null && page.grantedGroups.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
-    // check whether path starts slash
-    newPagePath = pathUtils.addHeadingSlash(newPagePath);
-
-    await page.populateDataToShowRevision();
-    const originTags = await page.findRelatedTagsById();
-
-    req.body.path = newPagePath;
-    req.body.body = page.revision.body;
-    req.body.grant = page.grant;
-    req.body.grantedUsers = page.grantedUsers;
-    req.body.grantUserGroupIds = page.grantedGroups;
-    req.body.pageTags = originTags;
-
-    return api.create(req, res);
-  };
-
   /**
    * @api {post} /pages.unlink Remove the redirecting page
    * @apiName UnlinkPage

+ 3 - 3
apps/app/src/server/routes/tag.js

@@ -1,6 +1,9 @@
 import { SupportedAction } from '~/interfaces/activity';
 import Tag from '~/server/models/tag';
 
+import PageTagRelation from '../models/page-tag-relation';
+import ApiResponse from '../util/apiResponse';
+
 /**
  * @swagger
  *
@@ -32,9 +35,7 @@ import Tag from '~/server/models/tag';
  */
 module.exports = function(crowi, app) {
 
-  const PageTagRelation = crowi.model('PageTagRelation');
   const activityEvent = crowi.event('activity');
-  const ApiResponse = require('../util/apiResponse');
   const actions = {};
   const api = {};
 
@@ -138,7 +139,6 @@ module.exports = function(crowi, app) {
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
     const User = crowi.model('User');
-    const PageTagRelation = crowi.model('PageTagRelation');
     const Revision = crowi.model('Revision');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;

+ 10 - 10
apps/app/src/server/service/global-notification/global-notification-mail.js

@@ -1,8 +1,10 @@
+import nodePath from 'path';
+
+import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:GlobalNotificationMailService'); // eslint-disable-line no-unused-vars
-const nodePath = require('path');
 
 /**
  * sub service class of GlobalNotificationSetting
@@ -11,8 +13,6 @@ class GlobalNotificationMailService {
 
   constructor(crowi) {
     this.crowi = crowi;
-    this.type = crowi.model('GlobalNotificationSetting').TYPE.MAIL;
-    this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
 
   /**
@@ -29,7 +29,7 @@ class GlobalNotificationMailService {
     const { mailService } = this.crowi;
 
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
-    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, page.path, this.type);
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, page.path, GlobalNotificationSettingType.MAIL);
 
     const option = this.generateOption(event, page, triggeredBy, vars);
 
@@ -73,19 +73,19 @@ class GlobalNotificationMailService {
     };
 
     switch (event) {
-      case this.event.PAGE_CREATE:
+      case GlobalNotificationSettingEvent.PAGE_CREATE:
         subject = `#${event} - ${triggeredBy.username} created ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_EDIT:
+      case GlobalNotificationSettingEvent.PAGE_EDIT:
         subject = `#${event} - ${triggeredBy.username} edited ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_DELETE:
+      case GlobalNotificationSettingEvent.PAGE_DELETE:
         subject = `#${event} - ${triggeredBy.username} deleted ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_MOVE:
+      case GlobalNotificationSettingEvent.PAGE_MOVE:
         // validate for page move
         if (oldPath == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
@@ -99,11 +99,11 @@ class GlobalNotificationMailService {
         };
         break;
 
-      case this.event.PAGE_LIKE:
+      case GlobalNotificationSettingEvent.PAGE_LIKE:
         subject = `#${event} - ${triggeredBy.username} liked ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.COMMENT:
+      case GlobalNotificationSettingEvent.COMMENT:
         // validate for comment
         if (comment == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);

+ 15 - 17
apps/app/src/server/service/global-notification/global-notification-slack.js

@@ -1,6 +1,7 @@
 import { pagePathUtils } from '@growi/core/dist/utils';
-import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models';
+import loggerFactory from '~/utils/logger';
 
 import {
   prepareSlackMessageForGlobalNotification,
@@ -18,9 +19,6 @@ class GlobalNotificationSlackService {
 
   constructor(crowi) {
     this.crowi = crowi;
-
-    this.type = crowi.model('GlobalNotificationSetting').TYPE.SLACK;
-    this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
 
 
@@ -39,7 +37,7 @@ class GlobalNotificationSlackService {
     const { appService, slackIntegrationService } = this.crowi;
 
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
-    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, GlobalNotificationSettingType.SLACK);
 
     const messageBody = this.generateMessageBody(event, id, path, triggeredBy, vars);
     const attachmentBody = this.generateAttachmentBody(event, id, path, triggeredBy, vars);
@@ -74,16 +72,16 @@ class GlobalNotificationSlackService {
     let messageBody;
 
     switch (event) {
-      case this.event.PAGE_CREATE:
+      case GlobalNotificationSettingEvent.PAGE_CREATE:
         messageBody = `:bell: ${username} created ${parmaLink}`;
         break;
-      case this.event.PAGE_EDIT:
+      case GlobalNotificationSettingEvent.PAGE_EDIT:
         messageBody = `:bell: ${username} edited ${parmaLink}`;
         break;
-      case this.event.PAGE_DELETE:
+      case GlobalNotificationSettingEvent.PAGE_DELETE:
         messageBody = `:bell: ${username} deleted ${pathLink}`;
         break;
-      case this.event.PAGE_MOVE:
+      case GlobalNotificationSettingEvent.PAGE_MOVE:
         // validate for page move
         if (oldPath == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
@@ -91,10 +89,10 @@ class GlobalNotificationSlackService {
         // eslint-disable-next-line no-case-declarations
         messageBody = `:bell: ${username} moved ${oldPath} to ${parmaLink}`;
         break;
-      case this.event.PAGE_LIKE:
+      case GlobalNotificationSettingEvent.PAGE_LIKE:
         messageBody = `:bell: ${username} liked ${parmaLink}`;
         break;
-      case this.event.COMMENT:
+      case GlobalNotificationSettingEvent.COMMENT:
         // validate for comment
         if (comment == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
@@ -128,17 +126,17 @@ class GlobalNotificationSlackService {
     // attachment body is intended for comment or page diff
 
     // switch (event) {
-    //   case this.event.PAGE_CREATE:
+    //   case GlobalNotificationSettingEvent.PAGE_CREATE:
     //     break;
-    //   case this.event.PAGE_EDIT:
+    //   case GlobalNotificationSettingEvent.PAGE_EDIT:
     //     break;
-    //   case this.event.PAGE_DELETE:
+    //   case GlobalNotificationSettingEvent.PAGE_DELETE:
     //     break;
-    //   case this.event.PAGE_MOVE:
+    //   case GlobalNotificationSettingEvent.PAGE_MOVE:
     //     break;
-    //   case this.event.PAGE_LIKE:
+    //   case GlobalNotificationSettingEvent.PAGE_LIKE:
     //     break;
-    //   case this.event.COMMENT:
+    //   case GlobalNotificationSettingEvent.COMMENT:
     //     break;
     //   default:
     //     throw new Error(`unknown global notificaiton event: ${event}`);

+ 25 - 10
apps/app/src/server/service/growi-bridge.js → apps/app/src/server/service/growi-bridge/index.ts

@@ -1,9 +1,14 @@
+import { Model } from 'mongoose';
+import unzipStream, { type Entry } from 'unzip-stream';
+
 import loggerFactory from '~/utils/logger';
 
+import { tapStreamDataByPromise } from './unzip-stream-utils';
+
 const fs = require('fs');
 const path = require('path');
+
 const streamToPromise = require('stream-to-promise');
-const unzipper = require('unzipper');
 
 const logger = loggerFactory('growi:services:GrowiBridgeService'); // eslint-disable-line no-unused-vars
 
@@ -13,6 +18,14 @@ const logger = loggerFactory('growi:services:GrowiBridgeService'); // eslint-dis
  */
 class GrowiBridgeService {
 
+  crowi: any;
+
+  encoding: string;
+
+  metaFileName: string;
+
+  baseDir: null;
+
   constructor(crowi) {
     this.crowi = crowi;
     this.encoding = 'utf-8';
@@ -47,7 +60,7 @@ class GrowiBridgeService {
    * @return {object} instance of mongoose model
    */
   getModelFromCollectionName(collectionName) {
-    const Model = Object.values(this.crowi.models).find((m) => {
+    const Model = Object.values(this.crowi.models).find((m: Model<unknown>) => {
       return m.collection != null && m.collection.name === collectionName;
     });
 
@@ -84,18 +97,20 @@ class GrowiBridgeService {
    */
   async parseZipFile(zipFile) {
     const fileStat = fs.statSync(zipFile);
-    const innerFileStats = [];
+    const innerFileStats: Array<{ fileName: string, collectionName: string, size: number }> = [];
     let meta = {};
 
     const readStream = fs.createReadStream(zipFile);
-    const unzipStream = readStream.pipe(unzipper.Parse());
+    const unzipStreamPipe = readStream.pipe(unzipStream.Parse());
+    let tapPromise;
 
-    unzipStream.on('entry', async(entry) => {
+    const unzipEntryStream = unzipStreamPipe.on('entry', (entry: Entry) => {
       const fileName = entry.path;
-      const size = entry.vars.uncompressedSize; // There is also compressedSize;
-
+      const size = entry.size; // might be undefined in some archives
       if (fileName === this.getMetaFileName()) {
-        meta = JSON.parse((await entry.buffer()).toString());
+        tapPromise = tapStreamDataByPromise(entry).then((metaBuffer) => {
+          meta = JSON.parse(metaBuffer.toString());
+        });
       }
       else {
         innerFileStats.push({
@@ -104,12 +119,12 @@ class GrowiBridgeService {
           size,
         });
       }
-
       entry.autodrain();
     });
 
     try {
-      await streamToPromise(unzipStream);
+      await streamToPromise(unzipEntryStream);
+      await tapPromise;
     }
     // if zip is broken
     catch (err) {

+ 22 - 0
apps/app/src/server/service/growi-bridge/unzip-stream-utils.ts

@@ -0,0 +1,22 @@
+import { PassThrough } from 'stream';
+
+import type { Entry } from 'unzip-stream';
+
+export const tapStreamDataByPromise = (entry: Entry): Promise<Buffer> => {
+  return new Promise((resolve, reject) => {
+    const buffers: Array<Buffer> = [];
+
+    const entryContentGetterStream = new PassThrough()
+      .on('data', (chunk) => {
+        buffers.push(Buffer.from(chunk));
+      })
+      .on('end', () => {
+        resolve(Buffer.concat(buffers));
+      })
+      .on('error', reject);
+
+    entry
+      .pipe(entryContentGetterStream)
+      .on('error', reject);
+  });
+};

+ 9 - 4
apps/app/src/server/service/import.js

@@ -1,3 +1,8 @@
+/**
+ * @typedef {import("@types/unzip-stream").Parse} Parse
+ * @typedef {import("@types/unzip-stream").Entry} Entry
+ */
+
 import gc from 'expose-gc/function';
 
 import loggerFactory from '~/utils/logger';
@@ -11,7 +16,7 @@ const parseISO = require('date-fns/parseISO');
 const isIsoDate = require('is-iso-date');
 const mongoose = require('mongoose');
 const streamToPromise = require('stream-to-promise');
-const unzipper = require('unzipper');
+const unzipStream = require('unzip-stream');
 
 const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
 const { createBatchStream } = require('../util/batch-stream');
@@ -386,10 +391,10 @@ class ImportService {
    */
   async unzip(zipFile) {
     const readStream = fs.createReadStream(zipFile);
-    const unzipStream = readStream.pipe(unzipper.Parse());
+    const unzipStreamPipe = readStream.pipe(unzipStream.Parse());
     const files = [];
 
-    unzipStream.on('entry', (entry) => {
+    unzipStreamPipe.on('entry', (/** @type {Entry} */ entry) => {
       const fileName = entry.path;
       // https://regex101.com/r/mD4eZs/6
       // prevent from unexpecting attack doing unzip file (path traversal attack)
@@ -412,7 +417,7 @@ class ImportService {
       }
     });
 
-    await streamToPromise(unzipStream);
+    await streamToPromise(unzipStreamPipe);
 
     return files;
   }

+ 12 - 0
apps/app/src/server/service/normalize-data/index.ts

@@ -0,0 +1,12 @@
+import loggerFactory from '~/utils/logger';
+
+import { renameDuplicateRootPages } from './rename-duplicate-root-pages';
+
+const logger = loggerFactory('growi:service:NormalizeData');
+
+export const normalizeData = async(): Promise<void> => {
+  await renameDuplicateRootPages();
+
+  logger.info('normalizeData has been executed');
+  return;
+};

+ 31 - 0
apps/app/src/server/service/normalize-data/rename-duplicate-root-pages.ts

@@ -0,0 +1,31 @@
+// see: https://github.com/weseek/growi/issues/8337
+
+import { type IPageHasId } from '@growi/core';
+import mongoose from 'mongoose';
+
+import { type PageModel } from '~/server/models/page';
+
+export const renameDuplicateRootPages = async(): Promise<void> => {
+  const Page = mongoose.model<IPageHasId, PageModel>('Page');
+  const rootPages = await Page.find({ path: '/' }).sort({ createdAt: 1 });
+
+  if (rootPages.length <= 1) {
+    return;
+  }
+
+  const duplicatedRootPages = rootPages.slice(1);
+  const requests = duplicatedRootPages.map((page) => {
+    return {
+      updateOne: {
+        filter: { _id: page._id },
+        update: {
+          $set: {
+            parent: rootPages[0],
+            path: `/obsolete-root-page-${page._id.toString()}`,
+          },
+        },
+      },
+    };
+  });
+  await Page.bulkWrite(requests);
+};

+ 147 - 16
apps/app/src/server/service/page-grant.ts

@@ -1,20 +1,19 @@
 import {
   type IGrantedGroup,
-  PageGrant, type PageGrantCanBeOnTree, GroupType,
+  PageGrant, GroupType, getIdForRef, isPopulated,
 } from '@growi/core';
 import {
   pagePathUtils, pathUtils, pageUtils,
 } from '@growi/core/dist/utils';
-import { et } from 'date-fns/locale';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose from 'mongoose';
 
-import ExternalUserGroup, { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import { IRecordApplicableGrant } from '~/interfaces/page-grant';
+import { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { PageDocument, PageModel } from '~/server/models/page';
-import UserGroup, { UserGroupDocument } from '~/server/models/user-group';
-import { includesObjectIds, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import UserGroup from '~/server/models/user-group';
+import { includesObjectIds, excludeTestIdsFromTargetIds, hasIntersection } from '~/server/util/compare-objectId';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import UserGroupRelation from '../models/user-group-relation';
@@ -26,7 +25,7 @@ const { isTopPage } = pagePathUtils;
 const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
 type ComparableTarget = {
-  grant: number,
+  grant?: number,
   grantedUserIds?: ObjectIdLike[],
   grantedGroupIds?: IGrantedGroup[],
   applicableUserIds?: ObjectIdLike[],
@@ -78,7 +77,33 @@ type OperatorGrantInfo = {
   userGroupIds: Set<ObjectIdLike>,
 };
 
-class PageGrantService {
+export interface IPageGrantService {
+  isGrantNormalized: (
+    user,
+    targetPath: string,
+    grant?: PageGrant,
+    grantedUserIds?: ObjectIdLike[],
+    grantedGroupIds?: IGrantedGroup[],
+    shouldCheckDescendants?: boolean,
+    includeNotMigratedPages?: boolean,
+    previousGrantedGroupIds?: IGrantedGroup[]
+  ) => Promise<boolean>,
+  separateNormalizableAndNotNormalizablePages: (user, pages) => Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]>,
+  generateUpdateGrantInfoToOverwriteDescendants: (
+    operator, updateGrant?: PageGrant, grantGroupIds?: IGrantedGroup[],
+  ) => Promise<UpdateGrantInfo>,
+  canOverwriteDescendants: (targetPath: string, operator: { _id: ObjectIdLike }, updateGrantInfo: UpdateGrantInfo) => Promise<boolean>,
+  validateGrantChange: (user, previousGrantedGroupIds: IGrantedGroup[], grant?: PageGrant, grantedGroupIds?: IGrantedGroup[]) => Promise<boolean>,
+  validateGrantChangeSyncronously:(
+    userRelatedGroups: PopulatedGrantedGroup[], previousGrantedGroups: IGrantedGroup[], grant?: PageGrant, grantedGroups?: IGrantedGroup[],
+  ) => boolean,
+  getUserRelatedGroups: (user) => Promise<PopulatedGrantedGroup[]>,
+  getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
+  getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
+  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean
+}
+
+class PageGrantService implements IPageGrantService {
 
   crowi!: any;
 
@@ -103,7 +128,10 @@ class PageGrantService {
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
    * @returns boolean
    */
-  private processValidation(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
+  private validateGrant(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
+    /*
+     * the page itself
+     */
     this.validateComparableTarget(target);
 
     const Page = mongoose.model('Page') as unknown as PageModel;
@@ -209,12 +237,55 @@ class PageGrantService {
     return true;
   }
 
+  /**
+   * Validate if page grant can be changed from prior grant to specified grant.
+   * Necessary for pages with multiple group grant.
+   * see: https://dev.growi.org/656745fa52eafe1cf1879508#%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AE-grant-%E3%81%AE%E6%9B%B4%E6%96%B0
+   * @param user The user who is changing the grant
+   * @param previousGrantedGroups The groups that were granted priorly
+   * @param grant The grant to be changed to
+   * @param grantedGroups The groups to be granted
+   */
+  async validateGrantChange(user, previousGrantedGroups: IGrantedGroup[], grant?: PageGrant, grantedGroups?: IGrantedGroup[]): Promise<boolean> {
+    const userRelatedGroups = await this.getUserRelatedGroups(user);
+    return this.validateGrantChangeSyncronously(userRelatedGroups, previousGrantedGroups, grant, grantedGroups);
+  }
+
+  /**
+   * Use when you do not want to use validateGrantChange with async/await (e.g inside loops that process a large amount of pages)
+   * Specification of userRelatedGroups is necessary to avoid the cost of fetching userRelatedGroups from DB every time.
+   */
+  validateGrantChangeSyncronously(
+      userRelatedGroups: PopulatedGrantedGroup[],
+      previousGrantedGroups: IGrantedGroup[],
+      grant?: PageGrant,
+      grantedGroups?: IGrantedGroup[],
+  ): boolean {
+    const userRelatedGroupIds = userRelatedGroups.map(g => g.item._id);
+    const userBelongsToAllPreviousGrantedGroups = excludeTestIdsFromTargetIds(
+      previousGrantedGroups.map(g => getIdForRef(g.item)),
+      userRelatedGroupIds,
+    ).length === 0;
+
+    if (!userBelongsToAllPreviousGrantedGroups) {
+      if (grant !== PageGrant.GRANT_USER_GROUP) {
+        return false;
+      }
+      const pageGrantIncludesUserRelatedGroup = hasIntersection(grantedGroups?.map(g => getIdForRef(g.item)) || [], userRelatedGroupIds);
+      if (!pageGrantIncludesUserRelatedGroup) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
   /**
    * Prepare ComparableTarget
    * @returns Promise<ComparableAncestor>
    */
   private async generateComparableTarget(
-      grant, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined, includeApplicable: boolean,
+      grant: PageGrant | undefined, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined, includeApplicable: boolean,
   ): Promise<ComparableTarget> {
     if (includeApplicable) {
       const Page = mongoose.model('Page') as unknown as PageModel;
@@ -415,27 +486,51 @@ class PageGrantService {
    * Only v5 schema pages will be used to compare by default (Set includeNotMigratedPages to true to include v4 schema pages as well).
    * When comparing, it will use path regex to collect pages instead of using parent attribute of the Page model. This is reasonable since
    * using the path attribute is safer than using the parent attribute in this case. 2022.02.13 -- Taichi Masuyama
+   * @param user The user responsible for execution
+   * @param targetPath Path of page which grant will be validated
+   * @param grant Type of the grant to be validated
+   * @param grantedUserIds Users of grant to be validated
+   * @param grantedGroupIds Groups of grant to be validated
+   * @param shouldCheckDescendants Whether or not to use descendant grant for validation
+   * @param includeNotMigratedPages Whether or not to use unmigrated pages for validation
+   * @param previousGrantedGroupIds
+   *   Previously granted groups of the page. Specific validation is required when previous grant is multiple group grant.
+   *   Apply when page grant change needs to be validated.
+   *   see: https://dev.growi.org/656745fa52eafe1cf1879508#%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AE-grant-%E3%81%AE%E6%9B%B4%E6%96%B0
    * @returns Promise<boolean>
    */
   async isGrantNormalized(
-      // eslint-disable-next-line max-len
-      user, targetPath: string, grant, grantedUserIds?: ObjectIdLike[], grantedGroupIds?: IGrantedGroup[], shouldCheckDescendants = false, includeNotMigratedPages = false,
+      user,
+      targetPath: string,
+      grant?: PageGrant,
+      grantedUserIds?: ObjectIdLike[],
+      grantedGroupIds?: IGrantedGroup[],
+      shouldCheckDescendants = false,
+      includeNotMigratedPages = false,
+      previousGrantedGroupIds?: IGrantedGroup[],
   ): Promise<boolean> {
     if (isTopPage(targetPath)) {
       return true;
     }
 
+    if (previousGrantedGroupIds != null) {
+      const isGrantChangeable = await this.validateGrantChange(user, previousGrantedGroupIds, grant, grantedGroupIds);
+      if (!isGrantChangeable) {
+        return false;
+      }
+    }
+
     const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
 
     if (!shouldCheckDescendants) { // checking the parent is enough
       const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, false);
-      return this.processValidation(comparableTarget, comparableAncestor);
+      return this.validateGrant(comparableTarget, comparableAncestor);
     }
 
     const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, true);
     const comparableDescendants = await this.generateComparableDescendants(targetPath, user, includeNotMigratedPages);
 
-    return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
+    return this.validateGrant(comparableTarget, comparableAncestor, comparableDescendants);
   }
 
   /**
@@ -568,7 +663,10 @@ class PageGrantService {
     return data;
   }
 
-  async getUserRelatedGroups(user) {
+  /*
+   * get all groups that user is related to
+   */
+  async getUserRelatedGroups(user): Promise<PopulatedGrantedGroup[]> {
     const userRelatedUserGroups = await UserGroupRelation.findAllGroupsForUser(user);
     const userRelatedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user);
     return [
@@ -581,6 +679,38 @@ class PageGrantService {
     ];
   }
 
+  /*
+   * get all groups of Page that user is related to
+   */
+  async getUserRelatedGrantedGroups(page: PageDocument, user): Promise<IGrantedGroup[]> {
+    const userRelatedGroups = (await this.getUserRelatedGroups(user));
+    return this.getUserRelatedGrantedGroupsSyncronously(userRelatedGroups, page);
+  }
+
+  /**
+   * Use when you do not want to use getUserRelatedGrantedGroups with async/await (e.g inside loops that process a large amount of pages)
+   * Specification of userRelatedGroups is necessary to avoid the cost of fetching userRelatedGroups from DB every time.
+   */
+  getUserRelatedGrantedGroupsSyncronously(userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument): IGrantedGroup[] {
+    const userRelatedGroupIds: string[] = userRelatedGroups.map(ug => ug.item._id.toString());
+    return page.grantedGroups?.filter((group) => {
+      if (isPopulated(group.item)) {
+        return userRelatedGroupIds.includes(group.item._id.toString());
+      }
+      return userRelatedGroupIds.includes(group.item);
+    }) || [];
+  }
+
+  /**
+   * Check if user is granted access to page
+   */
+  isUserGrantedPageAccess(page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]): boolean {
+    if (page.grant === PageGrant.GRANT_PUBLIC) return true;
+    if (page.grant === PageGrant.GRANT_OWNER) return page.grantedUsers?.includes(user._id.toString()) ?? false;
+    if (page.grant === PageGrant.GRANT_USER_GROUP) return this.getUserRelatedGrantedGroupsSyncronously(userRelatedGroups, page).length > 0;
+    return false;
+  }
+
   /**
    * see: https://dev.growi.org/635a314eac6bcd85cbf359fc
    * @param {string} targetPath
@@ -622,7 +752,7 @@ class PageGrantService {
   }
 
   async generateUpdateGrantInfoToOverwriteDescendants(
-      operator, updateGrant: PageGrantCanBeOnTree, grantGroupIds?: IGrantedGroup[],
+      operator, updateGrant?: PageGrant, grantGroupIds?: IGrantedGroup[],
   ): Promise<UpdateGrantInfo> {
     let updateGrantInfo: UpdateGrantInfo | null = null;
 
@@ -666,6 +796,7 @@ class PageGrantService {
     }
 
     if (updateGrantInfo == null) {
+      // Neither pages with grant `GRANT_RESTRICTED` nor `GRANT_SPECIFIED` can be on a page tree.
       throw Error('The parameter `updateGrant` must be 1, 4, or 5');
     }
 

+ 2 - 2
apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts

@@ -90,12 +90,12 @@ describe('delete-completely-user-home-by-system test', () => {
     const mockPageEvent = mock<EventEmitter>();
     const mockDeleteMultipleCompletely = vi.fn().mockImplementation(() => Promise.resolve());
 
-    const mockPageService: IPageService = {
+    const mockPageService = mock<IPageService>({
       updateDescendantCountOfAncestors: mockUpdateDescendantCountOfAncestors,
       deleteCompletelyOperation: mockDeleteCompletelyOperation,
       getEventEmitter: () => mockPageEvent,
       deleteMultipleCompletely: mockDeleteMultipleCompletely,
-    };
+    });
 
     it('should call used page service functions', async() => {
       // when

Разница между файлами не показана из-за своего большого размера
+ 271 - 133
apps/app/src/server/service/page/index.ts


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

@@ -1,12 +1,22 @@
 import type EventEmitter from 'events';
 
-import type { IUser } from '@growi/core';
+import type { IPageInfo, IPageInfoForEntity, IUser } from '@growi/core';
+import type { ObjectId } from 'mongoose';
 
+import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
+import type { PageDocument } from '~/server/models/page';
 
 export interface IPageService {
+  create(path: string, body: string, user: IUser, options: IOptionsForCreate): Promise<PageDocument>,
   updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
   deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
   getEventEmitter: () => EventEmitter,
   deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
+  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
+  findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
+  shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
+  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
+  canDeleteCompletely(page: PageDocument, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
 }

+ 11 - 14
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -7,18 +7,18 @@ import streamToPromise from 'stream-to-promise';
 
 import { Comment } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import {
-  ISearchResult, ISearchResultData, SORT_AXIS, SORT_ORDER,
-} from '~/interfaces/search';
+import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
+import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { SocketEventName } from '~/interfaces/websocket';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import loggerFactory from '~/utils/logger';
 
-import {
+import type {
   SearchDelegator, SearchableData, QueryTerms, UnavailableTermsKey, ESQueryTerms, ESTermsKey,
 } from '../../interfaces/search';
-import { PageModel } from '../../models/page';
+import type { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
-import { UpdateOrInsertPagesOpts } from '../interfaces/search';
+import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
 
 
 import ElasticsearchClient from './elasticsearch-client';
@@ -368,13 +368,11 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       });
     }
 
-    let grantedGroupIds = null;
-    if (page.grantedGroups != null) {
-      grantedGroupIds = page.grantedGroups.map((group) => {
-        const groupId = (group.item._id == null) ? group.item : group.item._id;
-        return groupId.toString();
-      });
-    }
+    let grantedGroupIds = [];
+    grantedGroupIds = page.grantedGroups.map((group) => {
+      const groupId = (group.item._id == null) ? group.item : group.item._id;
+      return groupId.toString();
+    });
 
     return {
       grant: page.grant,
@@ -462,7 +460,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
     const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
 

+ 22 - 0
apps/app/src/server/service/socket-io.js

@@ -1,8 +1,12 @@
+import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
 import { Server } from 'socket.io';
 
 import loggerFactory from '~/utils/logger';
+
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
+import YjsConnectionManager from './yjs-connection-manager';
+
 const expressSession = require('express-session');
 const passport = require('passport');
 
@@ -33,6 +37,9 @@ class SocketIoService {
     });
     this.io.attach(server);
 
+    // create the YjsConnectionManager instance
+    this.yjsConnectionManager = new YjsConnectionManager(this.io);
+
     // create namespace for admin
     this.adminNamespace = this.io.of('/admin');
 
@@ -47,6 +54,7 @@ class SocketIoService {
 
     await this.setupLoginedUserRoomsJoinOnConnection();
     await this.setupDefaultSocketJoinRoomsEventHandler();
+    await this.setupYjsConnection();
   }
 
   getDefaultSocket() {
@@ -151,6 +159,20 @@ class SocketIoService {
     });
   }
 
+  setupYjsConnection() {
+    this.io.on('connection', (socket) => {
+      socket.on(GlobalSocketEventName.YDocSync, async({ pageId, initialValue }) => {
+        try {
+          await this.yjsConnectionManager.handleYDocSync(pageId, initialValue);
+        }
+        catch (error) {
+          logger.warn(error.message);
+          socket.emit(GlobalSocketEventName.YDocSyncError, 'An error occurred during YDoc synchronization.');
+        }
+      });
+    });
+  }
+
   async checkConnectionLimitsForAdmin(socket, next) {
     const namespaceName = socket.nsp.name;
 

+ 2 - 3
apps/app/src/server/service/user-notification/index.ts

@@ -31,7 +31,7 @@ export class UserNotificationService {
    * @param {Comment} comment
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  async fire(page, user, slackChannelsStr, mode, option, comment = {}): Promise<PromiseSettledResult<any>[]> {
+  async fire(page, user, slackChannelsStr, mode, option?: { previousRevision: string }, comment = {}): Promise<PromiseSettledResult<any>[]> {
     const {
       appService, slackIntegrationService,
     } = this.crowi;
@@ -43,8 +43,7 @@ export class UserNotificationService {
     // update slackChannels attribute asynchronously
     page.updateSlackChannels(slackChannelsStr);
 
-    const opt = option || {};
-    const previousRevision = opt.previousRevision || '';
+    const { previousRevision } = option ?? {};
 
     // "dev,slacktest" => [dev,slacktest]
     const slackChannels: (string|null)[] = toArrayFromCsv(slackChannelsStr);

+ 73 - 0
apps/app/src/server/service/yjs-connection-manager.ts

@@ -0,0 +1,73 @@
+import type { Server } from 'socket.io';
+import { MongodbPersistence } from 'y-mongodb-provider';
+import { YSocketIO } from 'y-socket.io/dist/server';
+import * as Y from 'yjs';
+
+import { getMongoUri } from '../util/mongoose-utils';
+
+export const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
+export const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
+
+class YjsConnectionManager {
+
+  private ysocketio: YSocketIO;
+
+  private mdb: MongodbPersistence;
+
+  constructor(io: Server) {
+    this.ysocketio = new YSocketIO(io);
+    this.ysocketio.initialize();
+
+    this.mdb = new MongodbPersistence(getMongoUri(), {
+      collectionName: MONGODB_PERSISTENCE_COLLECTION_NAME,
+      flushSize: MONGODB_PERSISTENCE_FLUSH_SIZE,
+    });
+
+    this.getCurrentYdoc = this.getCurrentYdoc.bind(this);
+  }
+
+  public async handleYDocSync(pageId: string, initialValue: string): Promise<void> {
+    const persistedYdoc = await this.mdb.getYDoc(pageId);
+    const persistedStateVector = Y.encodeStateVector(persistedYdoc);
+
+    await this.mdb.flushDocument(pageId);
+
+    const currentYdoc = this.getCurrentYdoc(pageId);
+
+    const persistedCodeMirrorText = persistedYdoc.getText('codemirror').toString();
+    const currentCodeMirrorText = currentYdoc.getText('codemirror').toString();
+
+    if (persistedCodeMirrorText === '' && currentCodeMirrorText === '') {
+      currentYdoc.getText('codemirror').insert(0, initialValue);
+    }
+
+    const diff = Y.encodeStateAsUpdate(currentYdoc, persistedStateVector);
+
+    if (diff.reduce((prev, curr) => prev + curr, 0) > 0) {
+      this.mdb.storeUpdate(pageId, diff);
+    }
+
+    Y.applyUpdate(currentYdoc, Y.encodeStateAsUpdate(persistedYdoc));
+
+    currentYdoc.on('update', async(update) => {
+      await this.mdb.storeUpdate(pageId, update);
+    });
+
+    currentYdoc.on('destroy', async() => {
+      await this.mdb.flushDocument(pageId);
+    });
+
+    persistedYdoc.destroy();
+  }
+
+  private getCurrentYdoc(pageId: string): Y.Doc {
+    const currentYdoc = this.ysocketio.documents.get(`yjs/${pageId}`);
+    if (currentYdoc == null) {
+      throw new Error(`currentYdoc for pageId ${pageId} is undefined.`);
+    }
+    return currentYdoc;
+  }
+
+}
+
+export default YjsConnectionManager;

+ 0 - 1
apps/app/src/stores/context.tsx

@@ -124,7 +124,6 @@ export const useAuditLogEnabled = (initialData?: boolean): SWRResponse<boolean,
   return useContextSWR<boolean, Error>('auditLogEnabled', initialData, { fallbackData: false });
 };
 
-// TODO: initialize in [[..path]].page.tsx?
 export const useActivityExpirationSeconds = (initialData?: number) : SWRResponse<number, Error> => {
   return useContextSWR<number, Error>('activityExpirationSeconds', initialData);
 };

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