Browse Source

Merge branch 'master' into imprv/page-v5-test-code-revert

yohei0125 4 years ago
parent
commit
96912a0dca
73 changed files with 1794 additions and 286 deletions
  1. 10 1
      .devcontainer/docker-compose.yml
  2. 7 1
      CHANGELOG.md
  3. 3 0
      packages/app/.env.development
  4. 5 0
      packages/app/docker/README.md
  5. 1 1
      packages/app/package.json
  6. 4 4
      packages/app/resource/locales/en_US/sandbox.md
  7. 18 0
      packages/app/resource/locales/en_US/translation.json
  8. 3 3
      packages/app/resource/locales/ja_JP/sandbox.md
  9. 19 1
      packages/app/resource/locales/ja_JP/translation.json
  10. 4 4
      packages/app/resource/locales/zh_CN/sandbox.md
  11. 17 1
      packages/app/resource/locales/zh_CN/translation.json
  12. 3 0
      packages/app/src/client/app.jsx
  13. 2 1
      packages/app/src/client/services/ContextExtractor.tsx
  14. 28 9
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  15. 99 0
      packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx
  16. 1 1
      packages/app/src/components/LoginForm.jsx
  17. 5 4
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  18. 16 5
      packages/app/src/components/Navbar/SubNavButtons.tsx
  19. 31 4
      packages/app/src/components/Page/TrashPageAlert.jsx
  20. 7 2
      packages/app/src/components/PageDeleteModal.tsx
  21. 5 2
      packages/app/src/components/PageList/PageListItemL.tsx
  22. 303 0
      packages/app/src/components/PrivateLegacyPages.tsx
  23. 10 29
      packages/app/src/components/SearchPage.tsx
  24. 24 17
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  25. 4 0
      packages/app/src/components/SearchPage/SearchResultList.tsx
  26. 42 13
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  27. 2 1
      packages/app/src/components/Sidebar.tsx
  28. 2 2
      packages/app/src/components/Sidebar/PageTree.tsx
  29. 17 13
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  30. 23 26
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  31. 2 4
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  32. 3 2
      packages/app/src/components/User/SeenUserInfo.tsx
  33. 1 16
      packages/app/src/server/middlewares/http-error-handler.js
  34. 16 0
      packages/app/src/server/models/obsolete-page.js
  35. 9 2
      packages/app/src/server/routes/apiv3/forgot-password.js
  36. 1 3
      packages/app/src/server/routes/apiv3/page.js
  37. 7 3
      packages/app/src/server/routes/apiv3/pages.js
  38. 32 2
      packages/app/src/server/routes/forgot-password.ts
  39. 6 2
      packages/app/src/server/routes/index.js
  40. 148 0
      packages/app/src/server/routes/ogp.ts
  41. 1 1
      packages/app/src/server/routes/page.js
  42. 6 0
      packages/app/src/server/service/config-loader.ts
  43. 6 6
      packages/app/src/server/service/installer.ts
  44. 32 31
      packages/app/src/server/service/page.ts
  45. 2 2
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  46. 1 3
      packages/app/src/server/service/search-delegator/private-legacy-pages.ts
  47. 14 0
      packages/app/src/server/util/stream.ts
  48. 6 0
      packages/app/src/server/views/forgot-password/error.html
  49. 10 0
      packages/app/src/server/views/layout-growi/page.html
  50. 8 1
      packages/app/src/server/views/private-legacy-pages.html
  51. 3 4
      packages/app/src/server/views/search.html
  52. 4 0
      packages/app/src/stores/context.tsx
  53. 40 0
      packages/app/src/stores/modal.tsx
  54. 13 0
      packages/app/src/stores/search.tsx
  55. 1 1
      packages/app/src/stores/ui.tsx
  56. 0 1
      packages/app/src/styles/_override-bootstrap.scss
  57. 1 2
      packages/app/test/cypress/integration/2-basic-features/access-to-admin-page.spec.ts
  58. 2 3
      packages/app/test/cypress/integration/2-basic-features/access-to-me-page.spec.ts
  59. 6 0
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  60. 30 0
      packages/app/test/cypress/integration/3-search/access-to-private-legacy-pages-directly.spec.ts
  61. 2 2
      packages/app/test/cypress/integration/3-search/access-to-result-page-directly.spec.ts
  62. 10 0
      packages/app/test/cypress/support/commands.ts
  63. 1 0
      packages/app/test/cypress/support/index.ts
  64. 98 0
      packages/app/test/integration/models/v5.page.test.js
  65. 0 10
      packages/app/test/integration/service/page-grant.test.js
  66. 1 6
      packages/app/test/integration/service/v5.migration.test.js
  67. 526 20
      packages/app/test/integration/service/v5.page.test.ts
  68. 5 0
      packages/core/src/test/util/page-path-utils.test.js
  69. 1 0
      packages/core/src/utils/page-path-utils.ts
  70. 1 1
      packages/plugin-attachment-refs/package.json
  71. 1 1
      packages/slack/package.json
  72. 1 1
      packages/slackbot-proxy/package.json
  73. 21 11
      yarn.lock

+ 10 - 1
.devcontainer/docker-compose.yml

@@ -34,12 +34,21 @@ services:
     volumes:
       - /data/db
 
+  ogp:
+    image: ghcr.io/weseek/growi-unique-ogp:latest
+    ports:
+      - 8088:8088
+    restart: unless-stopped
+    tty: true
+
   # This container requires '../../growi-docker-compose' repository
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   elasticsearch:
     build:
       context: ../../growi-docker-compose/elasticsearch
       dockerfile: ./Dockerfile
+      args:
+        - version=6.8.22
     container_name: elasticsearch
     restart: unless-stopped
     ports:
@@ -57,7 +66,7 @@ services:
 
   #need to adjust kibana version based on elasticsearch version
   kibana:
-    image: docker.elastic.co/kibana/kibana:6.8.0
+    image: docker.elastic.co/kibana/kibana:6.8.22
     restart: unless-stopped
     environment:
       ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'

+ 7 - 1
CHANGELOG.md

@@ -1,9 +1,15 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.13...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.14...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.14](https://github.com/weseek/growi/compare/v4.5.13...v4.5.14) - 2022-02-10
+
+### 💎 Features
+
+- feat: OGP in public wiki (#5304) @yuto-oweseek
+
 ## [v4.5.13](https://github.com/weseek/growi/compare/v4.5.12...v4.5.13) - 2022-02-08
 
 ### 🐛 Bug Fixes

+ 3 - 0
packages/app/.env.development

@@ -14,8 +14,11 @@ MONGO_URI="mongodb://mongo:27017/growi"
 # NCHAN_URI="http://nchan"
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
 ELASTICSEARCH_REQUEST_TIMEOUT=15000
+#ELASTICSEARCH_REJECT_UNAUTHORIZED=false
+#USE_ELASTICSEARCH_V6=true
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
+OGP_URI="http://ogp:8088"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"
 # S2SMSG_PUBSUB_SERVER_TYPE=nchan
 # PUBLISH_OPEN_API=true

+ 5 - 0
packages/app/docker/README.md

@@ -10,10 +10,15 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
+<<<<<<< HEAD
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`4.5.13`, `4.5`, `4`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.13/docker/Dockerfile)
 * [`4.5.13-nocdn`, `4.5-nocdn`, `4-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.13/docker/Dockerfile)
+=======
+* [`4.5.14`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
+* [`4.5.14-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
+>>>>>>> origin/release/current
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 1 - 1
packages/app/package.json

@@ -107,7 +107,7 @@
     "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
     "helmet": "^4.6.0",
-    "http-errors": "~1.8.0",
+    "http-errors": "^2.0.0",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",
     "i18next-node-fs-backend": "^2.1.3",

+ 4 - 4
packages/app/resource/locales/en_US/sandbox.md

@@ -237,10 +237,10 @@ You can create links using `[Display text](URL)`.
 
 ```
 [/Sandbox]
-&lt;/user/admin1>
+</user/admin1>
 ```
 
-[/Sandbox]
+[/Sandbox]  
 </user/admin1>
 
 ## Pukiwiki like linker
@@ -253,10 +253,10 @@ Both the page description and link address can be displayed on the page.
 
 ```
 [[./Bootstrap4]]
-Example of Bootstrap4 is[[here>./Bootstrap4]]
+Example of Bootstrap4 is [[here>./Bootstrap4]]
 ```
 
-[[../user]]
+[[../Bootstrap4]]  
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 
 # :pencil: Lists

+ 18 - 0
packages/app/resource/locales/en_US/translation.json

@@ -441,6 +441,8 @@
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
   },
+  "deleted_pages": "Page(s) has been deleted",
+  "deleted_pages_completely": "Page(s) has been deleted completely",
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
     "notice": "The pages deleted completely are unrecoverable."
@@ -636,6 +638,22 @@
       "updatedAt": "Last update date"
     }
   },
+  "private_legacy_pages": {
+    "bulk_operation": "Bulk operation",
+    "convert_all_selected_pages": "Convert all to new v5 compatible format",
+    "alert_title": "You are viewing old v4 compatible private pages.",
+    "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
+    "nopages_title": "Congratulations. Ready to use GROWI v5!",
+    "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
+    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <i class='icon-share-alt'></i></a>.",
+    "modal": {
+      "title": "Convert to new v5 compatible format",
+      "converting_pages": "Converting pages",
+      "convert_recursively_label": "Convert child pages recursively.",
+      "convert_recursively_desc": "Convert pages under this path recursively.",
+      "button_label": "Convert"
+    }
+  },
   "security_setting": {
     "Guest Users Access": "Guest users access",
     "Fixed by env var": "This is fixed by the env var <code>%s=%s</code>.",

+ 3 - 3
packages/app/resource/locales/ja_JP/sandbox.md

@@ -236,10 +236,10 @@ ___
 
 ```
 [/Sandbox]
-&lt;/user/admin1>
+</user/admin1>
 ```
 
-[/Sandbox]
+[/Sandbox]  
 </user/admin1>
 
 ## Pukiwiki like linker
@@ -255,7 +255,7 @@ ___
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 ```
 
-[[../user]]
+[[../Bootstrap4]]  
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 
 # :pencil: Lists

+ 19 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -440,6 +440,8 @@
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
+  "deleted_pages": "ページをゴミ箱に入れました",
+  "deleted_pages_completely": "ページを完全に削除しました",
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
     "notice": "完全削除したページは元に戻すことができません"
@@ -635,6 +637,22 @@
       "updatedAt": "更新日時"
     }
   },
+  "private_legacy_pages": {
+    "bulk_operation": "一括操作",
+    "convert_all_selected_pages": "新しい v5 互換形式に一括変換",
+    "alert_title": "古い v4 互換形式のプライベートページを表示しています",
+    "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
+    "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
+    "nopages_desc1": "今あなたが管理可能なページはすべて v5 互換形式になっているようです。",
+    "detail_info": "詳しくは <a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>GROWI v5.0.x へのアップグレード  <i class='icon-share-alt'></i></a> を参照ください。",
+    "modal": {
+      "title": "新しい v5 互換形式への変換",
+      "converting_pages": "以下のページを変換します",
+      "convert_recursively_label": "再起的に変換",
+      "convert_recursively_desc": "このページの配下のページを再起的に変換します",
+      "button_label": "変換"
+    }
+  },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
@@ -970,7 +988,7 @@
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   },
   "pagetree": {
-    "private_legacy_pages": "待避所",
+    "private_legacy_pages": "旧形式のプライベートページ",
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
     "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
     "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました"

+ 4 - 4
packages/app/resource/locales/zh_CN/sandbox.md

@@ -237,10 +237,10 @@ You can create links using `[Display text](URL)`.
 
 ```
 [/Sandbox]
-&lt;/user/admin1>
+</user/admin1>
 ```
 
-[/Sandbox]
+[/Sandbox]  
 </user/admin1>
 
 ## Pukiwiki like linker
@@ -256,8 +256,8 @@ Both the page description and link address can be displayed on the page.
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 ```
 
-[[../user]]
-Example of Bootstrap4 is[[here>./Bootstrap4]]
+[[../Bootstrap4]]  
+Example of Bootstrap4 is [[here>./Bootstrap4]]
 
 # :pencil: Lists
 

+ 17 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -418,7 +418,7 @@
 		"delete_completely_restriction": "You don't have the authority to delete pages completely.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"completely": "Delete completely instead of putting it into trash."
-	},
+  },
 	"modal_empty": {
 		"empty_the_trash": "Empty The Trash",
 		"notice": "完全删除的页面是不可恢复的。"
@@ -913,6 +913,22 @@
       "updatedAt": "按更新日期排序"
     }
 	},
+  "private_legacy_pages": {
+    "bulk_operation": "Bulk operation",
+    "convert_all_selected_pages": "Convert all to new v5 compatible format",
+    "alert_title": "You are viewing old v4 compatible private pages.",
+    "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
+    "nopages_title": "Congratulations. Ready to use GROWI v5!",
+    "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
+    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <i class='icon-share-alt'></i></a>.",
+    "modal": {
+      "title": "Convert to new v5 compatible format",
+      "converting_pages": "Converting pages",
+      "convert_recursively_label": "Convert child pages recursively.",
+      "convert_recursively_desc": "Convert pages under this path recursively.",
+      "button_label": "Convert"
+    }
+  },
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {
 		"Sign in error": "登录错误",

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

@@ -54,6 +54,7 @@ import PersonalContainer from '~/client/services/PersonalContainer';
 
 import { appContainer, componentMappings } from './base';
 import { toastError } from './util/apiNotification';
+import { PrivateLegacyPages } from '~/components/PrivateLegacyPages';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -86,6 +87,8 @@ Object.assign(componentMappings, {
   'grw-sidebar-wrapper': <Sidebar />,
 
   'search-page': <SearchPage appContainer={appContainer} />,
+  'private-regacy-pages': <PrivateLegacyPages appContainer={appContainer} />,
+
   'all-in-app-notifications': <InAppNotificationPage />,
   'identical-path-page': <IdenticalPathPage />,
 

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

@@ -7,7 +7,7 @@ import {
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
 } from '../../stores/context';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
@@ -101,6 +101,7 @@ const ContextExtractorOnce: FC = () => {
   useIsAclEnabled(configByContextHydrate.isAclEnabled);
   useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
   useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);
+  useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
 
 
   // Page

+ 28 - 9
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -3,7 +3,6 @@ import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 
-import toastr from 'toastr';
 import { useTranslation } from 'react-i18next';
 
 import loggerFactory from '~/utils/logger';
@@ -16,12 +15,23 @@ import { useSWRxPageInfo } from '~/stores/page';
 const logger = loggerFactory('growi:cli:PageItemControl');
 
 
+export const MenuItemType = {
+  BOOKMARK: 'bookmark',
+  DUPLICATE: 'duplicate',
+  RENAME: 'rename',
+  DELETE: 'delete',
+} as const;
+export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
+
+export type ForceHideMenuItems = MenuItemType[];
+
 export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
 
 type CommonProps = {
   pageInfo?: IPageInfoAll,
   isEnableActions?: boolean,
-  showBookmarkMenuItem?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
+
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
@@ -41,7 +51,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   const {
     pageId, isLoading,
-    pageInfo, isEnableActions, showBookmarkMenuItem,
+    pageInfo, isEnableActions, forceHideMenuItems,
     onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
     additionalMenuItemRenderer: AdditionalMenuItems,
   } = props;
@@ -93,6 +103,10 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     );
   }
   else if (pageId != null && pageInfo != null) {
+
+    const showDeviderBeforeAdditionalMenuItems = (forceHideMenuItems?.length ?? 0) < 3;
+    const showDeviderBeforeDelete = AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
+
     contents = (
       <>
         { !isEnableActions && (
@@ -104,7 +118,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Bookmark */}
-        { showBookmarkMenuItem && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
           <DropdownItem onClick={bookmarkItemClickedHandler}>
             <i className="fa fa-fw fa-bookmark-o"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
@@ -112,7 +126,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Duplicate */}
-        { isEnableActions && (
+        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
           <DropdownItem onClick={duplicateItemClickedHandler}>
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
@@ -120,20 +134,25 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Move/Rename */}
-        { isEnableActions && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
           <DropdownItem onClick={renameItemClickedHandler}>
             <i className="icon-fw  icon-action-redo"></i>
             {t('Move/Rename')}
           </DropdownItem>
         ) }
 
-        { AdditionalMenuItems && <AdditionalMenuItems pageInfo={pageInfo} /> }
+        { AdditionalMenuItems && (
+          <>
+            { showDeviderBeforeAdditionalMenuItems && <DropdownItem divider /> }
+            <AdditionalMenuItems pageInfo={pageInfo} />
+          </>
+        ) }
 
         {/* divider */}
         {/* Delete */}
-        { isEnableActions && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && pageInfo.isMovable && (
           <>
-            <DropdownItem divider />
+            { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
               className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
               disabled={!pageInfo.isDeletable}

+ 99 - 0
packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx

@@ -0,0 +1,99 @@
+import React, { useState } from 'react';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { useLegacyPrivatePagesMigrationModal } from '~/stores/modal';
+
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+
+
+type Props = {
+
+}
+
+export const LegacyPrivatePagesMigrationModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: status, close } = useLegacyPrivatePagesMigrationModal();
+
+  const isOpened = status?.isOpened ?? false;
+
+  const [isRecursively, setIsRecursively] = useState(true);
+
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const [errs, setErrs] = useState<Error[] | null>(null);
+
+  async function submit() {
+    if (status == null || status.pages == null || status.pages.length === 0) {
+      return;
+    }
+
+    const { pages, onSubmited } = status;
+    const pageIds = pages.map(page => page.pageId);
+    try {
+      await apiv3Post<void>('/pages/legacy-pages-migration', {
+        pageIds,
+        isRecursively: isRecursively ? true : undefined,
+      });
+
+      if (onSubmited != null) {
+        onSubmited(pages, isRecursively);
+      }
+    }
+    catch (err) {
+      setErrs([err]);
+    }
+  }
+
+  function renderForm() {
+    return (
+      <div className="custom-control custom-checkbox custom-checkbox-warning">
+        <input
+          className="custom-control-input"
+          id="convertRecursively"
+          type="checkbox"
+          onChange={e => setIsRecursively(e.target.checked)}
+        />
+        <label className="custom-control-label" htmlFor="convertRecursively">
+          { t('private_legacy_pages.modal.convert_recursively_label') }
+          <p className="form-text text-muted mt-0"> { t('private_legacy_pages.modal.convert_recursively_desc') }</p>
+        </label>
+      </div>
+    );
+  }
+
+  const renderPageIds = () => {
+    if (status != null && status.pages != null) {
+      return status.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+    }
+    return <></>;
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={close} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        { t('private_legacy_pages.modal.title') }
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group grw-scrollable-modal-body pb-1">
+          <label>{ t('private_legacy_pages.modal.converting_pages') }:</label><br />
+          {/* Todo: change the way to show path on modal when too many pages are selected */}
+          {/* https://redmine.weseek.co.jp/issues/82787 */}
+          {renderPageIds()}
+        </div>
+        {renderForm()}
+      </ModalBody>
+      <ModalFooter>
+        <ApiErrorMessageList errs={errs} />
+        <button type="button" className="btn btn-primary" onClick={submit}>
+          <i className="icon-fw icon-refresh" aria-hidden="true"></i>
+          { t('private_legacy_pages.modal.button_label') }
+        </button>
+      </ModalFooter>
+    </Modal>
+
+  );
+};

+ 1 - 1
packages/app/src/components/LoginForm.jsx

@@ -297,7 +297,7 @@ class LoginForm extends React.Component {
               <div className="front">
                 {isLocalOrLdapStrategiesEnabled && this.renderLocalOrLdapLoginForm()}
                 {isSomeExternalAuthEnabled && this.renderExternalAuthLoginForm()}
-                {isPasswordResetEnabled && (
+                {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
                   <div className="text-right mb-2">
                     <a href="/forgot-password" className="d-block link-switch">
                       <i className="icon-key"></i> {t('forgot_password.forgot_password')}

+ 5 - 4
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -123,8 +123,6 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
 
 const GrowiContextualSubNavigation = (props) => {
-  const { t } = useTranslation();
-
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDrawerMode } = useDrawerMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
@@ -222,9 +220,11 @@ const GrowiContextualSubNavigation = (props) => {
       mutateEditorMode(viewType);
     }
 
+    const className = `d-flex flex-column align-items-end justify-content-center ${isViewMode ? ' h-50' : ''}`;
+
     return (
       <>
-        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+        <div className={className}>
           { pageId != null && isViewMode && (
             <SubNavButtons
               isCompactMode={isCompactMode}
@@ -249,7 +249,7 @@ const GrowiContextualSubNavigation = (props) => {
             />
           ) }
         </div>
-        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+        <div className={className}>
           {isAbleToShowPageEditorModeManager && (
             <PageEditorModeManager
               onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
@@ -304,6 +304,7 @@ const GrowiContextualSubNavigation = (props) => {
       tags={tagsInfoData?.tags || []}
       tagsUpdatedHandler={tagsUpdatedHandler}
       controls={ControlComponents}
+      additionalClasses={['container-fluid']}
     />
   );
 };

+ 16 - 5
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -13,20 +13,23 @@ import LikeButtons from '../LikeButtons';
 import BookmarkButtons from '../BookmarkButtons';
 import SeenUserInfo from '../User/SeenUserInfo';
 import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
-import { AdditionalMenuItemsRendererProps, PageItemControl } from '../Common/Dropdown/PageItemControl';
+import {
+  AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType, PageItemControl,
+} from '../Common/Dropdown/PageItemControl';
 
 
 type CommonProps = {
   isCompactMode?: boolean,
   disableSeenUserInfoPopover?: boolean,
   showPageControlDropdown?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
   onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
   onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean) => void,
 }
 
-type SubNavButtonsSubstanceProps= CommonProps & {
+type SubNavButtonsSubstanceProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
   revisionId: string,
@@ -38,7 +41,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   const {
     pageInfo,
     pageId, revisionId, path, shareLinkId,
-    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
+    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
     onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
   } = props;
 
@@ -129,9 +132,12 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
 
   const {
-    sumOfLikers, isLiked, bookmarkCount, isBookmarked,
+    sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
   } = pageInfo;
 
+  const forceHideMenuItemsWithBookmark = forceHideMenuItems ?? [];
+  forceHideMenuItemsWithBookmark.push(MenuItemType.BOOKMARK);
+
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
       <span>
@@ -155,13 +161,18 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
         onBookMarkClicked={bookmarkClickHandler}
       />
       { !isCompactMode && (
-        <SeenUserInfo seenUsers={seenUsers} disabled={disableSeenUserInfoPopover} />
+        <SeenUserInfo
+          seenUsers={seenUsers}
+          sumOfSeenUsers={sumOfSeenUsers}
+          disabled={disableSeenUserInfoPopover}
+        />
       ) }
       { showPageControlDropdown && (
         <PageItemControl
           pageId={pageId}
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
+          forceHideMenuItems={forceHideMenuItemsWithBookmark}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}

+ 31 - 4
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -10,17 +10,34 @@ import PageContainer from '~/client/services/PageContainer';
 import PutbackPageModal from '../PutbackPageModal';
 import EmptyTrashModal from '../EmptyTrashModal';
 
-import { useCurrentUpdatedAt } from '~/stores/context';
+import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
+import { useSWRxPageInfo } from '~/stores/page';
 
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const {
-    pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt, isAbleToDeleteCompletely,
+    pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt,
   } = pageContainer.state;
+  const { data: shareLinkId } = useShareLinkId();
+
+  /*
+  * TODO: Do not use useSWRxPageInfo on this component
+  * Ideal: use useSWRxPageInfo on TrashPage after applying Next.js
+  * Reference: https://github.com/weseek/growi/pull/5359#discussion_r808381329
+  */
+  const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+
   const { data: updatedAt } = useCurrentUpdatedAt();
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
+  const [isAbleToDeleteCompletely, setIsAbleToDeleteCompletely] = useState(false);
+
+  useEffect(() => {
+    if (pageInfo != null) {
+      setIsAbleToDeleteCompletely(pageInfo.isAbleToDeleteCompletely);
+    }
+  }, [pageInfo]);
 
   const { open: openDeleteModal } = usePageDeleteModal();
 
@@ -40,13 +57,23 @@ const TrashPageAlert = (props) => {
     setIsPutbackPageModalShown(false);
   }
 
+  const onDeletedHandler = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
+    if (typeof pathOrPathsToDelete !== 'string') {
+      return;
+    }
+
+    const path = pathOrPathsToDelete;
+    window.location.href = path;
+  }, []);
+
   function openPageDeleteModalHandler() {
     const pageToDelete = {
       pageId,
       revisionId,
       path,
     };
-    openDeleteModal([pageToDelete]);
+    const isDeleteCompletelyModal = true;
+    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely, isDeleteCompletelyModal);
   }
 
   function renderEmptyButton() {

+ 7 - 2
packages/app/src/components/PageDeleteModal.tsx

@@ -1,4 +1,4 @@
-import React, { useState, FC } from 'react';
+import React, { useState, useEffect, FC } from 'react';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
@@ -36,7 +36,7 @@ const PageDeleteModal: FC = () => {
   const isDeleteCompletelyModal = deleteModalData?.isDeleteCompletelyModal ?? false;
 
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
-  const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
+  const [isDeleteCompletely, setIsDeleteCompletely] = useState(false);
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -46,6 +46,10 @@ const PageDeleteModal: FC = () => {
     setIsDeleteRecursively(!isDeleteRecursively);
   }
 
+  useEffect(() => {
+    setIsDeleteCompletely(isDeleteCompletelyModal && isAbleToDeleteCompletely);
+  }, [isAbleToDeleteCompletely, isDeleteCompletelyModal]);
+
   function changeIsDeleteCompletelyHandler() {
     if (!isAbleToDeleteCompletely) {
       return;
@@ -128,6 +132,7 @@ const PageDeleteModal: FC = () => {
         />
         <label className="custom-control-label" htmlFor="deleteRecursively">
           { t('modal_delete.delete_recursively') }
+          <p className="form-text text-muted mt-0"> { t('modal_delete.recursively') }</p>
         </label>
       </div>
     );

+ 5 - 2
packages/app/src/components/PageList/PageListItemL.tsx

@@ -18,7 +18,7 @@ import {
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 
-import { PageItemControl } from '../Common/Dropdown/PageItemControl';
+import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import LinkedPagePath from '~/models/linked-page-path';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 import { ISelectable } from '~/client/interfaces/selectable-all';
@@ -27,6 +27,7 @@ type Props = {
   page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isEnableActions?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onClickItem?: (pageId: string) => void,
@@ -36,6 +37,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const {
     // todo: refactoring variable name to clear what changed
     page: { pageData, pageMeta }, isSelected, isEnableActions,
+    forceHideMenuItems,
     showPageUpdatedTime,
     onClickItem, onCheckboxChanged,
   } = props;
@@ -161,9 +163,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 <PageItemControl
                   pageId={pageData._id}
                   pageInfo={pageMeta}
+                  isEnableActions={isEnableActions}
+                  forceHideMenuItems={forceHideMenuItems}
                   onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
-                  isEnableActions={isEnableActions}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
                 />
               </div>

+ 303 - 0
packages/app/src/components/PrivateLegacyPages.tsx

@@ -0,0 +1,303 @@
+import React, {
+  useCallback, useMemo, useRef, useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+  UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+import { IFormattedSearchResult } from '~/interfaces/search';
+import AppContainer from '~/client/services/AppContainer';
+import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import { toastSuccess } from '~/client/util/apiNotification';
+import {
+  ISearchConfigurations, useSWRxNamedQuerySearch,
+} from '~/stores/search';
+import { ILegacyPrivatePage, useLegacyPrivatePagesMigrationModal } from '~/stores/modal';
+
+import PaginationWrapper from './PaginationWrapper';
+import { OperateAllControl } from './SearchPage/OperateAllControl';
+
+import { IReturnSelectedPageIds, SearchPageBase } from './SearchPage2/SearchPageBase';
+import { MenuItemType } from './Common/Dropdown/PageItemControl';
+import { LegacyPrivatePagesMigrationModal } from './LegacyPrivatePagesMigrationModal';
+
+
+// TODO: replace with "customize:showPageLimitationS"
+const INITIAL_PAGIONG_SIZE = 20;
+
+
+/**
+ * SearchResultListHead
+ */
+
+type SearchResultListHeadProps = {
+  searchResult: IFormattedSearchResult,
+  offset: number,
+  pagingSize: number,
+  onPagingSizeChanged: (size: number) => void,
+}
+
+const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    searchResult, offset, pagingSize,
+    onPagingSizeChanged,
+  } = props;
+
+  const { took, total, hitsCount } = searchResult.meta;
+  const leftNum = offset + 1;
+  const rightNum = offset + hitsCount;
+
+  if (total === 0) {
+    return (
+      <div className="card border-success mt-3">
+        <div className="card-body">
+          <h2 className="card-title text-success">{t('private_legacy_pages.nopages_title')}</h2>
+          <p className="card-text">
+            {t('private_legacy_pages.nopages_desc1')}<br />
+            {/* eslint-disable-next-line react/no-danger */}
+            <span dangerouslySetInnerHTML={{ __html: t('private_legacy_pages.detail_info') }}></span>
+          </p>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <>
+      <div className="form-inline d-flex align-items-center justify-content-between">
+        <div className="text-nowrap">
+          {t('search_result.result_meta')}
+          <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
+          { took != null && (
+            <span className="ml-3 text-muted">({took}ms)</span>
+          ) }
+        </div>
+        <div className="input-group flex-nowrap search-result-select-group ml-auto d-md-flex d-none">
+          <div className="input-group-prepend">
+            <label className="input-group-text text-muted" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+          </div>
+          <select
+            defaultValue={pagingSize}
+            className="custom-select"
+            id="inputGroupSelect01"
+            onChange={e => onPagingSizeChanged(Number(e.target.value))}
+          >
+            {[20, 50, 100, 200].map((limit) => {
+              return <option key={limit} value={limit}>{limit} {t('search_result.page_number_unit')}</option>;
+            })}
+          </select>
+        </div>
+      </div>
+      <div className="card border-warning mt-3">
+        <div className="card-body">
+          <h2 className="card-title text-warning">{t('private_legacy_pages.alert_title')}</h2>
+          <p className="card-text">
+            {t('private_legacy_pages.alert_desc1', { delete_all_selected_page: t('search_result.delete_all_selected_page') })}<br />
+            {/* eslint-disable-next-line react/no-danger */}
+            <span dangerouslySetInnerHTML={{ __html: t('private_legacy_pages.detail_info') }}></span>
+          </p>
+        </div>
+      </div>
+    </>
+  );
+});
+
+
+/**
+ * LegacyPage
+ */
+
+type Props = {
+  appContainer: AppContainer,
+}
+
+export const PrivateLegacyPages = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    appContainer,
+  } = props;
+
+
+  const [configurationsByPagination, setConfigurationsByPagination] = useState<Partial<ISearchConfigurations>>({
+    limit: INITIAL_PAGIONG_SIZE,
+  });
+  const [isControlEnabled, setControlEnabled] = useState(false);
+
+  const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
+  const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
+
+  const { data, conditions, mutate } = useSWRxNamedQuerySearch('PrivateLegacyPages', {
+    limit: INITIAL_PAGIONG_SIZE,
+    ...configurationsByPagination,
+  });
+
+  const { open: openModal, close: closeModal } = useLegacyPrivatePagesMigrationModal();
+
+  const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
+    const instance = searchPageBaseRef.current;
+
+    if (instance == null) {
+      return;
+    }
+
+    if (isChecked) {
+      instance.selectAll();
+      setControlEnabled(true);
+    }
+    else {
+      instance.deselectAll();
+      setControlEnabled(false);
+    }
+  }, []);
+
+  const selectedPagesByCheckboxesChangedHandler = useCallback((selectedCount: number, totalCount: number) => {
+    const instance = selectAllControlRef.current;
+
+    if (instance == null) {
+      return;
+    }
+
+    if (selectedCount === 0) {
+      instance.deselect();
+      setControlEnabled(false);
+    }
+    else if (selectedCount === totalCount) {
+      instance.select();
+      setControlEnabled(true);
+    }
+    else {
+      instance.setIndeterminate();
+      setControlEnabled(true);
+    }
+  }, []);
+
+  const convertMenuItemClickedHandler = useCallback(() => {
+    if (data == null) {
+      return;
+    }
+
+    const instance = searchPageBaseRef.current;
+    if (instance == null || instance.getSelectedPageIds == null) {
+      return;
+    }
+
+    const selectedPageIds = instance.getSelectedPageIds();
+
+    if (selectedPageIds.size === 0) {
+      return;
+    }
+
+    const selectedPages = data.data
+      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.pageData._id))
+      .map(pageWithMeta => ({ pageId: pageWithMeta.pageData._id, path: pageWithMeta.pageData.path } as ILegacyPrivatePage));
+
+    openModal(
+      selectedPages,
+      () => {
+        toastSuccess('success');
+        closeModal();
+        mutate();
+      },
+    );
+  }, [data, mutate, openModal, closeModal]);
+
+  const pagingNumberChangedHandler = useCallback((activePage: number) => {
+    const currentLimit = configurationsByPagination.limit ?? INITIAL_PAGIONG_SIZE;
+    setConfigurationsByPagination({
+      ...configurationsByPagination,
+      offset: (activePage - 1) * currentLimit,
+    });
+  }, [configurationsByPagination]);
+
+  const { offset, limit } = conditions;
+
+  const searchControl = useMemo(() => {
+    return (
+      <div className="shadow-sm">
+        <div className="search-control d-flex align-items-center py-md-2 py-3 px-md-4 px-3 border-bottom border-gray">
+          <div className="d-flex pl-md-2">
+            <OperateAllControl
+              ref={selectAllControlRef}
+              isCheckboxDisabled={!isControlEnabled}
+              onCheckboxChanged={selectAllCheckboxChangedHandler}
+            >
+              <UncontrolledButtonDropdown>
+                <DropdownToggle caret color="outline-primary" disabled={!isControlEnabled}>
+                  {t('private_legacy_pages.bulk_operation')}
+                </DropdownToggle>
+                <DropdownMenu>
+                  <DropdownItem onClick={convertMenuItemClickedHandler}>
+                    <i className="icon-fw icon-refresh"></i>
+                    {t('private_legacy_pages.convert_all_selected_pages')}
+                  </DropdownItem>
+                  <DropdownItem onClick={() => { /* TODO: implement */ }}>
+                    <span className="text-danger">
+                      <i className="icon-fw icon-trash"></i>
+                      {t('search_result.delete_all_selected_page')}
+                    </span>
+                  </DropdownItem>
+                </DropdownMenu>
+              </UncontrolledButtonDropdown>
+            </OperateAllControl>
+          </div>
+        </div>
+      </div>
+    );
+  }, [convertMenuItemClickedHandler, isControlEnabled, selectAllCheckboxChangedHandler, t]);
+
+  const searchResultListHead = useMemo(() => {
+    if (data == null) {
+      return <></>;
+    }
+    return (
+      <SearchResultListHead
+        searchResult={data}
+        offset={offset}
+        pagingSize={limit}
+        onPagingSizeChanged={() => {}}
+      />
+    );
+  }, [data, limit, offset]);
+
+  const searchPager = useMemo(() => {
+    // when pager is not needed
+    if (data == null || data.meta.hitsCount === data.meta.total) {
+      return <></>;
+    }
+
+    const { total } = data.meta;
+    const { offset, limit } = conditions;
+
+    return (
+      <PaginationWrapper
+        activePage={Math.floor(offset / limit) + 1}
+        totalItemsCount={total}
+        pagingLimit={configurationsByPagination?.limit}
+        changePage={pagingNumberChangedHandler}
+      />
+    );
+  }, [conditions, configurationsByPagination?.limit, data, pagingNumberChangedHandler]);
+
+  return (
+    <>
+      <SearchPageBase
+        ref={searchPageBaseRef}
+        appContainer={appContainer}
+        pages={data?.data}
+        onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
+        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE]}
+        // Components
+        searchControl={searchControl}
+        searchResultListHead={searchResultListHead}
+        searchPager={searchPager}
+      />
+
+      <LegacyPrivatePagesMigrationModal />
+    </>
+  );
+};

+ 10 - 29
packages/app/src/components/SearchPage.tsx

@@ -8,7 +8,7 @@ import { parse as parseQuerystring } from 'querystring';
 import AppContainer from '~/client/services/AppContainer';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
-import { useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
+import { useIsSearchServiceReachable } from '~/stores/context';
 import { ISearchConditions, ISearchConfigurations, useSWRxFullTextSearch } from '~/stores/search';
 
 import PaginationWrapper from './PaginationWrapper';
@@ -46,14 +46,20 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   const leftNum = offset + 1;
   const rightNum = offset + hitsCount;
 
+  if (total === 0) {
+    return (
+      <div className="d-flex justify-content-center h2 text-muted my-5">
+        0 {t('search_result.page_number_unit')}
+      </div>
+    );
+  }
+
   return (
     <div className="form-inline d-flex align-items-center justify-content-between">
       <div className="text-nowrap">
         {t('search_result.result_meta')}
         <span className="search-result-keyword">{`${searchingKeyword}`}</span>
-        { total > 0 && (
-          <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
-        ) }
+        <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
         { took != null && (
           <span className="ml-3 text-muted">({took}ms)</span>
         ) }
@@ -111,7 +117,6 @@ export const SearchPage = (props: Props): JSX.Element => {
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll|null>(null);
 
-  const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
   const { data, conditions } = useSWRxFullTextSearch(keyword, {
@@ -254,30 +259,6 @@ export const SearchPage = (props: Props): JSX.Element => {
     );
   }, [conditions, configurationsByPagination?.limit, data, pagingNumberChangedHandler]);
 
-  if (!isSearchServiceConfigured) {
-    return (
-      <div className="grw-container-convertible">
-        <div className="row mt-5">
-          <div className="col text-muted">
-            <h1>Search service is not configured in this system.</h1>
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-  if (!isSearchServiceReachable) {
-    return (
-      <div className="grw-container-convertible">
-        <div className="row mt-5">
-          <div className="col text-muted">
-            <h1>Search service occures errors. Please contact to administrators of this system.</h1>
-          </div>
-        </div>
-      </div>
-    );
-  }
-
   return (
     <SearchPageBase
       ref={searchPageBaseRef}

+ 24 - 17
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,9 +1,9 @@
 import React, {
   FC, useCallback, useEffect, useRef,
 } from 'react';
+import { useTranslation } from 'react-i18next';
 
 import { DropdownItem } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
 
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
@@ -15,9 +15,11 @@ import AppContainer from '../../client/services/AppContainer';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
-import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
-import { usePageDuplicateModal, usePageRenameModal, usePageDeleteModal } from '~/stores/modal';
+import {
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, OnDeletedFunction,
+} from '~/stores/modal';
 
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
@@ -31,15 +33,11 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { pageId, revisionId } = props;
 
   return (
-    <>
-      <DropdownItem divider />
-
-      {/* Export markdown */}
-      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
-        <i className="icon-fw icon-cloud-download"></i>
-        {t('export_bulk.export_page_markdown')}
-      </DropdownItem>
-    </>
+    // Export markdown
+    <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
+      <i className="icon-fw icon-cloud-download"></i>
+      {t('export_bulk.export_page_markdown')}
+    </DropdownItem>
   );
 };
 
@@ -51,6 +49,7 @@ type Props ={
   pageWithMeta : IPageWithMeta<IPageSearchMeta>,
   highlightKeywords?: string[],
   showPageControlDropdown?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
 }
 
 const scrollTo = (scrollElement:HTMLElement) => {
@@ -97,14 +96,14 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     pageWithMeta,
     highlightKeywords,
     showPageControlDropdown,
+    forceHideMenuItems,
   } = props;
 
+  const page = pageWithMeta?.pageData;
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
-  const page = pageWithMeta?.pageData;
-
   const growiRenderer = appContainer.getRenderer('searchresult');
 
 
@@ -116,9 +115,16 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     openRenameModal(pageId, revisionId, path);
   }, [openRenameModal]);
 
-  const deleteItemClickedHandler = useCallback(async(pageToDelete) => {
-    openDeleteModal([pageToDelete]);
-  }, [openDeleteModal]);
+  const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
+    if (typeof pathOrPathsToDelete !== 'string') {
+      return;
+    }
+    window.location.reload();
+  }, []);
+
+  const deleteItemClickedHandler = useCallback(async(pageToDelete, isAbleToDeleteCompletely) => {
+    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
+  }, [onDeletedHandler, openDeleteModal]);
 
   const ControlComponents = useCallback(() => {
     if (page == null) {
@@ -137,6 +143,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
             revisionId={revisionId}
             path={page.path}
             showPageControlDropdown={showPageControlDropdown}
+            forceHideMenuItems={forceHideMenuItems}
             additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
             isCompactMode
             onClickDuplicateMenuItem={duplicateItemClickedHandler}

+ 4 - 0
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -7,6 +7,7 @@ import { IPageWithMeta, isIPageInfoForListing } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { useIsGuestUser } from '~/stores/context';
 import { useSWRxPageInfoForList } from '~/stores/page';
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import { PageListItemL } from '../PageList/PageListItemL';
 
@@ -14,6 +15,7 @@ import { PageListItemL } from '../PageList/PageListItemL';
 type Props = {
   pages: IPageWithMeta<IPageSearchMeta>[],
   selectedPageId?: string,
+  forceHideMenuItems?: ForceHideMenuItems,
   onPageSelected?: (page?: IPageWithMeta<IPageSearchMeta>) => void,
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
 }
@@ -21,6 +23,7 @@ type Props = {
 const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
   const {
     pages, selectedPageId,
+    forceHideMenuItems,
     onPageSelected,
   } = props;
 
@@ -89,6 +92,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
             page={page}
             isEnableActions={!isGuestUser}
             isSelected={page.pageData._id === selectedPageId}
+            forceHideMenuItems={forceHideMenuItems}
             onClickItem={clickItemHandler}
             onCheckboxChanged={props.onCheckboxChanged}
           />

+ 42 - 13
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -1,21 +1,28 @@
 import React, {
   forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
-import { useTranslation } from 'react-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
-import { useIsGuestUser } from '~/stores/context';
+import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import { SearchResultContent } from '../SearchPage/SearchResultContent';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 
+export interface IReturnSelectedPageIds {
+  getSelectedPageIds?: () => Set<string>,
+}
+
+
 type Props = {
   appContainer: AppContainer,
 
   pages?: IPageWithMeta<IPageSearchMeta>[],
 
+  forceHideMenuItems?: ForceHideMenuItems,
+
   onSelectedPagesByCheckboxesChanged?: (selectedCount: number, totalCount: number) => void,
 
   searchControl: React.ReactNode,
@@ -23,12 +30,11 @@ type Props = {
   searchPager: React.ReactNode,
 }
 
-const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
-  const { t } = useTranslation();
-
+const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
   const {
     appContainer,
     pages,
+    forceHideMenuItems,
     onSelectedPagesByCheckboxesChanged,
     searchControl, searchResultListHead, searchPager,
   } = props;
@@ -36,6 +42,8 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
   const searchResultListRef = useRef<ISelectableAll|null>(null);
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
+  const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
   // TODO get search keywords and split
   // ref: RevisionRenderer
@@ -65,6 +73,9 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
 
       selectedPageIdsByCheckboxes.clear();
     },
+    getSelectedPageIds: () => {
+      return selectedPageIdsByCheckboxes;
+    },
   }));
 
   const checkboxChangedHandler = (isChecked: boolean, pageId: string) => {
@@ -106,6 +117,30 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
     }
   }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
 
+  if (!isSearchServiceConfigured) {
+    return (
+      <div className="grw-container-convertible">
+        <div className="row mt-5">
+          <div className="col text-muted">
+            <h1>Search service is not configured in this system.</h1>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  if (!isSearchServiceReachable) {
+    return (
+      <div className="grw-container-convertible">
+        <div className="row mt-5">
+          <div className="col text-muted">
+            <h1>Search service occures errors. Please contact to administrators of this system.</h1>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
   return (
     <div className="content-main">
       <div className="search-result-base d-flex" data-testid="search-result-base">
@@ -130,14 +165,6 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
                   {searchResultListHead}
                 </div>
 
-                {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
-                { pages.length === 0 && (
-                  <div className="d-flex justify-content-center h2 text-muted my-5">
-                    0 {t('search_result.page_number_unit')}
-                  </div>
-                ) }
-
-                {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
                 { pages.length > 0 && (
                   <div className="page-list px-md-4">
                     <SearchResultList
@@ -145,6 +172,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
                       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                       pages={pages!}
                       selectedPageId={selectedPageWithMeta?.pageData._id}
+                      forceHideMenuItems={forceHideMenuItems}
                       onPageSelected={page => setSelectedPageWithMeta(page)}
                       onCheckboxChanged={checkboxChangedHandler}
                     />
@@ -167,6 +195,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
               pageWithMeta={selectedPageWithMeta}
               highlightKeywords={highlightKeywords}
               showPageControlDropdown={!isGuestUser}
+              forceHideMenuItems={forceHideMenuItems}
             />
           )}
         </div>

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

@@ -312,7 +312,7 @@ const Sidebar: FC<Props> = (props: Props) => {
                 style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
               >
                 <div className="grw-contextual-navigation-child">
-                  <div role="group" className={`grw-contextual-navigation-sub ${showContents ? '' : 'd-none'}`}>
+                  <div role="group" data-testid="grw-contextual-navigation-sub" className={`grw-contextual-navigation-sub ${showContents ? '' : 'd-none'}`}>
                     <SidebarContentsWrapper></SidebarContentsWrapper>
                   </div>
                 </div>
@@ -328,6 +328,7 @@ const Sidebar: FC<Props> = (props: Props) => {
                 </div>
               ) }
               <button
+                data-testid="grw-navigation-resize-button"
                 className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
                 type="button"
                 aria-expanded="true"

+ 2 - 2
packages/app/src/components/Sidebar/PageTree.tsx

@@ -7,7 +7,7 @@ import {
 } from '~/stores/context';
 
 import ItemsTree from './PageTree/ItemsTree';
-import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
+import { PrivateLegacyPagesLink } from './PageTree/PrivateLegacyPagesLink';
 
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
@@ -76,7 +76,7 @@ const PageTree: FC = memo(() => {
 
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
         <div className="grw-pagetree-footer border-top p-3 w-100">
-          <PrivateLegacyPages />
+          <PrivateLegacyPagesLink />
         </div>
       )}
     </>

+ 17 - 13
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -29,9 +29,11 @@ interface ItemProps {
   itemNode: ItemNode
   targetPathOrId?: string
   isOpen?: boolean
+  isEnabledAttachTitleHeader?: boolean
   onClickDuplicateMenuItem?(pageId: string, path: string): void
   onClickRenameMenuItem?(pageId: string, revisionId: string, path: string): void
-  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean): void
+  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean, callback?: VoidFunction): void
+  onSelfDeleted?: VoidFunction
 }
 
 // Utility to mark target
@@ -72,7 +74,8 @@ const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const {
-    itemNode, targetPathOrId, isOpen: _isOpen = false, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, isEnableActions,
+    itemNode, targetPathOrId, isOpen: _isOpen = false, isEnabledAttachTitleHeader,
+    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, isEnableActions, onSelfDeleted,
   } = props;
 
   const { page, children } = itemNode;
@@ -260,8 +263,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     };
     const isAbleToDeleteCompletely = pageInfo?.isAbleToDeleteCompletely ?? false;
 
-    onClickDeleteMenuItem(pageToDelete, isAbleToDeleteCompletely);
-  }, [onClickDeleteMenuItem, page, pageInfo?.isAbleToDeleteCompletely]);
+    onClickDeleteMenuItem(pageToDelete, isAbleToDeleteCompletely, async() => {
+      if (onSelfDeleted != null) await onSelfDeleted();
+    });
+  }, [onClickDeleteMenuItem, page, pageInfo?.isAbleToDeleteCompletely, onSelfDeleted]);
 
   const onPressEnterForCreateHandler = async(inputText: string) => {
     setNewPageInputShown(false);
@@ -274,17 +279,15 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       return;
     }
 
-    // TODO 88261: Get the isEnabledAttachTitleHeader by SWR
-    // const initBody = '';
-    // const { isEnabledAttachTitleHeader } = props.appContainer.getConfig();
-    // if (isEnabledAttachTitleHeader) {
-    //   initBody = pathUtils.attachTitleHeader(newPagePath);
-    // }
+    let initBody = '';
+    if (isEnabledAttachTitleHeader) {
+      initBody = pathUtils.attachTitleHeader(newPagePath);
+    }
 
     try {
       await apiv3Post('/pages/', {
         path: newPagePath,
-        body: '',
+        body: initBody,
         grant: page.grant,
         grantUserGroupId: page.grantedGroup,
         createFromPageTree: true,
@@ -372,7 +375,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           />
         )}
         { !isRenameInputShown && ( */}
-        <a href={page._id} className="grw-pagetree-title-anchor flex-grow-1">
+        <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
           <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(pageTitle as string) || '/'}</p>
         </a>
         {/* )} */}
@@ -385,7 +388,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           <PageItemControl
             pageId={page._id}
             isEnableActions={isEnableActions}
-            showBookmarkMenuItem
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
@@ -422,9 +424,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               itemNode={node}
               isOpen={false}
               targetPathOrId={targetPathOrId}
+              isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
               onClickDuplicateMenuItem={onClickDuplicateMenuItem}
               onClickRenameMenuItem={onClickRenameMenuItem}
               onClickDeleteMenuItem={onClickDeleteMenuItem}
+              onSelfDeleted={async() => { await mutateChildren() }}
             />
           </div>
         ))

+ 23 - 26
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
-import { useSWRxPageAncestorsChildren, useSWRxPageChildren, useSWRxRootPage } from '../../../stores/page-listing';
+import { useSWRxPageAncestorsChildren, useSWRxPageChildren, useSWRxRootPage } from '~/stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
@@ -12,6 +12,8 @@ import {
 } from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
+import { useIsEnabledAttachTitleHeader } from '~/stores/context';
+
 /*
  * Utility to generate initial node
  */
@@ -62,9 +64,10 @@ const renderByInitialNode = (
     initialNode: ItemNode,
     isEnableActions: boolean,
     targetPathOrId?: string,
+    isEnabledAttachTitleHeader?: boolean,
     onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
     onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
-    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean) => void,
+    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean, onItemDeleted: VoidFunction) => void,
 ): JSX.Element => {
 
   return (
@@ -74,6 +77,7 @@ const renderByInitialNode = (
         targetPathOrId={targetPathOrId}
         itemNode={initialNode}
         isOpen
+        isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
         isEnableActions={isEnableActions}
         onClickDuplicateMenuItem={onClickDuplicateMenuItem}
         onClickRenameMenuItem={onClickRenameMenuItem}
@@ -95,8 +99,8 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { t } = useTranslation();
 
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
-  const { mutate: mutateChildren } = useSWRxPageChildren(targetPathOrId);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
+  const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -118,35 +122,24 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     openRenameModal(pageId, revisionId, path);
   };
 
-  const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
-    if (typeof pathOrPathsToDelete !== 'string') {
-      return;
-    }
+  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal, isAbleToDeleteCompletely, onItemDeleted: VoidFunction) => {
+    const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+      if (typeof pathOrPathsToDelete !== 'string') {
+        return;
+      }
 
-    mutateChildren();
+      onItemDeleted();
 
-    const path = pathOrPathsToDelete;
+      const path = pathOrPathsToDelete;
 
-    if (isRecursively) {
-      if (isCompletely) {
-        toastSuccess(t('deleted_single_page_recursively_completely', { path }));
-      }
-      else {
-        toastSuccess(t('deleted_single_page_recursively', { path }));
-      }
-    }
-    else {
-      // eslint-disable-next-line no-lonely-if
       if (isCompletely) {
-        toastSuccess(t('deleted_single_page_completely', { path }));
+        toastSuccess(t('deleted_pages_completely', { path }));
       }
       else {
-        toastSuccess(t('deleted_single_page', { path }));
+        toastSuccess(t('deleted_pages', { path }));
       }
-    }
-  };
+    };
 
-  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal, isAbleToDeleteCompletely) => {
     openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
   };
 
@@ -161,7 +154,9 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
-    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem);
+    return renderByInitialNode(
+      initialNode, isEnableActions, targetPathOrId, isEnabledAttachTitleHeader, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    );
   }
 
   /*
@@ -169,7 +164,9 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (targetAndAncestorsData != null) {
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem);
+    return renderByInitialNode(
+      initialNode, isEnableActions, targetPathOrId, isEnabledAttachTitleHeader, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    );
   }
 
   return null;

+ 2 - 4
packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx → packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx

@@ -1,14 +1,12 @@
 import React, { FC, memo } from 'react';
 import { useTranslation } from 'react-i18next';
 
-const PrivateLegacyPages: FC = memo(() => {
+export const PrivateLegacyPagesLink: FC = memo(() => {
   const { t } = useTranslation();
 
   return (
-    <a href="/private-legacy-pages?q=[nq:PrivateLegacyPages]" className="h5 grw-private-legacy-pages-anchor text-decoration-none">
+    <a href="/_private-legacy-pages" className="h5 grw-private-legacy-pages-anchor text-decoration-none">
       <i className="icon-drawer mr-2"></i> {t('pagetree.private_legacy_pages')}
     </a>
   );
 });
-
-export default PrivateLegacyPages;

+ 3 - 2
packages/app/src/components/User/SeenUserInfo.tsx

@@ -9,13 +9,14 @@ import UserPictureList from './UserPictureList';
 
 interface Props {
   seenUsers: IUser[],
+  sumOfSeenUsers?: number,
   disabled?: boolean,
 }
 
 const SeenUserInfo: FC<Props> = (props: Props) => {
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
-  const { seenUsers, disabled } = props;
+  const { seenUsers, sumOfSeenUsers, disabled } = props;
 
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
 
@@ -25,7 +26,7 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
         <span className="mr-1 footstamp-icon">
           <FootstampIcon />
         </span>
-        <span className="seen-user-count">{seenUsers.length}</span>
+        <span className="seen-user-count">{sumOfSeenUsers || seenUsers.length}</span>
       </button>
       <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
         <PopoverBody className="user-list-popover">

+ 1 - 16
packages/app/src/server/middlewares/http-error-handler.js

@@ -1,23 +1,8 @@
-import { HttpError } from 'http-errors';
+import { isHttpError } from 'http-errors';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middleware:htto-error-handler');
 
-const isHttpError = (val) => {
-  if (!val || typeof val !== 'object') {
-    return false;
-  }
-
-  if (val instanceof HttpError) {
-    return true;
-  }
-
-  return val instanceof Error
-    && typeof val.expose === 'boolean'
-    && typeof val.statusCode === 'number'
-    && val.status === val.statusCode;
-};
-
 module.exports = async(err, req, res, next) => {
   // handle if the err is a HttpError instance
   if (isHttpError(err)) {

+ 16 - 0
packages/app/src/server/models/obsolete-page.js

@@ -222,6 +222,22 @@ export class PageQueryBuilder {
     return this;
   }
 
+  async addConditionAsMigratablePages(user) {
+    this.query = this.query
+      .and({
+        $or: [
+          { grant: { $ne: GRANT_RESTRICTED } },
+          { grant: { $ne: GRANT_SPECIFIED } },
+        ],
+      });
+    this.addConditionAsNotMigrated();
+    this.addConditionAsNonRootPage();
+    this.addConditionToExcludeTrashed();
+    await this.addConditionForParentNormalization(user);
+
+    return this;
+  }
+
   addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
     const grantConditions = [
       { grant: null },

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

@@ -5,6 +5,9 @@ import ErrorV3 from '~/server/models/vo/error-apiv3';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import loggerFactory from '~/utils/logger';
 
+import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
+import httpErrorHandler from '../../middlewares/http-error-handler';
+
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -40,6 +43,8 @@ module.exports = (crowi) => {
       'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
   });
 
+  const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
+
   async function sendPasswordResetEmail(txtFileName, i18n, email, url) {
     return mailService.send({
       to: email,
@@ -53,7 +58,7 @@ module.exports = (crowi) => {
     });
   }
 
-  router.post('/', async(req, res) => {
+  router.post('/', checkPassportStrategyMiddleware, async(req, res) => {
     const { email } = req.body;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
     const i18n = req.language || grobalLang;
@@ -81,7 +86,8 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/', apiLimiter, injectResetOrderByTokenMiddleware, csrf, validator.password, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.put('/', apiLimiter, checkPassportStrategyMiddleware, injectResetOrderByTokenMiddleware, csrf, validator.password, apiV3FormValidator, async(req, res) => {
     const { passwordResetOrder } = req;
     const { email } = passwordResetOrder;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
@@ -109,6 +115,7 @@ module.exports = (crowi) => {
   });
 
   // middleware to handle error
+  router.use(httpErrorHandler);
   router.use((error, req, res, next) => {
     if (error != null) {
       return res.apiv3Err(new ErrorV3(error.message, error.code));

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

@@ -10,7 +10,7 @@ const express = require('express');
 const { body, query } = require('express-validator');
 
 const router = express.Router();
-const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
+const { convertToNewAffiliationPath } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -391,14 +391,12 @@ module.exports = (crowi) => {
 
       const isBookmarked = await Bookmark.findByPageIdAndUserId(pageId, user._id);
       const isLiked = page.isLiked(user);
-      const isMovable = !isTopPage(page.path);
       const isAbleToDeleteCompletely = pageService.canDeleteCompletely(page.creator?._id, user);
 
       const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
 
       const responseBody = {
         ...responseBodyForGuest,
-        isMovable,
         isAbleToDeleteCompletely,
         isBookmarked,
         isLiked,

+ 7 - 3
packages/app/src/server/routes/apiv3/pages.js

@@ -1,11 +1,10 @@
-import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
-const { pathUtils } = require('@growi/core');
+const { pathUtils, pagePathUtils } = require('@growi/core');
 const mongoose = require('mongoose');
 
 const { body } = require('express-validator');
@@ -637,6 +636,11 @@ module.exports = (crowi) => {
 
     const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
 
+    const isCreatable = isCreatablePage(newPagePath);
+    if (!isCreatable) {
+      return res.apiv3Err(new ErrorV3('This page path is invalid', 'invalid_path'), 400);
+    }
+
     // check page existence
     const isExist = (await Page.count({ path: newPagePath })) > 0;
     if (isExist) {
@@ -787,7 +791,7 @@ module.exports = (crowi) => {
   });
 
   // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
     const { pageIds: _pageIds, isRecursively } = req.body;
     const pageIds = _pageIds == null ? [] : _pageIds;
 

+ 32 - 2
packages/app/src/server/routes/forgot-password.ts

@@ -1,8 +1,38 @@
 import {
   NextFunction, Request, RequestHandler, Response,
 } from 'express';
+
+import createError from 'http-errors';
+
+import loggerFactory from '~/utils/logger';
+
 import { ReqWithPasswordResetOrder } from '../middlewares/inject-reset-order-by-token-middleware';
 
+const logger = loggerFactory('growi:routes:forgot-password');
+
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
+export const checkForgotPasswordEnabledMiddlewareFactory = (crowi: any, forApi = false) => {
+
+  return (req: Request, res: Response, next: NextFunction): void => {
+    const isPasswordResetEnabled = crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled') as boolean | null;
+    const isLocalStrategySetup = crowi.passportService.isLocalStrategySetup as boolean ?? false;
+
+    const isEnabled = isLocalStrategySetup && isPasswordResetEnabled;
+
+    if (!isEnabled) {
+      const message = 'Forgot-password function is unavailable because neither LocalStrategy and LdapStrategy is not setup.';
+      logger.error(message);
+
+      const statusCode = forApi ? 405 : 404;
+      return next(createError(statusCode, message, { code: 'password-reset-is-unavailable' }));
+    }
+
+    next();
+  };
+
+};
+
 export const forgotPassword = (req: Request, res: Response): void => {
   return res.render('forgot-password');
 };
@@ -13,9 +43,9 @@ export const resetPassword = (req: ReqWithPasswordResetOrder, res: Response): vo
 };
 
 // middleware to handle error
-export const handleHttpErrosMiddleware = (error: Error & { code: string }, req: Request, res: Response, next: NextFunction): Promise<RequestHandler> | void => {
+export const handleErrosMiddleware = (error: Error & { code: string }, req: Request, res: Response, next: NextFunction): Promise<RequestHandler> | void => {
   if (error != null) {
     return res.render('forgot-password/error', { key: error.code });
   }
-  next();
+  next(error);
 };

+ 6 - 2
packages/app/src/server/routes/index.js

@@ -50,6 +50,7 @@ module.exports = function(crowi, app) {
   const tag = require('./tag')(crowi, app);
   const search = require('./search')(crowi, app);
   const hackmd = require('./hackmd')(crowi, app);
+  const ogp = require('./ogp')(crowi);
 
   const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
@@ -196,11 +197,12 @@ module.exports = function(crowi, app) {
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
   app.use('/forgot-password', express.Router()
+    .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
     .get('/', forgotPassword.forgotPassword)
     .get('/:token', apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
-    .use(forgotPassword.handleHttpErrosMiddleware));
+    .use(forgotPassword.handleErrosMiddleware));
 
-  app.use('/private-legacy-pages', express.Router()
+  app.use('/_private-legacy-pages', express.Router()
     .get('/', privateLegacyPages.renderPrivateLegacyPages));
   app.use('/user-activation', express.Router()
     .get('/:token', apiLimiter, applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
@@ -209,6 +211,8 @@ module.exports = function(crowi, app) {
 
   app.get('/share/:linkId', page.showSharedPage);
 
+  app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));
+
   app.get('/:id([0-9a-z]{24})'       , loginRequired , injectUserUISettings, page.showPage);
 
   app.get('/*/$'                   , loginRequired , injectUserUISettings, page.redirectorWithEndOfSlash);

+ 148 - 0
packages/app/src/server/routes/ogp.ts

@@ -0,0 +1,148 @@
+import {
+  Request, Response, NextFunction,
+} from 'express';
+import { param, validationResult, ValidationError } from 'express-validator';
+
+import path from 'path';
+import * as fs from 'fs';
+
+import { DevidedPagePath } from '@growi/core';
+import axios from '~/utils/axios';
+import loggerFactory from '~/utils/logger';
+import { projectRoot } from '~/utils/project-dir-utils';
+import { convertStreamToBuffer } from '../util/stream';
+
+const logger = loggerFactory('growi:routes:ogp');
+
+const DEFAULT_USER_IMAGE_URL = '/images/icons/user.svg';
+const DEFAULT_USER_IMAGE_PATH = `public${DEFAULT_USER_IMAGE_URL}`;
+
+let bufferedDefaultUserImageCache: Buffer = Buffer.from('');
+fs.readFile(path.join(projectRoot, DEFAULT_USER_IMAGE_PATH), (err, buffer) => {
+  if (err) throw err;
+  bufferedDefaultUserImageCache = buffer;
+});
+
+
+module.exports = function(crowi) {
+
+  const isUserImageAttachment = (userImageUrlCached: string): boolean => {
+    return /^\/attachment\/.+/.test(userImageUrlCached);
+  };
+
+  const getBufferedUserImage = async(userImageUrlCached: string): Promise<Buffer> => {
+
+    let bufferedUserImage: Buffer;
+
+    if (isUserImageAttachment(userImageUrlCached)) {
+      const { fileUploadService } = crowi;
+      const Attachment = crowi.model('Attachment');
+      const attachment = await Attachment.findById(userImageUrlCached);
+      const fileStream = await fileUploadService.findDeliveryFile(attachment);
+      bufferedUserImage = await convertStreamToBuffer(fileStream);
+      return bufferedUserImage;
+    }
+
+    return (await axios.get(
+      userImageUrlCached, {
+        responseType: 'arraybuffer',
+      },
+    )).data;
+
+  };
+
+  const renderOgp = async(req: Request, res: Response) => {
+
+    const { configManager } = crowi;
+    const ogpUri = configManager.getConfig('crowi', 'app:ogpUri');
+    const page = req.body.page;
+
+    let user;
+    let pageTitle: string;
+    let bufferedUserImage: Buffer;
+
+    try {
+      const User = crowi.model('User');
+      user = await User.findById(page.creator._id.toString());
+
+      bufferedUserImage = user.imageUrlCached === DEFAULT_USER_IMAGE_URL ? bufferedDefaultUserImageCache : (await getBufferedUserImage(user.imageUrlCached));
+      // todo: consider page title
+      pageTitle = (new DevidedPagePath(page.path)).latter;
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(500).send(`error: ${err}`);
+    }
+
+    let result;
+    try {
+      result = await axios.post(
+        ogpUri, {
+          data: {
+            title: pageTitle,
+            userName: user.username,
+            userImage: bufferedUserImage,
+          },
+        }, {
+          responseType: 'stream',
+        },
+      );
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(500).send(`error: ${err}`);
+    }
+
+    res.writeHead(200, {
+      'Content-Type': 'image/jpeg',
+    });
+    result.data.pipe(res);
+
+  };
+
+  const pageIdRequired = param('pageId').not().isEmpty().withMessage('page id is not included in the parameter');
+
+  const ogpValidator = async(req:Request, res:Response, next:NextFunction) => {
+    const { aclService, fileUploadService, configManager } = crowi;
+
+    const ogpUri = configManager.getConfig('crowi', 'app:ogpUri');
+
+    if (ogpUri == null) return res.status(400).send('OGP URI for GROWI has not been setup');
+    if (!fileUploadService.getIsUploadable()) return res.status(501).send('This GROWI can not upload file');
+    if (!aclService.isGuestAllowedToRead()) return res.status(501).send('This GROWI is not public');
+
+    const errors = validationResult(req);
+
+    if (errors.isEmpty()) {
+
+      try {
+        const Page = crowi.model('Page');
+        const page = await Page.findByIdAndViewer(req.params.pageId);
+
+        if (page == null || page.status !== Page.STATUS_PUBLISHED || (page.grant !== Page.GRANT_PUBLIC && page.grant !== Page.GRANT_RESTRICTED)) {
+          return res.status(400).send('the page does not exist');
+        }
+
+        req.body.page = page;
+      }
+      catch (error) {
+        logger.error(error);
+        return res.status(500).send(`error: ${error}`);
+      }
+
+      return next();
+    }
+
+    // errors.array length is one bacause pageIdRequired is used
+    const pageIdRequiredError: ValidationError = errors.array()[0];
+
+    return res.status(400).send(pageIdRequiredError.msg);
+  };
+
+  return {
+    renderOgp,
+    pageIdRequired,
+    ogpValidator,
+  };
+
+};

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

@@ -890,7 +890,7 @@ module.exports = function(crowi, app) {
    * - If revision_id is not specified => force update by the new contents.
    */
   api.update = async function(req, res) {
-    const pageBody = body ?? null;
+    const pageBody = req.body.body ?? null;
     const pageId = req.body.page_id || null;
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;

+ 6 - 0
packages/app/src/server/service/config-loader.ts

@@ -604,6 +604,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: 'ptog',
   },
+  OGP_URI: {
+    ns:      'crowi',
+    key:     'app:ogpUri',
+    type:    ValueType.STRING,
+    default: null,
+  },
 };
 
 

+ 6 - 6
packages/app/src/server/service/installer.ts

@@ -62,14 +62,14 @@ export class InstallerService {
     const { localeDir } = this.crowi;
     // create /Sandbox/*
     /*
-     * Keep in this order to avoid creating the same pages
+     * Keep in this order to
+     *   1. avoid creating the same pages
+     *   2. avoid difference for order in VRT
      */
     await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox', owner);
-    await Promise.all([
-      this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner),
-      this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner),
-      this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner),
-    ]);
+    await this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner);
+    await this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner);
+    await this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner);
 
     // update createdAt and updatedAt fields of all pages
     if (initialPagesCreatedAt != null) {

+ 32 - 31
packages/app/src/server/service/page.ts

@@ -26,7 +26,7 @@ const debug = require('debug')('growi:services:page');
 
 const logger = loggerFactory('growi:services:page');
 const {
-  isCreatablePage, isTrashPage, isTopPage, isDeletablePage, omitDuplicateAreaPathFromPaths, omitDuplicateAreaPageFromPages,
+  isCreatablePage, isTrashPage, isTopPage, isDeletablePage, omitDuplicateAreaPathFromPaths, omitDuplicateAreaPageFromPages, isUserPage, isUserNamePage,
 } = pagePathUtils;
 
 const BULK_REINDEX_SIZE = 100;
@@ -314,7 +314,7 @@ class PageService {
   async renamePage(page, newPagePath, user, options) {
     const Page = this.crowi.model('Page');
 
-    const isExist = await Page.count({ path: newPagePath }) > 0;
+    const isExist = await Page.exists({ path: newPagePath });
     if (isExist) {
       // if page found, cannot rename to that path
       throw new Error('the path already exists');
@@ -1101,22 +1101,24 @@ class PageService {
     // TODO: resume
     // no await for deleteDescendantsWithStream and updateDescendantCountOfAncestors
     if (isRecursively) {
-      (async() => {
-        const deletedDescendantCount = await this.deleteDescendantsWithStream(page, user, shouldUseV4Process); // use the same process in both version v4 and v5
-
-        // update descendantCount of ancestors'
-        if (page.parent != null) {
-          await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
-
-          // delete leaf empty pages
-          await this.removeLeafEmptyPages(page);
-        }
-      })();
+      this.resumableDeleteDescendants(page, user, shouldUseV4Process);
     }
 
     return deletedPage;
   }
 
+  async resumableDeleteDescendants(page, user, shouldUseV4Process) {
+    const deletedDescendantCount = await this.deleteDescendantsWithStream(page, user, shouldUseV4Process); // use the same process in both version v4 and v5
+
+    // update descendantCount of ancestors'
+    if (page.parent != null) {
+      await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
+
+      // delete leaf empty pages
+      await this.removeLeafEmptyPages(page);
+    }
+  }
+
   private async deletePageV4(page, user, options = {}, isRecursively = false) {
     const Page = mongoose.model('Page') as PageModel;
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
@@ -1267,7 +1269,7 @@ class PageService {
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(writeStream);
 
-    await streamToPromise(readStream);
+    await streamToPromise(writeStream);
 
     return nDeletedNonEmptyPages;
   }
@@ -1330,14 +1332,14 @@ class PageService {
 
     logger.debug('Deleting completely', paths);
 
+    await this.deleteCompletelyOperation(ids, paths);
+
     // replace with an empty page
     const shouldReplace = !isRecursively && !isTrashPage(page.path) && await Page.exists({ parent: page._id });
     if (shouldReplace) {
       await Page.replaceTargetWithPage(page);
     }
 
-    await this.deleteCompletelyOperation(ids, paths);
-
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(page.parent, -1, true);
     }
@@ -1352,19 +1354,21 @@ class PageService {
     // TODO: resume
     if (isRecursively) {
       // no await for deleteCompletelyDescendantsWithStream
-      (async() => {
-        const deletedDescendantCount = await this.deleteCompletelyDescendantsWithStream(page, user, options, shouldUseV4Process);
-
-        // update descendantCount of ancestors'
-        if (page.parent != null) {
-          await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
-        }
-      })();
+      this.resumableDeleteCompletelyDescendants(page, user, options, shouldUseV4Process);
     }
 
     return;
   }
 
+  async resumableDeleteCompletelyDescendants(page, user, options, shouldUseV4Process) {
+    const deletedDescendantCount = await this.deleteCompletelyDescendantsWithStream(page, user, options, shouldUseV4Process);
+
+    // update descendantCount of ancestors'
+    if (page.parent != null) {
+      await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
+    }
+  }
+
   private async deleteCompletelyV4(page, user, options = {}, isRecursively = false, preventEmitting = false) {
     const ids = [page._id];
     const paths = [page.path];
@@ -1696,17 +1700,17 @@ class PageService {
   }
 
   constructBasicPageInfo(page: IPage, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
+    const isMovable = isGuestUser ? false : !isTopPage(page.path) && !isUserPage(page.path) && !isUserNamePage(page.path);
+
     if (page.isEmpty) {
       return {
         isEmpty: true,
-        isMovable: true,
+        isMovable,
         isDeletable: false,
         isAbleToDeleteCompletely: false,
       };
     }
 
-    const isMovable = isGuestUser ? false : !isTopPage(page.path);
-
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
 
@@ -2254,10 +2258,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
 
     const builder = new PageQueryBuilder(Page.count(), false);
-    builder.addConditionAsNotMigrated();
-    builder.addConditionAsNonRootPage();
-    builder.addConditionToExcludeTrashed();
-    await builder.addConditionForParentNormalization(user);
+    await builder.addConditionAsMigratablePages(user);
 
     const nMigratablePages = await builder.query.exec();
 

+ 2 - 2
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -386,7 +386,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
 
     const bookmarkCount = page.bookmarkCount || 0;
-    const seenUsersCount = page.seenUsers.length || 0;
+    const seenUsersCount = page.seenUsers?.length || 0;
     let document = {
       path: page.path,
       body: page.revision.body,
@@ -396,7 +396,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       comment_count: page.commentCount,
       bookmark_count: bookmarkCount,
       seenUsers_count: seenUsersCount,
-      like_count: page.liker.length || 0,
+      like_count: page.liker?.length || 0,
       created_at: page.createdAt,
       updated_at: page.updatedAt,
       tag_names: page.tagNames,

+ 1 - 3
packages/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -33,11 +33,9 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
     const { PageQueryBuilder } = Page;
 
     const queryBuilder = new PageQueryBuilder(Page.find());
+    await queryBuilder.addConditionAsMigratablePages(user);
 
     const _pages: PageDocument[] = await queryBuilder
-      .addConditionAsNonRootPage()
-      .addConditionAsNotMigrated()
-      .addConditionToFilteringByViewer(user, userGroups)
       .addConditionToPagenate(offset, limit)
       .query
       .populate('lastUpdateUser')

+ 14 - 0
packages/app/src/server/util/stream.ts

@@ -0,0 +1,14 @@
+export const convertStreamToBuffer = (stream: any): Promise<Buffer> => {
+
+  return new Promise((resolve, reject) => {
+
+    const buffer: Uint8Array[] = [];
+
+    stream.on('data', (chunk: Uint8Array) => {
+      buffer.push(chunk);
+    });
+    stream.on('end', () => resolve(Buffer.concat(buffer)));
+    stream.on('error', err => reject(err));
+
+  });
+};

+ 6 - 0
packages/app/src/server/views/forgot-password/error.html

@@ -33,6 +33,11 @@
             <div class="text-center">
               <h1><i class="icon-lock-open large"></i></h1>
               <h2 class="text-center">{{ t('forgot_password.reset_password') }}</h2>
+
+                {% if key === 'password-reset-is-unavailable' %}
+                <h3 class="text-muted">This feature is unavailable.</h3>
+                {% endif %}
+
                 {% if key === 'password-reset-order-is-not-appropriate' %}
                 <div>
                   <div class="alert alert-warning mb-3">
@@ -43,6 +48,7 @@
                   </a>
                 </div>
                 {% endif %}
+
             </div>
           </div>
         </div>

+ 10 - 0
packages/app/src/server/views/layout-growi/page.html

@@ -1,5 +1,15 @@
 {% extends 'base/layout.html' %}
 
+{% block html_additional_headers %}
+  {% parent %}
+
+  <!-- OGP -->
+  <meta property="og:site_name" content="{{ appService.getAppTitle() | preventXss }}" />
+  <meta property="og:title" content="{{ page.path | preventXss }}" />
+  <meta property="og:url" content="{{ appService.getSiteUrl() | preventXss }}/{{ page.id }}" />
+  <meta property="og:type" content="article" />
+  <meta property="og:image" content="{{ appService.getSiteUrl() | preventXss }}/ogp/{{ page.id }}" />
+{% endblock %}
 
 {% block content_main_before %}
 {% endblock %}

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

@@ -11,6 +11,13 @@
   data-target="#search-result-list"
 {% endblock %}
 
+<!-- add .on-search to body tag class in layout -->
+{% set additionalBodyClass = 'on-search' %}
+
 {% block layout_main %}
-<p>This page is not implemented.</p>
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
+
+<div id="main" class="main search-page mt-0">
+  <div id="private-regacy-pages"></div>
+</div>
 {% endblock %} {# layout_main #}

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

@@ -17,8 +17,7 @@
 {% block layout_main %}
 <div id="grw-fav-sticky-trigger" class="sticky-top"></div>
 
-  <div id="main" class="main search-page mt-0">
-    <div id="search-page"></div>
-  </div>
-
+<div id="main" class="main search-page mt-0">
+  <div id="search-page"></div>
+</div>
 {% endblock %} {# layout_main #}

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

@@ -143,6 +143,10 @@ export const useIsSearchServiceReachable = (initialData?: boolean) : SWRResponse
   return useStaticSWR<boolean, Error>('isSearchServiceReachable', initialData);
 };
 
+export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
+};
+
 
 /** **********************************************************
  *                     Computed contexts

+ 40 - 0
packages/app/src/stores/modal.tsx

@@ -1,6 +1,7 @@
 import { SWRResponse } from 'swr';
 import { useStaticSWR } from './use-static-swr';
 import { Nullable } from '~/interfaces/common';
+import { IPageInfo } from '~/interfaces/page';
 
 
 /*
@@ -177,6 +178,45 @@ export const usePagePresentationModal = (
   };
 };
 
+
+/*
+ * LegacyPrivatePagesMigrationModal
+ */
+
+export type ILegacyPrivatePage = { pageId: string, path: string };
+
+export type LegacyPrivatePagesMigrationModalSubmitedHandler = (pages: ILegacyPrivatePage[], isRecursively?: boolean) => void;
+
+type LegacyPrivatePagesMigrationModalStatus = {
+  isOpened: boolean,
+  pages?: ILegacyPrivatePage[],
+  onSubmited?: LegacyPrivatePagesMigrationModalSubmitedHandler,
+}
+
+type LegacyPrivatePagesMigrationModalStatusUtils = {
+  open(pages: ILegacyPrivatePage[], onSubmited?: LegacyPrivatePagesMigrationModalSubmitedHandler): Promise<LegacyPrivatePagesMigrationModalStatus | undefined>,
+  close(): Promise<LegacyPrivatePagesMigrationModalStatus | undefined>,
+}
+
+export const useLegacyPrivatePagesMigrationModal = (
+    status?: LegacyPrivatePagesMigrationModalStatus,
+): SWRResponse<LegacyPrivatePagesMigrationModalStatus, Error> & LegacyPrivatePagesMigrationModalStatusUtils => {
+  const initialData: LegacyPrivatePagesMigrationModalStatus = {
+    isOpened: false,
+    pages: [],
+  };
+  const swrResponse = useStaticSWR<LegacyPrivatePagesMigrationModalStatus, Error>('legacyPrivatePagesMigrationModal', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (pages, onSubmited?) => swrResponse.mutate({
+      isOpened: true, pages, onSubmited,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false, pages: [], onSubmited: undefined }),
+  };
+};
+
+
 /*
 * DescendantsPageListModal
 */

+ 13 - 0
packages/app/src/stores/search.tsx

@@ -90,3 +90,16 @@ export const useSWRxFullTextSearch = (
     },
   };
 };
+
+export const useSWRxNamedQuerySearch = (
+    namedQuery: string, configurations: ISearchConfigurations,
+): SWRResponse<IFormattedSearchResult, Error> & { conditions: ISearchConditions } => {
+
+  const keyword = `[nq:${namedQuery}]`;
+  return useSWRxFullTextSearch(keyword, {
+    ...configurations,
+    includeTrashPages: true,
+    includeUserPages: true,
+  });
+
+};

+ 1 - 1
packages/app/src/stores/ui.tsx

@@ -299,7 +299,7 @@ export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
   const isNotFoundPage = notFoundTargetPathOrId != null;
 
   return useSWRImmutable(
-    includesUndefined ? null : key,
+    includesUndefined ? null : [key, editorMode],
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     () => !isUserPage && !isSharedPage(currentPagePath!) && !isIdenticalPath && !(isViewMode && isNotFoundPage),
   );

+ 0 - 1
packages/app/src/styles/_override-bootstrap.scss

@@ -100,7 +100,6 @@
     &.btn.disabled {
       pointer-events: auto;
       cursor: not-allowed;
-      opacity: unset;
     }
 
     // hide caret

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

@@ -1,5 +1,3 @@
-const ssPrefix = 'access-to-admin-page-';
-
 const adminMenues = [
   'app', // App
   'security', // Security
@@ -15,6 +13,7 @@ const adminMenues = [
 ];
 
 context('Access to Admin page', () => {
+  const ssPrefix = 'access-to-admin-page-';
 
   let connectSid: string | undefined;
 

+ 2 - 3
packages/app/test/cypress/integration/2-basic-features/access-to-me-page.spec.ts

@@ -1,6 +1,5 @@
-const ssPrefix = 'access-to-page-';
-
-context('Access to page', () => {
+context('Access to /me page', () => {
+  const ssPrefix = 'access-to-me-page-';
 
   let connectSid: string | undefined;
 

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

@@ -22,6 +22,12 @@ context('Access to page', () => {
 
   it('/Sandbox is successfully loaded', () => {
     cy.visit('/Sandbox', {  });
+
+    // collapse sidebar and wait saving
+    cy.collapseSidebar(true);
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1500);
+
     cy.screenshot(`${ssPrefix}-sandbox`, { capture: 'viewport' });
   });
 

+ 30 - 0
packages/app/test/cypress/integration/3-search/access-to-private-legacy-pages-directly.spec.ts

@@ -0,0 +1,30 @@
+context('Access to legacy private pages directly', () => {
+  const ssPrefix = 'access-to-legacy-private-pages-directly-';
+
+  let connectSid: string | undefined;
+
+  before(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    cy.getCookie('connect.sid').then(cookie => {
+      connectSid = cookie?.value;
+    });
+  });
+
+  beforeEach(() => {
+    if (connectSid != null) {
+      cy.setCookie('connect.sid', connectSid);
+    }
+  });
+
+  it('/_private-legacy-pages is successfully loaded', () => {
+    cy.visit('/_private-legacy-pages');
+
+    cy.getByTestid('search-result-base').should('be.visible');
+
+    cy.screenshot(`${ssPrefix}-shown`, { capture: 'viewport' });
+  });
+
+});

+ 2 - 2
packages/app/test/cypress/integration/3-search/access-to-result-page-directly.spec.ts

@@ -20,7 +20,7 @@ context('Access to search result page directly', () => {
   });
 
   it('/_search with "q" param is successfully loaded', () => {
-    cy.visit('/_search', { qs: { q: 'bootstrap4' } });
+    cy.visit('/_search', { qs: { q: 'bootstrap4 labels alerts' } });
 
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
@@ -29,7 +29,7 @@ context('Access to search result page directly', () => {
   });
 
   it('checkboxes behaviors', () => {
-    cy.visit('/_search', { qs: { q: 'bootstrap4' } });
+    cy.visit('/_search', { qs: { q: 'bootstrap4 labels alerts' } });
 
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');

+ 10 - 0
packages/app/test/cypress/support/commands.ts

@@ -37,3 +37,13 @@ Cypress.Commands.add('login', (username, password) => {
     cy.getByTestid('btnSubmitForLogin').click();
   });
 });
+
+Cypress.Commands.add('collapseSidebar', (isCollapsed) => {
+  cy.getByTestid('grw-contextual-navigation-sub').then(($contents) => {
+    const isCurrentCollapsed = $contents.hasClass('d-none');
+    // toggle when the current state and isCoolapsed is not match
+    if (isCurrentCollapsed !== isCollapsed) {
+      cy.getByTestid("grw-navigation-resize-button").click();
+    }
+  });
+});

+ 1 - 0
packages/app/test/cypress/support/index.ts

@@ -25,6 +25,7 @@ declare global {
     interface Chainable {
        getByTestid(selector: string, options?: Partial<Loggable & Timeoutable & Withinable & Shadow>): Chainable<JQuery<Element>>,
        login(username: string, password: string): Chainable<void>,
+       collapseSidebar(isCollapsed: boolean): Chainable<void>,
     }
   }
 }

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

@@ -0,0 +1,98 @@
+import mongoose from 'mongoose';
+
+import { getInstance } from '../setup-crowi';
+
+describe('Page', () => {
+  let crowi;
+  let Page;
+  let Revision;
+  let User;
+  let Tag;
+  let PageTagRelation;
+  let Bookmark;
+  let Comment;
+  let ShareLink;
+  let PageRedirect;
+  let xssSpy;
+
+  let rootPage;
+  let dummyUser1;
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
+
+    jest.restoreAllMocks();
+    User = mongoose.model('User');
+    Page = mongoose.model('Page');
+    Revision = mongoose.model('Revision');
+    Tag = mongoose.model('Tag');
+    PageTagRelation = mongoose.model('PageTagRelation');
+    Bookmark = mongoose.model('Bookmark');
+    Comment = mongoose.model('Comment');
+    ShareLink = mongoose.model('ShareLink');
+    PageRedirect = mongoose.model('PageRedirect');
+
+    dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
+    if (dummyUser1 == null) {
+      dummyUser1 = await User.create({ name: 'v5DummyUser1', username: 'v5DummyUser1', email: 'v5DummyUser1@example.com' });
+    }
+
+    rootPage = await Page.findOne({ path: '/' });
+
+    const createPageId1 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: createPageId1,
+        path: '/v5_empty_create_4',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        path: '/v5_empty_create_4/v5_create_5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: createPageId1,
+      },
+    ]);
+
+  });
+  describe('create', () => {
+
+    test('Should create single page', async() => {
+      const page = await Page.create('/v5_create1', 'create1', dummyUser1, {});
+      expect(page).toBeTruthy();
+      expect(page.parent).toStrictEqual(rootPage._id);
+    });
+
+    test('Should create empty-child and non-empty grandchild', async() => {
+      const grandchildPage = await Page.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
+      const childPage = await Page.findOne({ path: '/v5_empty_create2' });
+
+      expect(childPage.isEmpty).toBe(true);
+      expect(grandchildPage).toBeTruthy();
+      expect(childPage).toBeTruthy();
+      expect(childPage.parent).toStrictEqual(rootPage._id);
+      expect(grandchildPage.parent).toStrictEqual(childPage._id);
+    });
+
+    test('Should create on empty page', async() => {
+      const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
+      expect(beforeCreatePage.isEmpty).toBe(true);
+
+      const childPage = await Page.create('/v5_empty_create_4', 'body', dummyUser1, {});
+      const grandchildPage = await Page.findOne({ parent: childPage._id });
+
+      expect(childPage).toBeTruthy();
+      expect(childPage.isEmpty).toBe(false);
+      expect(childPage.revision.body).toBe('body');
+      expect(grandchildPage).toBeTruthy();
+      expect(childPage.parent).toStrictEqual(rootPage._id);
+      expect(grandchildPage.parent).toStrictEqual(childPage._id);
+    });
+
+  });
+});

+ 0 - 10
packages/app/test/integration/service/page-grant.test.js

@@ -110,16 +110,6 @@ describe('PageGrantService', () => {
 
     // Root page (Depth: 0)
     rootPage = await Page.findOne({ path: '/' });
-    if (rootPage == null) {
-      const pages = await Page.insertMany([
-        {
-          path: '/',
-          grant: Page.GRANT_PUBLIC,
-        },
-      ]);
-      rootPage = pages[0];
-    }
-
 
     // Empty pages (Depth: 1)
     await Page.insertMany([

+ 1 - 6
packages/app/test/integration/service/v5.migration.test.js

@@ -28,7 +28,7 @@ describe('V5 page migration', () => {
       jest.restoreAllMocks();
 
       // initialize pages for test
-      let pages = await Page.insertMany([
+      const pages = await Page.insertMany([
         {
           path: '/private1',
           grant: Page.GRANT_OWNER,
@@ -59,11 +59,6 @@ describe('V5 page migration', () => {
         },
       ]);
 
-      if (!await Page.exists({ path: '/' })) {
-        const additionalPages = await Page.insertMany([{ path: '/', grant: Page.GRANT_PUBLIC }]);
-        pages = [...additionalPages, ...pages];
-      }
-
       const pageIds = pages.map(page => page._id);
       // migrate
       await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, testUser1);

+ 526 - 20
packages/app/test/integration/service/v5.page.test.ts

@@ -24,6 +24,9 @@ describe('PageService page operations with only public pages', () => {
 
   let rootPage;
 
+  /* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expectAllToBeTruthy"] }] */
+  // https://github.com/jest-community/eslint-plugin-jest/blob/v24.3.5/docs/rules/expect-expect.md#assertfunctionnames
+
   // pass unless the data is one of [false, 0, '', null, undefined, NaN]
   const expectAllToBeTruthy = (dataList) => {
     dataList.forEach((data) => {
@@ -48,19 +51,9 @@ describe('PageService page operations with only public pages', () => {
     /*
      * Common
      */
-    await User.insertMany([
-      { name: 'v5DummyUser1', username: 'v5DummyUser1', email: 'v5DummyUser1@example.com' },
-      { name: 'v5DummyUser2', username: 'v5DummyUser2', email: 'v5DummyUser2@example.com' },
-    ]);
 
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
-    if (dummyUser1 == null) {
-      dummyUser1 = await User.create({ name: 'v5DummyUser1', username: 'v5DummyUser1', email: 'v5DummyUser1@example.com' });
-    }
     dummyUser2 = await User.findOne({ username: 'v5DummyUser2' });
-    if (dummyUser2 == null) {
-      dummyUser2 = await User.create({ name: 'v5DummyUser2', username: 'v5DummyUser2', email: 'v5DummyUser2@example.com' });
-    }
 
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
 
@@ -231,7 +224,276 @@ describe('PageService page operations with only public pages', () => {
       },
     ]);
 
+    /**
+     * Delete
+     */
+    const pageIdForDelete1 = new mongoose.Types.ObjectId();
+    const pageIdForDelete2 = new mongoose.Types.ObjectId();
+    const pageIdForDelete3 = new mongoose.Types.ObjectId();
+    const pageIdForDelete4 = new mongoose.Types.ObjectId();
+    const pageIdForDelete5 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        path: '/trash/v5_PageForDelete1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        status: Page.STATUS_DELETED,
+      },
+      {
+        path: '/v5_PageForDelete2',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDelete1,
+        path: '/v5_PageForDelete3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDelete2,
+        path: '/v5_PageForDelete3/v5_PageForDelete4',
+        grant: Page.GRANT_PUBLIC,
+        parent: pageIdForDelete1,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: true,
+      },
+      {
+        path: '/v5_PageForDelete3/v5_PageForDelete4/v5_PageForDelete5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDelete2,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDelete3,
+        path: '/v5_PageForDelete6',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDelete4,
+        path: '/user',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdForDelete5,
+        path: '/user/v5DummyUser1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDelete4,
+        status: Page.STATUS_PUBLISHED,
+      },
+    ]);
+
+    const tagIdForDelete1 = new mongoose.Types.ObjectId();
+    const tagIdForDelete2 = new mongoose.Types.ObjectId();
+
+    await Tag.insertMany([
+      { _id: tagIdForDelete1, name: 'TagForDelete1' },
+      { _id: tagIdForDelete2, name: 'TagForDelete2' },
+    ]);
+
+    await PageTagRelation.insertMany([
+      { relatedPage: pageIdForDelete3, relatedTag: tagIdForDelete1 },
+      { relatedPage: pageIdForDelete3, relatedTag: tagIdForDelete2 },
+    ]);
+
+    /**
+     * Delete completely
+     */
+    const pageIdForDeleteCompletely1 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely2 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely3 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely4 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely5 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely6 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely7 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely8 = new mongoose.Types.ObjectId();
+
+    const revisionIdForDeleteCompletely1 = new mongoose.Types.ObjectId();
+    const revisionIdForDeleteCompletely2 = new mongoose.Types.ObjectId();
+    const revisionIdForDeleteCompletely3 = new mongoose.Types.ObjectId();
+    const revisionIdForDeleteCompletely4 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageIdForDeleteCompletely1,
+        path: '/v5_PageForDeleteCompletely1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDeleteCompletely2,
+        path: '/v5_PageForDeleteCompletely2',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDeleteCompletely3,
+        path: '/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3',
+        grant: Page.GRANT_PUBLIC,
+        parent: pageIdForDeleteCompletely2,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdForDeleteCompletely4,
+        path: '/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3/v5_PageForDeleteCompletely4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDeleteCompletely3,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDeleteCompletely5,
+        path: '/trash/v5_PageForDeleteCompletely5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        status: Page.STATUS_DELETED,
+      },
+      {
+        _id: pageIdForDeleteCompletely6,
+        path: '/v5_PageForDeleteCompletely6',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDeleteCompletely7,
+        path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDeleteCompletely6,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDeleteCompletely8,
+        path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDeleteCompletely7,
+        status: Page.STATUS_PUBLISHED,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: revisionIdForDeleteCompletely1,
+        format: 'markdown',
+        pageId: pageIdForDeleteCompletely2,
+        body: 'pageIdForDeleteCompletely2',
+      },
+      {
+        _id: revisionIdForDeleteCompletely2,
+        format: 'markdown',
+        pageId: pageIdForDeleteCompletely4,
+        body: 'pageIdForDeleteCompletely4',
+      },
+      {
+        _id: revisionIdForDeleteCompletely3,
+        format: 'markdown',
+        pageId: pageIdForDeleteCompletely5,
+        body: 'pageIdForDeleteCompletely5',
+      },
+      {
+        _id: revisionIdForDeleteCompletely4,
+        format: 'markdown',
+        pageId: pageIdForDeleteCompletely2,
+        body: 'comment_pageIdForDeleteCompletely3',
+      },
+    ]);
+
+    const tagForDeleteCompletely1 = new mongoose.Types.ObjectId();
+    const tagForDeleteCompletely2 = new mongoose.Types.ObjectId();
+    await Tag.insertMany([
+      { name: 'TagForDeleteCompletely1' },
+      { name: 'TagForDeleteCompletely2' },
+    ]);
+
+    await PageTagRelation.insertMany([
+      { relatedPage: pageIdForDeleteCompletely2, relatedTag: tagForDeleteCompletely1 },
+      { relatedPage: pageIdForDeleteCompletely4, relatedTag: tagForDeleteCompletely2 },
+    ]);
+
+    await Bookmark.insertMany([
+      {
+        page: pageIdForDeleteCompletely2,
+        user: dummyUser1._id,
+      },
+      {
+        page: pageIdForDeleteCompletely2,
+        user: dummyUser2._id,
+      },
+    ]);
+
+    await Comment.insertMany([
+      {
+        commentPosition: -1,
+        isMarkdown: true,
+        page: pageIdForDeleteCompletely2,
+        creator: dummyUser1._id,
+        revision: revisionIdForDeleteCompletely4,
+        comment: 'comment_ForDeleteCompletely4',
+      },
+    ]);
 
+    await PageRedirect.insertMany([
+      {
+        fromPath: '/from/v5_PageForDeleteCompletely2',
+        toPath: '/v5_PageForDeleteCompletely2',
+      },
+      {
+        fromPath: '/from/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3/v5_PageForDeleteCompletely4',
+        toPath: '/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3/v5_PageForDeleteCompletely4',
+      },
+    ]);
+
+    await ShareLink.insertMany([
+      {
+        relatedPage: pageIdForDeleteCompletely2,
+        expiredAt: null,
+        description: 'sharlink_v5PageForDeleteCompletely2',
+      },
+      {
+        relatedPage: pageIdForDeleteCompletely4,
+        expiredAt: null,
+        description: 'sharlink_v5PageForDeleteCompletely4',
+      },
+    ]);
+
+    /**
+     * Revert
+     */
     const pageIdForRevert1 = new mongoose.Types.ObjectId();
     const pageIdForRevert2 = new mongoose.Types.ObjectId();
     const pageIdForRevert3 = new mongoose.Types.ObjectId();
@@ -351,10 +613,12 @@ describe('PageService page operations with only public pages', () => {
 
       const newPath = '/v5_ParentForRename1/renamedChildForRename1';
       const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename1' });
 
       expect(xssSpy).toHaveBeenCalled();
       expect(renamedPage.path).toBe(newPath);
       expect(renamedPage.parent).toStrictEqual(parentPage._id);
+      expect(childPageBeforeRename).toBeNull();
 
     });
 
@@ -366,11 +630,13 @@ describe('PageService page operations with only public pages', () => {
 
       const newPath = '/v5_ParentForRename2/renamedChildForRename2';
       const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename2' });
 
       expect(xssSpy).toHaveBeenCalled();
       expect(renamedPage.path).toBe(newPath);
       expect(parentPage.isEmpty).toBe(true);
       expect(renamedPage.parent).toStrictEqual(parentPage._id);
+      expect(childPageBeforeRename).toBeNull();
     });
 
     test('Should rename/move with option updateMetadata: true', async() => {
@@ -412,40 +678,47 @@ describe('PageService page operations with only public pages', () => {
     test('Should rename/move with descendants', async() => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename5' });
       const childPage = await Page.findOne({ path: '/v5_ChildForRename5' });
+      const grandchild = await Page.findOne({ parent: childPage._id, path: '/v5_ChildForRename5/v5_GrandchildForRename5' });
 
-      expectAllToBeTruthy([parentPage, childPage]);
+      expectAllToBeTruthy([parentPage, childPage, grandchild]);
 
       const newPath = '/v5_ParentForRename5/renamedChildForRename5';
       const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
       // find child of renamed page
-      const grandchild = await Page.findOne({ parent: renamedPage._id });
+      const renamedGrandchild = await Page.findOne({ parent: renamedPage._id });
+      const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename5' });
+      const grandchildBeforeRename = await Page.findOne({ path: grandchild.path });
 
       expect(xssSpy).toHaveBeenCalled();
       expect(renamedPage.path).toBe(newPath);
       expect(renamedPage.parent).toStrictEqual(parentPage._id);
+      expect(childPageBeforeRename).toBeNull();
+      expect(grandchildBeforeRename).toBeNull();
       // grandchild's parent should be the renamed page
-      expect(grandchild.parent).toStrictEqual(renamedPage._id);
-      expect(grandchild.path).toBe('/v5_ParentForRename5/renamedChildForRename5/v5_GrandchildForRename5');
+      expect(renamedGrandchild.parent).toStrictEqual(renamedPage._id);
+      expect(renamedGrandchild.path).toBe('/v5_ParentForRename5/renamedChildForRename5/v5_GrandchildForRename5');
     });
 
     test('Should rename/move empty page', async() => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename7' });
-      const childPage = await Page.findOne({ path: '/v5_ChildForRename7' });
+      const childPage = await Page.findOne({ path: '/v5_ChildForRename7', isEmpty: true });
+      const grandchild = await Page.findOne({ parent: childPage._id, path: '/v5_ChildForRename7/v5_GrandchildForRename7' });
 
-      expectAllToBeTruthy([parentPage, childPage]);
-      expect(childPage.isEmpty).toBe(true);
+      expectAllToBeTruthy([parentPage, childPage, grandchild]);
 
       const newPath = '/v5_ParentForRename7/renamedChildForRename7';
       const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
-      const grandchild = await Page.findOne({ parent: renamedPage._id });
+      const grandchildAfterRename = await Page.findOne({ parent: renamedPage._id });
+      const grandchildBeforeRename = await Page.findOne({ path: '/v5_ChildForRename7/v5_GrandchildForRename7' });
 
       expect(xssSpy).toHaveBeenCalled();
       expect(renamedPage.path).toBe(newPath);
       expect(renamedPage.isEmpty).toBe(true);
       expect(renamedPage.parent).toStrictEqual(parentPage._id);
+      expect(grandchildBeforeRename).toBeNull();
       // grandchild's parent should be renamed page
-      expect(grandchild.parent).toStrictEqual(renamedPage._id);
-      expect(grandchild.path).toBe('/v5_ParentForRename7/renamedChildForRename7/v5_GrandchildForRename7');
+      expect(grandchildAfterRename.parent).toStrictEqual(renamedPage._id);
+      expect(grandchildAfterRename.path).toBe('/v5_ParentForRename7/renamedChildForRename7/v5_GrandchildForRename7');
     });
     test('Should NOT rename/move with existing path', async() => {
       const page = await Page.findOne({ path: '/v5_ParentForRename8' });
@@ -463,6 +736,239 @@ describe('PageService page operations with only public pages', () => {
       expect(isThrown).toBe(true);
     });
   });
+  describe('Delete', () => {
+    const deletePage = async(page, user, options, isRecursively) => {
+      const mockedResumableDeleteDescendants = jest.spyOn(crowi.pageService, 'resumableDeleteDescendants').mockReturnValue(null);
+      const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
+
+      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively);
+
+      const argsForResumableDeleteDescendants = mockedResumableDeleteDescendants.mock.calls[0];
+
+      mockedResumableDeleteDescendants.mockRestore();
+      mockedCreateAndSendNotifications.mockRestore();
+
+      if (isRecursively) {
+        await crowi.pageService.resumableDeleteDescendants(...argsForResumableDeleteDescendants);
+      }
+
+      return deletedPage;
+    };
+
+    test('Should NOT delete root page', async() => {
+      let isThrown;
+      expectAllToBeTruthy([rootPage]);
+
+      try { await deletePage(rootPage, dummyUser1, {}, false) }
+      catch (err) { isThrown = true }
+
+      const page = await Page.findOne({ path: '/' });
+
+      expect(isThrown).toBe(true);
+      expect(page).toBeTruthy();
+    });
+
+    test('Should NOT delete trashed page', async() => {
+      const trashedPage = await Page.findOne({ path: '/trash/v5_PageForDelete1' });
+      expectAllToBeTruthy([trashedPage]);
+
+      let isThrown;
+      try { await deletePage(trashedPage, dummyUser1, {}, false) }
+      catch (err) { isThrown = true }
+
+      const page = await Page.findOne({ path: '/trash/v5_PageForDelete1' });
+
+      expect(page).toBeTruthy();
+      expect(isThrown).toBe(true);
+    });
+
+    test('Should NOT delete /user/hoge page', async() => {
+      const dummyUser1Page = await Page.findOne({ path: '/user/v5DummyUser1' });
+      expectAllToBeTruthy([dummyUser1Page]);
+
+      let isThrown;
+      try { await deletePage(dummyUser1Page, dummyUser1, {}, false) }
+      catch (err) { isThrown = true }
+
+      const page = await Page.findOne({ path: '/user/v5DummyUser1' });
+
+      expect(page).toBeTruthy();
+      expect(isThrown).toBe(true);
+    });
+
+    test('Should delete single page', async() => {
+      const pageToDelete = await Page.findOne({ path: '/v5_PageForDelete2' });
+      expectAllToBeTruthy([pageToDelete]);
+
+      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false);
+      const page = await Page.findOne({ path: '/v5_PageForDelete2' });
+
+      expect(page).toBeNull();
+      expect(deletedPage.path).toBe(`/trash${pageToDelete.path}`);
+      expect(deletedPage.parent).toBeNull();
+      expect(deletedPage.status).toBe(Page.STATUS_DELETED);
+    });
+
+    test('Should delete multiple pages including empty child', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_PageForDelete3' });
+      const childPage = await Page.findOne({ path: '/v5_PageForDelete3/v5_PageForDelete4' });
+      const grandchildPage = await Page.findOne({ path: '/v5_PageForDelete3/v5_PageForDelete4/v5_PageForDelete5' });
+      expectAllToBeTruthy([parentPage, childPage, grandchildPage]);
+
+      const deletedParentPage = await deletePage(parentPage, dummyUser1, {}, true);
+      const deletedChildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4' });
+      const deletedGrandchildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4/v5_PageForDelete5' });
+
+      // originally NOT empty page should exist with status 'deleted' and parent set null
+      expect(deletedParentPage._id).toStrictEqual(parentPage._id);
+      expect(deletedParentPage.status).toBe(Page.STATUS_DELETED);
+      expect(deletedParentPage.parent).toBeNull();
+      // originally empty page should NOT exist
+      expect(deletedChildPage).toBeNull();
+      // originally NOT empty page should exist with status 'deleted' and parent set null
+      expect(deletedGrandchildPage._id).toStrictEqual(grandchildPage._id);
+      expect(deletedGrandchildPage.status).toBe(Page.STATUS_DELETED);
+      expect(deletedGrandchildPage.parent).toBeNull();
+    });
+
+    test('Should delete page tag relation', async() => {
+      const pageToDelete = await Page.findOne({ path: '/v5_PageForDelete6' });
+      const tag1 = await Tag.findOne({ name: 'TagForDelete1' });
+      const tag2 = await Tag.findOne({ name: 'TagForDelete2' });
+      const pageRelation1 = await PageTagRelation.findOne({ relatedTag: tag1._id });
+      const pageRelation2 = await PageTagRelation.findOne({ relatedTag: tag2._id });
+      expectAllToBeTruthy([pageToDelete, tag1, tag2, pageRelation1, pageRelation2]);
+
+      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false);
+      const page = await Page.findOne({ path: '/v5_PageForDelete6' });
+      const deletedTagRelation1 = await PageTagRelation.findOne({ _id: pageRelation1._id });
+      const deletedTagRelation2 = await PageTagRelation.findOne({ _id: pageRelation2._id });
+
+      expect(page).toBe(null);
+      expect(deletedPage.status).toBe(Page.STATUS_DELETED);
+      expect(deletedTagRelation1.isPageTrashed).toBe(true);
+      expect(deletedTagRelation2.isPageTrashed).toBe(true);
+    });
+  });
+
+  describe('Delete completely', () => {
+    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
+      const mockedResumableDeleteCompletelyDescendants = jest.spyOn(crowi.pageService, 'resumableDeleteCompletelyDescendants').mockReturnValue(null);
+      const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
+
+      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting);
+
+      const argsForResumableDeleteDescendants = mockedResumableDeleteCompletelyDescendants.mock.calls[0];
+
+      mockedResumableDeleteCompletelyDescendants.mockRestore();
+      mockedCreateAndSendNotifications.mockRestore();
+
+      if (isRecursively) {
+        await crowi.pageService.resumableDeleteCompletelyDescendants(...argsForResumableDeleteDescendants);
+      }
+
+      return;
+    };
+
+    test('Should NOT completely delete root page', async() => {
+      expectAllToBeTruthy([rootPage]);
+      let isThrown;
+      try { await deleteCompletely(rootPage, dummyUser1, {}, false) }
+      catch (err) { isThrown = true }
+      const page = await Page.findOne({ path: '/' });
+      expect(page).toBeTruthy();
+      expect(isThrown).toBe(true);
+    });
+    test('Should completely delete single page', async() => {
+      const page = await Page.findOne({ path: '/v5_PageForDeleteCompletely1' });
+      expectAllToBeTruthy([page]);
+
+      await deleteCompletely(page, dummyUser1, {}, false);
+      const deletedPage = await Page.findOne({ _id: page._id, path: '/v5_PageForDeleteCompletely1' });
+
+      expect(deletedPage).toBeNull();
+    });
+    test('Should completely delete multiple pages', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely2' });
+      const childPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3' });
+      const grandchildPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3/v5_PageForDeleteCompletely4' });
+      const tag1 = await Tag.findOne({ name: 'TagForDeleteCompletely1' });
+      const tag2 = await Tag.findOne({ name: 'TagForDeleteCompletely2' });
+      const pageTagRelation1 = await PageTagRelation.findOne({ relatedPage: parentPage._id });
+      const pageTagRelation2 = await PageTagRelation.findOne({ relatedPage: grandchildPage._id });
+      const bookmark = await Bookmark.findOne({ page: parentPage._id });
+      const comment = await Comment.findOne({ page: parentPage._id });
+      const pageRedirect1 = await PageRedirect.findOne({ toPath: parentPage.path });
+      const pageRedirect2 = await PageRedirect.findOne({ toPath: grandchildPage.path });
+      const shareLink1 = await ShareLink.findOne({ relatedPage: parentPage._id });
+      const shareLink2 = await ShareLink.findOne({ relatedPage: grandchildPage._id });
+
+      expectAllToBeTruthy(
+        [parentPage, childPage, grandchildPage, tag1, tag2,
+         pageTagRelation1, pageTagRelation2, bookmark, comment,
+         pageRedirect1, pageRedirect2, shareLink1, shareLink2],
+      );
+
+      await deleteCompletely(parentPage, dummyUser1, {}, true);
+      const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
+      const deletedRevisions = await Revision.find({ pageId: { $in: [parentPage._id, grandchildPage._id] } });
+      const tags = await Tag.find({ _id: { $in: [tag1._id, tag2._id] } });
+      const deletedPageTagRelations = await PageTagRelation.find({ _id: { $in: [pageTagRelation1._id, pageTagRelation2._id] } });
+      const deletedBookmarks = await Bookmark.find({ _id: bookmark._id });
+      const deletedComments = await Comment.find({ _id: comment._id });
+      const deletedPageRedirects = await PageRedirect.find({ _id: { $in: [pageRedirect1._id, pageRedirect2._id] } });
+      const deletedShareLinks = await ShareLink.find({ _id: { $in: [shareLink1._id, shareLink2._id] } });
+
+      // page should be null
+      expect(deletedPages.length).toBe(0);
+      // revision should be null
+      expect(deletedRevisions.length).toBe(0);
+      // tag should be Truthy
+      expectAllToBeTruthy(tags);
+      // pageTagRelation should be null
+      expect(deletedPageTagRelations.length).toBe(0);
+      // bookmark should be null
+      expect(deletedBookmarks.length).toBe(0);
+      // comment should be null
+      expect(deletedComments.length).toBe(0);
+      // pageRedirect should be null
+      expect(deletedPageRedirects.length).toBe(0);
+      // sharelink should be null
+      expect(deletedShareLinks.length).toBe(0);
+    });
+    test('Should completely delete trashed page', async() => {
+      const page = await Page.findOne({ path: '/trash/v5_PageForDeleteCompletely5' });
+      const revision = await Revision.findOne({ pageId: page._id });
+      expectAllToBeTruthy([page, revision]);
+
+      await deleteCompletely(page, dummyUser1, {}, false);
+      const deltedPage = await Page.findOne({ _id: page._id });
+      const deltedRevision = await Revision.findOne({ _id: revision._id });
+
+      expect(deltedPage).toBeNull();
+      expect(deltedRevision).toBeNull();
+    });
+    test('Should completely deleting page in the middle results in having an empty page', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely6' });
+      const childPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7' });
+      const grandchildPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8' });
+      expectAllToBeTruthy([parentPage, childPage, grandchildPage]);
+
+      await deleteCompletely(childPage, dummyUser1, {}, false);
+      const parentPageAfterDelete = await Page.findOne({ path: parentPage.path });
+      const childPageAfterDelete = await Page.findOne({ path: childPage.path });
+      const grandchildPageAfterDelete = await Page.findOne({ path: grandchildPage.path });
+      const childOfDeletedPage = await Page.findOne({ parent: childPageAfterDelete._id });
+
+      expectAllToBeTruthy([parentPageAfterDelete, childPageAfterDelete, grandchildPageAfterDelete]);
+      expect(childPageAfterDelete._id).not.toStrictEqual(childPage._id);
+      expect(childPageAfterDelete.isEmpty).toBe(true);
+      expect(childPageAfterDelete.parent).toStrictEqual(parentPage._id);
+      expect(childOfDeletedPage._id).toStrictEqual(grandchildPage._id);
+
+    });
+  });
+
 
   describe('revert', () => {
     const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {

+ 5 - 0
packages/core/src/test/util/page-path-utils.test.js

@@ -94,6 +94,11 @@ describe('isCreatablePage test', () => {
     expect(isCreatablePage('http://demo.growi.org/hoge')).toBeFalsy();
     expect(isCreatablePage('https://demo.growi.org/hoge')).toBeFalsy();
 
+    expect(isCreatablePage('/_search')).toBeFalsy();
+    expect(isCreatablePage('/_search/foo')).toBeFalsy();
+    expect(isCreatablePage('/_private-legacy-pages')).toBeFalsy();
+    expect(isCreatablePage('/_private-legacy-pages/foo')).toBeFalsy();
+
     expect(isCreatablePage('/ the / path / with / space')).toBeFalsy();
 
     const forbidden = ['installer', 'register', 'login', 'logout',

+ 1 - 0
packages/core/src/utils/page-path-utils.ts

@@ -81,6 +81,7 @@ const restrictedPatternsToCreate: Array<RegExp> = [
   /.+\.md$/,
   /^(\.\.)$/, // see: https://github.com/weseek/growi/issues/3582
   /(\/\.\.)\/?/, // see: https://github.com/weseek/growi/issues/3582
+  /^\/(_search|_private-legacy-pages)(\/.*|$)/,
   /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share)(\/.*|$)/,
 ];
 export const isCreatablePage = (path: string): boolean => {

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

@@ -24,7 +24,7 @@
   "dependencies": {
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
-    "http-errors": "^1.8.0",
+    "http-errors": "^2.0.0",
     "react-images": "~1.0.0",
     "react-motion": "^0.5.2",
     "universal-bunyan": "^0.9.2"

+ 1 - 1
packages/slack/package.json

@@ -18,7 +18,7 @@
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "extensible-custom-error": "^0.0.7",
-    "http-errors": "^1.8.0",
+    "http-errors": "^2.0.0",
     "qs": "^6.10.2",
     "universal-bunyan": "^0.9.2",
     "url-join": "^4.0.0"

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

@@ -42,7 +42,7 @@
     "express-bunyan-logger": "^1.3.3",
     "extensible-custom-error": "^0.0.7",
     "helmet": "^4.6.0",
-    "http-errors": "^1.8.0",
+    "http-errors": "^2.0.0",
     "method-override": "^3.0.0",
     "mysql2": "^2.2.5",
     "read-pkg-up": "^7.0.1",

+ 21 - 11
yarn.lock

@@ -7162,14 +7162,14 @@ depd@1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359"
 
+depd@2.0.0, depd@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+
 depd@^1.1.2, depd@~1.1.1, depd@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
 
-depd@~2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
-
 deprecation@^2.0.0, deprecation@^2.3.1:
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
@@ -10243,16 +10243,16 @@ http-errors@1.7.3, http-errors@~1.7.2:
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
-http-errors@^1.8.0, http-errors@~1.8.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.0.tgz#75d1bbe497e1044f51e4ee9e704a62f28d336507"
-  integrity sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==
+http-errors@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
+  integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
   dependencies:
-    depd "~1.1.2"
+    depd "2.0.0"
     inherits "2.0.4"
     setprototypeof "1.2.0"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
+    statuses "2.0.1"
+    toidentifier "1.0.1"
 
 http-proxy-agent@^4.0.0, http-proxy-agent@^4.0.1:
   version "4.0.1"
@@ -18926,6 +18926,11 @@ static-extend@^0.1.1:
     define-property "^0.2.5"
     object-copy "^0.1.0"
 
+statuses@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
+  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
 "statuses@>= 1.3.1 < 2", "statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
@@ -20225,6 +20230,11 @@ toidentifier@1.0.0:
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
   integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
 
+toidentifier@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
 tough-cookie@^2.3.3, tough-cookie@~2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"