Przeglądaj źródła

Resolved conflict

Taichi Masuyama 4 lat temu
rodzic
commit
3ab2380102
46 zmienionych plików z 1983 dodań i 229 usunięć
  1. 7 0
      .devcontainer/docker-compose.yml
  2. 7 1
      CHANGELOG.md
  3. 1 0
      packages/app/.env.development
  4. 5 0
      packages/app/docker/README.md
  5. 1 1
      packages/app/package.json
  6. 2 0
      packages/app/src/client/base.jsx
  7. 30 4
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  8. 1 1
      packages/app/src/components/LoginForm.jsx
  9. 2 6
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  10. 5 15
      packages/app/src/components/Page/TrashPageAlert.jsx
  11. 3 2
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  12. 16 5
      packages/app/src/components/PageList/PageListItemL.tsx
  13. 1 1
      packages/app/src/components/PrivateLegacyPages.tsx
  14. 7 9
      packages/app/src/components/PutbackPageModal.jsx
  15. 1 1
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  16. 1 1
      packages/app/src/components/SearchTypeahead.tsx
  17. 2 2
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  18. 1 0
      packages/app/src/interfaces/page.ts
  19. 1 16
      packages/app/src/server/middlewares/http-error-handler.js
  20. 17 5
      packages/app/src/server/models/obsolete-page.js
  21. 1 1
      packages/app/src/server/models/page.ts
  22. 1 1
      packages/app/src/server/models/subscription.ts
  23. 9 2
      packages/app/src/server/routes/apiv3/forgot-password.js
  24. 13 47
      packages/app/src/server/routes/apiv3/page.js
  25. 2 2
      packages/app/src/server/routes/apiv3/pages.js
  26. 32 2
      packages/app/src/server/routes/forgot-password.ts
  27. 5 1
      packages/app/src/server/routes/index.js
  28. 148 0
      packages/app/src/server/routes/ogp.ts
  29. 2 12
      packages/app/src/server/routes/page.js
  30. 6 0
      packages/app/src/server/service/config-loader.ts
  31. 95 35
      packages/app/src/server/service/page.ts
  32. 1 3
      packages/app/src/server/service/search-delegator/private-legacy-pages.ts
  33. 14 0
      packages/app/src/server/util/stream.ts
  34. 6 0
      packages/app/src/server/views/forgot-password/error.html
  35. 10 0
      packages/app/src/server/views/layout-growi/page.html
  36. 1 0
      packages/app/src/server/views/layout/layout.html
  37. 29 4
      packages/app/src/stores/modal.tsx
  38. 0 11
      packages/app/test/integration/models/page.test.js
  39. 98 0
      packages/app/test/integration/models/v5.page.test.js
  40. 1338 0
      packages/app/test/integration/service/v5.page.test.ts
  41. 11 1
      packages/core/src/test/util/page-path-utils.test.js
  42. 26 23
      packages/core/src/utils/page-path-utils.ts
  43. 1 1
      packages/plugin-attachment-refs/package.json
  44. 1 1
      packages/slack/package.json
  45. 1 1
      packages/slackbot-proxy/package.json
  46. 21 11
      yarn.lock

+ 7 - 0
.devcontainer/docker-compose.yml

@@ -34,6 +34,13 @@ 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:

+ 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

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

@@ -18,6 +18,7 @@ ELASTICSEARCH_REQUEST_TIMEOUT=15000
 #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",

+ 2 - 0
packages/app/src/client/base.jsx

@@ -12,6 +12,7 @@ import PageDuplicateModal from '../components/PageDuplicateModal';
 import PageRenameModal from '../components/PageRenameModal';
 import PagePresentationModal from '../components/PagePresentationModal';
 import PageAccessoriesModal from '../components/PageAccessoriesModal';
+import PutbackPageModal from '~/components/PutbackPageModal';
 
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
@@ -52,6 +53,7 @@ const componentMappings = {
   'page-presentation-modal': <PagePresentationModal />,
   'page-accessories-modal': <PageAccessoriesModal />,
   'descendants-page-list-modal': <DescendantsPageListModal />,
+  'page-put-back-modal': <PutbackPageModal />,
 
   'grw-hotkeys-manager': <HotkeysManager />,
 

+ 30 - 4
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -20,6 +20,7 @@ export const MenuItemType = {
   DUPLICATE: 'duplicate',
   RENAME: 'rename',
   DELETE: 'delete',
+  REVERT: 'revert',
 } as const;
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
@@ -35,7 +36,8 @@ type CommonProps = {
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickDeleteMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
 }
@@ -52,7 +54,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const {
     pageId, isLoading,
     pageInfo, isEnableActions, forceHideMenuItems,
-    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
     additionalMenuItemRenderer: AdditionalMenuItems,
   } = props;
 
@@ -81,6 +83,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickRenameMenuItem(pageId);
   }, [onClickRenameMenuItem, pageId]);
 
+  const revertItemClickedHandler = useCallback(async() => {
+    if (onClickRevertMenuItem == null) {
+      return;
+    }
+    await onClickRevertMenuItem(pageId);
+  }, [onClickRevertMenuItem]);
+
+
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
     if (pageInfo == null || onClickDeleteMenuItem == null) {
@@ -90,7 +100,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
       logger.warn('This page could not be deleted.');
       return;
     }
-    await onClickDeleteMenuItem(pageId);
+    await onClickDeleteMenuItem(pageId, pageInfo);
   }, [onClickDeleteMenuItem, pageId, pageInfo]);
 
   let contents = <></>;
@@ -141,6 +151,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
         ) }
 
+        {/* Revert */}
+        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
+          <DropdownItem onClick={revertItemClickedHandler}>
+            <i className="icon-fw  icon-action-undo"></i>
+            {t('modal_putback.label.Put Back Page')}
+          </DropdownItem>
+        ) }
+
         { AdditionalMenuItems && (
           <>
             { showDeviderBeforeAdditionalMenuItems && <DropdownItem divider /> }
@@ -186,7 +204,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const {
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     children,
-    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
@@ -223,6 +241,13 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     await onClickRenameMenuItem(pageId);
   }, [onClickRenameMenuItem, pageId]);
 
+  const deleteMenuItemClickHandler = useCallback(async() => {
+    if (onClickDeleteMenuItem == null) {
+      return;
+    }
+    await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
+  }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
+
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
 
@@ -239,6 +264,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
         onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickRenameMenuItem={renameMenuItemClickHandler}
+        onClickDeleteMenuItem={deleteMenuItemClickHandler}
       />
     </Dropdown>
   );

+ 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')}

+ 2 - 6
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -15,7 +15,6 @@ import {
   usePageAccessoriesModal, PageAccessoriesModalContents,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, OnDeletedFunction, usePagePresentationModal,
 } from '~/stores/modal';
-import { useSWRxPageChildren } from '~/stores/page-listing';
 
 
 import {
@@ -143,7 +142,6 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
-  const { mutate: mutateChildren } = useSWRxPageChildren(path);
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
 
   const { open: openDuplicateModal } = usePageDuplicateModal();
@@ -193,8 +191,6 @@ const GrowiContextualSubNavigation = (props) => {
       return;
     }
 
-    mutateChildren();
-
     const path = pathOrPathsToDelete;
 
     if (isCompletely) {
@@ -204,10 +200,10 @@ const GrowiContextualSubNavigation = (props) => {
     else {
       window.location.reload();
     }
-  }, [mutateChildren]);
+  }, []);
 
   const deleteItemClickedHandler = useCallback(async(pageToDelete, isAbleToDeleteCompletely) => {
-    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
+    openDeleteModal([pageToDelete], isAbleToDeleteCompletely, onDeletedHandler);
   }, [onDeletedHandler, openDeleteModal]);
 
   const templateMenuItemClickHandler = useCallback(() => {

+ 5 - 15
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -7,11 +7,11 @@ import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
-import PutbackPageModal from '../PutbackPageModal';
+
 import EmptyTrashModal from '../EmptyTrashModal';
 
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
-import { usePageDeleteModal } from '~/stores/modal';
+import { usePageDeleteModal, usePutBackPageMOdal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 
 const TrashPageAlert = (props) => {
@@ -30,7 +30,6 @@ const TrashPageAlert = (props) => {
 
   const { data: updatedAt } = useCurrentUpdatedAt();
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
-  const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
   const [isAbleToDeleteCompletely, setIsAbleToDeleteCompletely] = useState(false);
 
   useEffect(() => {
@@ -40,6 +39,7 @@ const TrashPageAlert = (props) => {
   }, [pageInfo]);
 
   const { open: openDeleteModal } = usePageDeleteModal();
+  const { open: openPutBackPageModal } = usePutBackPageMOdal();
 
   function openEmptyTrashModalHandler() {
     setIsEmptyTrashModalShown(true);
@@ -50,11 +50,7 @@ const TrashPageAlert = (props) => {
   }
 
   function openPutbackPageModalHandler() {
-    setIsPutbackPageModalShown(true);
-  }
-
-  function closePutbackPageModalHandler() {
-    setIsPutbackPageModalShown(false);
+    openPutBackPageModal(pageId, path);
   }
 
   const onDeletedHandler = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
@@ -73,7 +69,7 @@ const TrashPageAlert = (props) => {
       path,
     };
     const isDeleteCompletelyModal = true;
-    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely, isDeleteCompletelyModal);
+    openDeleteModal([pageToDelete], isAbleToDeleteCompletely, onDeletedHandler, isDeleteCompletelyModal);
   }
 
   function renderEmptyButton() {
@@ -120,12 +116,6 @@ const TrashPageAlert = (props) => {
           isOpen={isEmptyTrashModalShown}
           onClose={closeEmptyTrashModalHandler}
         />
-        <PutbackPageModal
-          isOpen={isPutbackPageModalShown}
-          onClose={closePutbackPageModalHandler}
-          pageId={pageId}
-          path={path}
-        />
       </>
     );
   }

+ 3 - 2
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -202,8 +202,9 @@ class LinkEditModal extends React.PureComponent {
   }
 
   handleChangeTypeahead(selected) {
-    const page = selected[0];
-    if (page != null) {
+    const pageWithMeta = selected[0];
+    if (pageWithMeta != null) {
+      const page = pageWithMeta.pageData;
       const permalink = `${window.location.origin}/${page.id}`;
       this.setState({ linkInputValue: page.path, permalink });
     }

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

@@ -12,7 +12,9 @@ import urljoin from 'url-join';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
-import { usePageRenameModal, usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
+import {
+  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, OnDeletedFunction, usePutBackPageMOdal,
+} from '~/stores/modal';
 import {
   IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
 } from '~/interfaces/page';
@@ -64,6 +66,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
+  const { open: openPutBackPageModal } = usePutBackPageMOdal();
 
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
@@ -96,10 +99,17 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openRenameModal(pageId, revisionId as string, path);
   }, [openRenameModal, pageData]);
 
-  const deleteMenuItemClickHandler = useCallback(() => {
+
+  const deleteMenuItemClickHandler = useCallback((_id, pageInfo) => {
     const { _id: pageId, revision: revisionId, path } = pageData;
-    openDeleteModal([{ pageId, revisionId: revisionId as string, path }]);
-  }, [openDeleteModal, pageData]);
+    const pageToDelete = { pageId, revisionId: revisionId as string, path };
+    openDeleteModal([pageToDelete], pageInfo.isAbleToDeleteCompletely);
+  }, [pageData, openDeleteModal]);
+
+  const revertMenuItemClickHandler = useCallback(() => {
+    const { _id: pageId, path } = pageData;
+    openPutBackPageModal(pageId, path);
+  }, [openPutBackPageModal, pageData]);
 
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'list-group-item'
@@ -165,9 +175,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   pageInfo={pageMeta}
                   isEnableActions={isEnableActions}
                   forceHideMenuItems={forceHideMenuItems}
-                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
+                  onClickRevertMenuItem={revertMenuItemClickHandler}
                 />
               </div>
             </div>

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

@@ -290,7 +290,7 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
         appContainer={appContainer}
         pages={data?.data}
         onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
-        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE]}
+        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE, MenuItemType.REVERT]}
         // Components
         searchControl={searchControl}
         searchResultListHead={searchResultListHead}

+ 7 - 9
packages/app/src/components/PutbackPageModal.jsx

@@ -7,15 +7,19 @@ import {
 
 import { withTranslation } from 'react-i18next';
 
+import { usePutBackPageMOdal } from '~/stores/modal';
 import { apiPost } from '~/client/util/apiv1-client';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 const PutBackPageModal = (props) => {
   const {
-    t, isOpen, onClose, pageId, path,
+    t,
   } = props;
 
+  const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageMOdal();
+  const { isOpened, pageId, path } = pageDataToRevert;
+
   const [errs, setErrs] = useState(null);
 
   const [isPutbackRecursively, setIsPutbackRecursively] = useState(true);
@@ -50,8 +54,8 @@ const PutBackPageModal = (props) => {
   }
 
   return (
-    <Modal isOpen={isOpen} toggle={onClose} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={onClose} className="bg-info text-light">
+    <Modal isOpen={isOpened} toggle={closePutBackPageModal} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closePutBackPageModal} className="bg-info text-light">
         <i className="icon-action-undo mr-2" aria-hidden="true"></i> { t('modal_putback.label.Put Back Page') }
       </ModalHeader>
       <ModalBody>
@@ -88,12 +92,6 @@ const PutBackPageModal = (props) => {
 
 PutBackPageModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-
-  pageId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
 };
 
 

+ 1 - 1
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -123,7 +123,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   }, []);
 
   const deleteItemClickedHandler = useCallback(async(pageToDelete, isAbleToDeleteCompletely) => {
-    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
+    openDeleteModal([pageToDelete], isAbleToDeleteCompletely, onDeletedHandler);
   }, [onDeletedHandler, openDeleteModal]);
 
   const ControlComponents = useCallback(() => {

+ 1 - 1
packages/app/src/components/SearchTypeahead.tsx

@@ -177,7 +177,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
       return emptyLabel;
     }
 
-    return false;
+    return <></>;
   };
 
   const defaultSelected = (keywordOnInit !== '')

+ 2 - 2
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, useSWRxRootPage } from '~/stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
@@ -140,7 +140,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
       }
     };
 
-    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
+    openDeleteModal([pageToDelete], isAbleToDeleteCompletely, onDeletedHandler);
   };
 
   if (error1 != null || error2 != null) {

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

@@ -41,6 +41,7 @@ export type IPageInfo = {
   isMovable: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
+  isRevertible: boolean,
 }
 
 export type IPageInfoForEntity = IPageInfo & {

+ 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)) {

+ 17 - 5
packages/app/src/server/models/obsolete-page.js

@@ -15,7 +15,7 @@ const differenceInYears = require('date-fns/differenceInYears');
 const { pathUtils } = require('@growi/core');
 const escapeStringRegexp = require('escape-string-regexp');
 
-const { isTopPage, isTrashPage, isUserNamePage } = pagePathUtils;
+const { isTopPage, isTrashPage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
 
 const logger = loggerFactory('growi:models:page');
@@ -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 },
@@ -596,10 +612,6 @@ export const getPageSchema = (crowi) => {
     return path.replace('/trash', '');
   };
 
-  pageSchema.statics.isDeletableName = function(path) {
-    return !isTopPage(path) && !isUserNamePage(path);
-  };
-
   pageSchema.statics.fixToCreatableName = function(path) {
     return path
       .replace(/\/\//g, '/');

+ 1 - 1
packages/app/src/server/models/page.ts

@@ -673,7 +673,7 @@ export default (crowi: Crowi): any => {
     // Delete PageRedirect if exists
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     try {
-      await PageRedirect.deleteOne({ from: path });
+      await PageRedirect.deleteOne({ fromPath: path });
       logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
     }
     catch (err) {

+ 1 - 1
packages/app/src/server/models/subscription.ts

@@ -22,7 +22,7 @@ export interface ISubscription {
 export interface SubscriptionDocument extends ISubscription, Document {}
 
 export interface SubscriptionModel extends Model<SubscriptionDocument> {
-  findByUserIdAndTargetId(userId: Types.ObjectId, targetId: Types.ObjectId): any
+  findByUserIdAndTargetId(userId: Types.ObjectId | string, targetId: Types.ObjectId | string): any
   upsertSubscription(user: Types.ObjectId, targetModel: string, target: Types.ObjectId, status: string): any
   subscribeByPageId(user: Types.ObjectId, pageId: Types.ObjectId, status: string): any
   getSubscription(target: Types.ObjectId): Promise<Types.ObjectId[]>

+ 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));

+ 13 - 47
packages/app/src/server/routes/apiv3/page.js

@@ -239,33 +239,36 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Parameter path or pageId is required.', 'invalid-request'));
     }
 
-    let result = {};
+    let page;
     try {
-      result = await pageService.findPageAndMetaDataByViewer({ pageId, path, user: req.user });
+      if (pageId != null) { // prioritized
+        page = await Page.findByIdAndViewer(pageId, req.user);
+      }
+      else {
+        page = await Page.findByPathAndViewer(path, req.user);
+      }
     }
     catch (err) {
       logger.error('get-page-failed', err);
       return res.apiv3Err(err, 500);
     }
 
-    const page = result.page;
-
     if (page == null) {
-      return res.apiv3(result);
+      return res.apiv3Err('Page is not found', 404);
     }
 
     try {
       page.initLatestRevisionField();
 
       // populate
-      result.page = await page.populateDataToShowRevision();
+      page = await page.populateDataToShowRevision();
     }
     catch (err) {
       logger.error('populate-page-failed', err);
       return res.apiv3Err(err, 500);
     }
 
-    return res.apiv3(result);
+    return res.apiv3({ page });
   });
 
   /**
@@ -360,50 +363,13 @@ module.exports = (crowi) => {
     const { pageId } = req.query;
 
     try {
-      const page = await Page.findByIdAndViewer(pageId, user, null, true);
+      const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, null, user, isSharedPage);
 
-      if (page == null) {
+      if (pageWithMeta == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
 
-      if (isSharedPage) {
-        return {
-          isEmpty: page.isEmpty,
-          isMovable: false,
-          isDeletable: false,
-          isAbleToDeleteCompletely: false,
-        };
-      }
-
-      const isGuestUser = !req.user;
-      const pageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
-
-      const bookmarkCount = await Bookmark.countByPageId(pageId);
-
-      const responseBodyForGuest = {
-        ...pageInfo,
-        bookmarkCount,
-      };
-
-      if (isGuestUser) {
-        return res.apiv3(responseBodyForGuest);
-      }
-
-      const isBookmarked = await Bookmark.findByPageIdAndUserId(pageId, user._id);
-      const isLiked = page.isLiked(user);
-      const isAbleToDeleteCompletely = pageService.canDeleteCompletely(page.creator?._id, user);
-
-      const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
-
-      const responseBody = {
-        ...responseBodyForGuest,
-        isAbleToDeleteCompletely,
-        isBookmarked,
-        isLiked,
-        subscriptionStatus: subscription?.status,
-      };
-
-      return res.apiv3(responseBody);
+      return res.apiv3(pageWithMeta.pageMeta);
     }
     catch (err) {
       logger.error('get-page-info', err);

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

@@ -484,7 +484,7 @@ module.exports = (crowi) => {
 
     const isExist = await Page.count({ path: newPagePath }) > 0;
     if (isExist) {
-      // if page found, cannot cannot rename to that path
+      // if page found, cannot rename to that path
       return res.apiv3Err(new ErrorV3(`${newPagePath} already exists`, 'already_exists'), 409);
     }
 
@@ -649,7 +649,7 @@ module.exports = (crowi) => {
 
     const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
 
-    const isEmptyAndNotRecursively = page?.isEmpty && isRecursively;
+    const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
     if (page == null || isEmptyAndNotRecursively) {
       res.code = 'Page is not found';
       logger.error('Failed to find the pages');

+ 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);
 };

+ 5 - 1
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,9 +197,10 @@ 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()
     .get('/', privateLegacyPages.renderPrivateLegacyPages));
@@ -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,
+  };
+
+};

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

@@ -6,9 +6,8 @@ import mongoose from 'mongoose';
 import loggerFactory from '~/utils/logger';
 import { PageQueryBuilder } from '../models/obsolete-page';
 import UpdatePost from '../models/update-post';
-import { PageRedirectModel } from '../models/page-redirect';
 
-const { isCreatablePage, isTopPage } = pagePathUtils;
+const { isCreatablePage, isTopPage, isUsersHomePage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../models/serializers/user-serializer');
@@ -142,7 +141,6 @@ module.exports = function(crowi, app) {
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
-  const Bookmark = crowi.model('Bookmark');
   const PageTagRelation = crowi.model('PageTagRelation');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ShareLink = crowi.model('ShareLink');
@@ -173,14 +171,6 @@ module.exports = function(crowi, app) {
     return pathUtils.normalizePath(req.pagePath || req.params[0] || '');
   }
 
-  function isUserPage(path) {
-    if (path.match(/^\/user\/[^/]+\/?$/)) {
-      return true;
-    }
-
-    return false;
-  }
-
   function generatePager(offset, limit, totalCount) {
     let prev = null;
 
@@ -450,7 +440,7 @@ module.exports = function(crowi, app) {
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: page._id });
     renderVars.sharelinksNumber = sharelinksNumber;
 
-    if (isUserPage(path)) {
+    if (isUsersHomePage(path)) {
       // change template
       view = 'layout-growi/user_page';
       await addRenderVarsForUserPage(renderVars, page);

+ 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,
+  },
 };
 
 

+ 95 - 35
packages/app/src/server/service/page.ts

@@ -5,30 +5,31 @@ import streamToPromise from 'stream-to-promise';
 import pathlib from 'path';
 import { Readable, Writable } from 'stream';
 
-import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import {
-  CreateMethod, generateGrantCondition, PageCreateOptions, PageModel,
+  CreateMethod, generateGrantCondition, PageCreateOptions, PageModel, PageDocument,
 } from '~/server/models/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
-import ActivityDefine from '../util/activityDefine';
 import {
-  IPage, IPageInfo, IPageInfoForEntity,
+  IPage, IPageInfo, IPageInfoForEntity, IPageWithMeta,
 } from '~/interfaces/page';
+import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { PageRedirectModel } from '../models/page-redirect';
+import Subscription from '../models/subscription';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { IUserHasId } from '~/interfaces/user';
 import { Ref } from '~/interfaces/common';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import PageOperation, { PageActionStage, PageActionType } from '../models/page-operation';
+import ActivityDefine from '../util/activityDefine';
 
 const debug = require('debug')('growi:services:page');
 
 const logger = loggerFactory('growi:services:page');
 const {
-  isCreatablePage, isTrashPage, isTopPage, isDeletablePage, omitDuplicateAreaPageFromPages,
-  isUserPage, isUserNamePage, collectAncestorPaths,
+  isTrashPage, isTopPage, omitDuplicateAreaPageFromPages,
+  collectAncestorPaths, isMovablePage,
 } = pagePathUtils;
 
 const BULK_REINDEX_SIZE = 100;
@@ -215,13 +216,14 @@ class PageService {
     return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.creator, user));
   }
 
-  async findPageAndMetaDataByViewer({ pageId, path, user }) {
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  async findPageAndMetaDataByViewer(pageId: string, path: string, user: IUserHasId, isSharedPage = false): Promise<IPageWithMeta|null> {
 
     const Page = this.crowi.model('Page');
 
     let pagePath = path;
 
-    let page;
+    let page: PageModel & PageDocument & HasObjectId;
     if (pageId != null) { // prioritized
       page = await Page.findByIdAndViewer(pageId, user);
       pagePath = page.path;
@@ -230,26 +232,57 @@ class PageService {
       page = await Page.findByPathAndViewer(pagePath, user);
     }
 
-    const result: any = {};
-
     if (page == null) {
-      const isExist = await Page.count({ $or: [{ _id: pageId }, { pat: pagePath }] }) > 0;
-      result.isForbidden = isExist;
-      result.isNotFound = !isExist;
-      result.isCreatable = isCreatablePage(pagePath);
-      result.page = page;
+      return null;
+    }
 
-      return result;
+    if (isSharedPage) {
+      return {
+        pageData: page,
+        pageMeta: {
+          isEmpty: page.isEmpty,
+          isMovable: false,
+          isDeletable: false,
+          isAbleToDeleteCompletely: false,
+          isRevertible: false,
+        },
+      };
     }
 
-    result.page = page;
-    result.isForbidden = false;
-    result.isNotFound = false;
-    result.isCreatable = false;
-    result.isDeletable = isDeletablePage(pagePath);
-    result.isDeleted = page.isDeleted();
+    const isGuestUser = user == null;
+    const pageInfo = this.constructBasicPageInfo(page, isGuestUser);
 
-    return result;
+    const Bookmark = this.crowi.model('Bookmark');
+    const bookmarkCount = await Bookmark.countByPageId(pageId);
+
+    const metadataForGuest = {
+      ...pageInfo,
+      bookmarkCount,
+    };
+
+    if (isGuestUser) {
+      return {
+        pageData: page,
+        pageMeta: metadataForGuest,
+      };
+    }
+
+    const isBookmarked = await Bookmark.findByPageIdAndUserId(pageId, user._id);
+    const isLiked = page.isLiked(user);
+    const isAbleToDeleteCompletely = this.canDeleteCompletely((page.creator as IUserHasId)?._id, user);
+
+    const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
+
+    return {
+      pageData: page,
+      pageMeta: {
+        ...metadataForGuest,
+        isAbleToDeleteCompletely,
+        isBookmarked,
+        isLiked,
+        subscriptionStatus: subscription?.status,
+      },
+    };
   }
 
   private shouldUseV4Process(page): boolean {
@@ -321,6 +354,14 @@ class PageService {
     /*
      * Common Operation
      */
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const isExist = await Page.exists({ path: newPagePath });
+    if (isExist) {
+      // if page found, cannot rename to that path
+      throw new Error('the path already exists');
+    }
+
     if (isTopPage(page.path)) {
       throw Error('It is forbidden to rename the top page');
     }
@@ -697,6 +738,11 @@ class PageService {
     /*
      * Common Operation
      */
+    const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
+    if (page == null || isEmptyAndNotRecursively) {
+      throw new Error('Cannot find or duplicate the empty page');
+    }
+
     const Page = mongoose.model('Page') as unknown as PageModel;
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
 
@@ -1126,7 +1172,8 @@ class PageService {
     if (isTrashed) {
       throw new Error('This method does NOT support deleting trashed pages.');
     }
-    if (!Page.isDeletableName(page.path)) {
+
+    if (!isMovablePage(page.path)) {
       throw new Error('Page is not deletable.');
     }
 
@@ -1199,8 +1246,14 @@ class PageService {
     }, { new: true });
 
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
-    await PageRedirect.create({ fromPath: page.path, toPath: newPath });
-
+    try {
+      await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw err;
+      }
+    }
     this.pageEvent.emit('delete', page, user);
     this.pageEvent.emit('create', deletedPage, user);
 
@@ -1237,7 +1290,7 @@ class PageService {
       throw new Error('This method does NOT support deleting trashed pages.');
     }
 
-    if (!Page.isDeletableName(page.path)) {
+    if (!isMovablePage(page.path)) {
       throw new Error('Page is not deletable.');
     }
 
@@ -1254,7 +1307,14 @@ class PageService {
     }, { new: true });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
 
-    await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+    try {
+      await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw err;
+      }
+    }
 
     this.pageEvent.emit('delete', page, user);
     this.pageEvent.emit('create', deletedPage, user);
@@ -1444,6 +1504,8 @@ class PageService {
 
     logger.debug('Deleting completely', paths);
 
+    await this.deleteCompletelyOperation(ids, paths);
+
     // replace with an empty page
     const shouldReplace = !isRecursively && await Page.exists({ parent: page._id });
     if (shouldReplace) {
@@ -1870,7 +1932,7 @@ class PageService {
   }
 
   constructBasicPageInfo(page: IPage, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
-    const isMovable = isGuestUser ? false : !isTopPage(page.path) && !isUserPage(page.path) && !isUserNamePage(page.path);
+    const isMovable = isGuestUser ? false : isMovablePage(page.path);
 
     if (page.isEmpty) {
       return {
@@ -1878,13 +1940,13 @@ class PageService {
         isMovable,
         isDeletable: false,
         isAbleToDeleteCompletely: false,
+        isRevertible: false,
       };
     }
 
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
 
-    const Page = this.crowi.model('Page');
     return {
       isEmpty: false,
       sumOfLikers: page.liker.length,
@@ -1892,8 +1954,9 @@ class PageService {
       seenUserIds: this.extractStringIds(seenUsers),
       sumOfSeenUsers: page.seenUsers.length,
       isMovable,
-      isDeletable: Page.isDeletableName(page.path),
+      isDeletable: isMovable,
       isAbleToDeleteCompletely: false,
+      isRevertible: isTrashPage(page.path),
     };
 
   }
@@ -2443,10 +2506,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();
 

+ 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 %}

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

@@ -110,6 +110,7 @@
 <div id="page-presentation-modal"></div>
 <div id="page-accessories-modal"></div>
 <div id="descendants-page-list-modal"></div>
+<div id="page-put-back-modal"></div>
 
 {% include '../modal/shortcuts.html' %}
 

+ 29 - 4
packages/app/src/stores/modal.tsx

@@ -44,16 +44,16 @@ export type OnDeletedFunction = (pathOrPaths: string | string[], isRecursively:
 type DeleteModalStatus = {
   isOpened: boolean,
   pages?: IPageForPageDeleteModal[],
-  onDeleted?: OnDeletedFunction,
   isAbleToDeleteCompletely?: boolean,
+  onDeleted?: OnDeletedFunction,
   isDeleteCompletelyModal?: boolean,
 }
 
 type DeleteModalStatusUtils = {
   open(
     pages?: IPageForPageDeleteModal[],
-    onDeleted?: OnDeletedFunction,
     isAbleToDeleteCompletely?: boolean,
+    onDeleted?: OnDeletedFunction,
     isDeleteCompletelyModal?: boolean,
   ): Promise<DeleteModalStatus | undefined>,
   close(): Promise<DeleteModalStatus | undefined>,
@@ -73,11 +73,11 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
     ...swrResponse,
     open: (
         pages?: IPageForPageDeleteModal[],
-        onDeleted?: OnDeletedFunction,
         isAbleToDeleteCompletely?: boolean,
+        onDeleted?: OnDeletedFunction,
         isDeleteCompletelyModal?: boolean,
     ) => swrResponse.mutate({
-      isOpened: true, pages, onDeleted, isAbleToDeleteCompletely, isDeleteCompletelyModal,
+      isOpened: true, pages, isAbleToDeleteCompletely, onDeleted, isDeleteCompletelyModal,
     }),
     close: () => swrResponse.mutate({ isOpened: false }),
   };
@@ -150,6 +150,31 @@ export const usePageRenameModal = (status?: RenameModalStatus): SWRResponse<Rena
   };
 };
 
+type PutBackPageModalStatus = {
+  isOpened: boolean,
+  pageId?: string,
+  path?: string,
+}
+
+type PutBackPageModalUtils = {
+  open(pageId: string, path: string): Promise<PutBackPageModalStatus | undefined>
+  close():Promise<PutBackPageModalStatus | undefined>
+}
+
+export const usePutBackPageMOdal = (status?: PutBackPageModalStatus): SWRResponse<PutBackPageModalStatus, Error> & PutBackPageModalUtils => {
+  const initialData = { isOpened: false, pageId: '', path: '' };
+  const swrResponse = useStaticSWR<PutBackPageModalStatus, Error>('putBackPageModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (pageId: string, path: string) => swrResponse.mutate({
+      isOpened: true, pageId, path,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+
 /*
 * PagePresentationModal
 */

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

@@ -156,17 +156,6 @@ describe('Page', () => {
     });
   });
 
-  describe('.isDeletableName', () => {
-    test('should decide deletable or not', () => {
-      expect(Page.isDeletableName('/')).toBeFalsy();
-      expect(Page.isDeletableName('/hoge')).toBeTruthy();
-      expect(Page.isDeletableName('/user/xxx')).toBeFalsy();
-      expect(Page.isDeletableName('/user/xxx123')).toBeFalsy();
-      expect(Page.isDeletableName('/user/xxx/')).toBeTruthy();
-      expect(Page.isDeletableName('/user/xxx/hoge')).toBeTruthy();
-    });
-  });
-
   describe('.isAccessiblePageByViewer', () => {
     describe('with a granted page', () => {
       test('should return true with granted user', async() => {

+ 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);
+    });
+
+  });
+});

+ 1338 - 0
packages/app/test/integration/service/v5.page.test.ts

@@ -0,0 +1,1338 @@
+/* eslint-disable no-unused-vars */
+import { advanceTo } from 'jest-date-mock';
+
+import mongoose from 'mongoose';
+
+import { getInstance } from '../setup-crowi';
+
+describe('PageService page operations with only public pages', () => {
+
+  let dummyUser1;
+  let dummyUser2;
+
+  let crowi;
+  let Page;
+  let Revision;
+  let User;
+  let Tag;
+  let PageTagRelation;
+  let Bookmark;
+  let Comment;
+  let ShareLink;
+  let PageRedirect;
+  let xssSpy;
+
+  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) => {
+      expect(data).toBeTruthy();
+    });
+  };
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
+
+    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');
+
+    /*
+     * 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);
+
+    rootPage = await Page.findOne({ path: '/' });
+    if (rootPage == null) {
+      const pages = await Page.insertMany([{ path: '/', grant: Page.GRANT_PUBLIC }]);
+      rootPage = pages[0];
+    }
+
+    /*
+     * Rename
+     */
+    const pageIdForRename1 = new mongoose.Types.ObjectId();
+    const pageIdForRename2 = new mongoose.Types.ObjectId();
+    const pageIdForRename3 = new mongoose.Types.ObjectId();
+    const pageIdForRename4 = new mongoose.Types.ObjectId();
+    const pageIdForRename5 = new mongoose.Types.ObjectId();
+
+    const pageIdForRename7 = new mongoose.Types.ObjectId();
+    const pageIdForRename8 = new mongoose.Types.ObjectId();
+    const pageIdForRename9 = new mongoose.Types.ObjectId();
+    const pageIdForRename10 = new mongoose.Types.ObjectId();
+    const pageIdForRename11 = new mongoose.Types.ObjectId();
+    const pageIdForRename12 = new mongoose.Types.ObjectId();
+    const pageIdForRename13 = new mongoose.Types.ObjectId();
+    const pageIdForRename14 = new mongoose.Types.ObjectId();
+
+    const pageIdForRename16 = new mongoose.Types.ObjectId();
+
+    // Create Pages
+    await Page.insertMany([
+      // parents
+      {
+        _id: pageIdForRename1,
+        path: '/v5_ParentForRename1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename2,
+        path: '/v5_ParentForRename2',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        // id not needed for this data
+        path: '/v5_ParentForRename2/dummyChild1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename2,
+      },
+      {
+        _id: pageIdForRename3,
+        path: '/v5_ParentForRename3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename4,
+        path: '/v5_ParentForRename4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename5,
+        path: '/v5_ParentForRename5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename7,
+        path: '/v5_ParentForRename7',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename8,
+        path: '/v5_ParentForRename8',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename9,
+        path: '/v5_ParentForRename9',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      // children
+      {
+        _id: pageIdForRename10,
+        path: '/v5_ChildForRename1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename11,
+        path: '/v5_ChildForRename2',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename12,
+        path: '/v5_ChildForRename3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        updatedAt: new Date('2021'),
+      },
+      {
+        _id: pageIdForRename13,
+        path: '/v5_ChildForRename4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename14,
+        path: '/v5_ChildForRename5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename16,
+        path: '/v5_ChildForRename7',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      // Grandchild
+      {
+        path: '/v5_ChildForRename5/v5_GrandchildForRename5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename14,
+        updatedAt: new Date('2021'),
+      },
+      {
+        path: '/v5_ChildForRename7/v5_GrandchildForRename7',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename16,
+      },
+    ]);
+
+    /*
+     * Duplicate
+     */
+    // page ids
+    const pageIdForDuplicate1 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate2 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate3 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate4 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate5 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate6 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate7 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate8 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate9 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate10 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate11 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate12 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate13 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate14 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate15 = new mongoose.Types.ObjectId();
+
+    // revision ids
+    const revisionIdForDuplicate1 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate2 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate3 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate4 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate5 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate6 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate7 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate8 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate9 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate10 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate11 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate12 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageIdForDuplicate1,
+        path: '/v5_PageForDuplicate1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdForDuplicate1,
+      },
+      {
+        _id: pageIdForDuplicate2,
+        path: '/v5_PageForDuplicate2',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdForDuplicate3,
+        path: '/v5_PageForDuplicate2/v5_ChildForDuplicate2',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate2,
+        revision: revisionIdForDuplicate2,
+      },
+      {
+        _id: pageIdForDuplicate4,
+        path: '/v5_PageForDuplicate3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdForDuplicate3,
+      },
+      {
+        _id: pageIdForDuplicate5,
+        path: '/v5_PageForDuplicate3/v5_Child_1_ForDuplicate3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate4,
+        revision: revisionIdForDuplicate4,
+      },
+      {
+        _id: pageIdForDuplicate6,
+        path: '/v5_PageForDuplicate3/v5_Child_2_ForDuplicate3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate4,
+        revision: revisionIdForDuplicate5,
+      },
+      {
+        _id: pageIdForDuplicate7,
+        path: '/v5_PageForDuplicate4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdForDuplicate6,
+      },
+      {
+        _id: pageIdForDuplicate8,
+        path: '/v5_PageForDuplicate4/v5_empty_PageForDuplicate4',
+        grant: Page.GRANT_PUBLIC,
+        parent: pageIdForDuplicate7,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdForDuplicate9,
+        path: '/v5_PageForDuplicate4/v5_empty_PageForDuplicate4/v5_grandchild_PageForDuplicate4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate8,
+        revision: revisionIdForDuplicate7,
+      },
+      {
+        _id: pageIdForDuplicate10,
+        path: '/v5_PageForDuplicate5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdForDuplicate8,
+      },
+      {
+        _id: pageIdForDuplicate11,
+        path: '/v5_PageForDuplicate6',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdForDuplicate9,
+      },
+      {
+        _id: pageIdForDuplicate13,
+        path: '/v5_empty_PageForDuplicate7',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdForDuplicate14,
+        path: '/v5_empty_PageForDuplicate7/v5_child_PageForDuplicate7',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate13,
+        revision: revisionIdForDuplicate11,
+      },
+      {
+        _id: pageIdForDuplicate15,
+        path: '/v5_empty_PageForDuplicate7/v5_child_PageForDuplicate7/v5_grandchild_PageForDuplicate7',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate14,
+        revision: revisionIdForDuplicate12,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: revisionIdForDuplicate1,
+        body: 'body1',
+        format: 'markdown',
+        pageId: pageIdForDuplicate1,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate2,
+        body: 'body3',
+        format: 'markdown',
+        pageId: pageIdForDuplicate3,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate3,
+        body: 'parent_page_body4',
+        format: 'markdown',
+        pageId: pageIdForDuplicate4,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate4,
+        body: 'revision_id_4_child_page_body',
+        format: 'markdown',
+        pageId: pageIdForDuplicate5,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate5,
+        body: 'revision_id_5_child_page_body',
+        format: 'markdown',
+        pageId: pageIdForDuplicate6,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate6,
+        body: '/v5_PageForDuplicate4',
+        format: 'markdown',
+        pageId: pageIdForDuplicate7,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate7,
+        body: '/v5_PageForDuplicate4/v5_empty_PageForDuplicate4/v5_grandchild_PageForDuplicate4',
+        format: 'markdown',
+        pageId: pageIdForDuplicate9,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate8,
+        body: '/v5_PageForDuplicate5',
+        format: 'markdown',
+        pageId: pageIdForDuplicate10,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate9,
+        body: '/v5_PageForDuplicate6',
+        format: 'markdown',
+        pageId: pageIdForDuplicate11,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate10,
+        body: '/v5_PageForDuplicate6',
+        format: 'comment',
+        pageId: pageIdForDuplicate12,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate11,
+        body: '/v5_child_PageForDuplicate7',
+        format: 'markdown',
+        pageId: pageIdForDuplicate14,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate12,
+        body: '/v5_grandchild_PageForDuplicate7',
+        format: 'markdown',
+        pageId: pageIdForDuplicate15,
+        author: dummyUser1,
+      },
+    ]);
+    const tagForDuplicate1 = new mongoose.Types.ObjectId();
+    const tagForDuplicate2 = new mongoose.Types.ObjectId();
+
+    await Tag.insertMany([
+      { _id: tagForDuplicate1, name: 'duplicate_Tag1' },
+      { _id: tagForDuplicate2, name: 'duplicate_Tag2' },
+    ]);
+
+    await PageTagRelation.insertMany([
+      { relatedPage: pageIdForDuplicate10, relatedTag: tagForDuplicate1 },
+      { relatedPage: pageIdForDuplicate10._id, relatedTag: tagForDuplicate2 },
+    ]);
+
+    await Comment.insertMany([
+      {
+        commentPosition: -1,
+        isMarkdown: true,
+        page: pageIdForDuplicate11,
+        creator: dummyUser1._id,
+        revision: revisionIdForDuplicate10,
+        comment: 'this is comment',
+      },
+    ]);
+
+    /**
+     * 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',
+      },
+    ]);
+
+  });
+
+  describe('Rename', () => {
+
+    const renamePage = async(page, newPagePath, user, options) => {
+    // mock return value
+      const mockedResumableRenameDescendants = jest.spyOn(crowi.pageService, 'resumableRenameDescendants').mockReturnValue(null);
+      const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
+      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
+
+      // retrieve the arguments passed when calling method resumableRenameDescendants inside renamePage method
+      const argsForResumableRenameDescendants = mockedResumableRenameDescendants.mock.calls[0];
+
+      // restores the original implementation
+      mockedResumableRenameDescendants.mockRestore();
+      mockedCreateAndSendNotifications.mockRestore();
+
+      // rename descendants
+      await crowi.pageService.resumableRenameDescendants(...argsForResumableRenameDescendants);
+
+      return renamedPage;
+    };
+
+    test('Should NOT rename top page', async() => {
+      expectAllToBeTruthy([rootPage]);
+      let isThrown = false;
+      try {
+        await crowi.pageService.renamePage(rootPage, '/new_root', dummyUser1, {});
+      }
+      catch (err) {
+        isThrown = true;
+      }
+
+      expect(isThrown).toBe(true);
+    });
+
+    test('Should rename/move to under non-empty page', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_ParentForRename1' });
+      const childPage = await Page.findOne({ path: '/v5_ChildForRename1' });
+      expectAllToBeTruthy([childPage, parentPage]);
+
+      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();
+
+    });
+
+    test('Should rename/move to under empty page', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_ParentForRename2' });
+      const childPage = await Page.findOne({ path: '/v5_ChildForRename2' });
+      expectAllToBeTruthy([childPage, parentPage]);
+      expect(parentPage.isEmpty).toBe(true);
+
+      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() => {
+      const parentPage = await Page.findOne({ path: '/v5_ParentForRename3' });
+      const childPage = await Page.findOne({ path: '/v5_ChildForRename3' });
+      expectAllToBeTruthy([childPage, parentPage]);
+      expect(childPage.lastUpdateUser).toStrictEqual(dummyUser1._id);
+
+      const newPath = '/v5_ParentForRename3/renamedChildForRename3';
+      const oldUdpateAt = childPage.updatedAt;
+      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { updateMetadata: true });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(renamedPage.path).toBe(newPath);
+      expect(renamedPage.parent).toStrictEqual(parentPage._id);
+      expect(renamedPage.lastUpdateUser).toStrictEqual(dummyUser2._id);
+      expect(renamedPage.updatedAt.getFullYear()).toBeGreaterThan(oldUdpateAt.getFullYear());
+    });
+
+    // ****************** TODO ******************
+    // uncomment the next test when working on 88097
+    // ******************************************
+    // test('Should move with option createRedirectPage: true', async() => {
+    // const parentPage = await Page.findOne({ path: '/v5_ParentForRename4' });
+    // const childPage = await Page.findOne({ path: '/v5_ChildForRename4' });
+    // expectAllToBeTruthy([parentPage, childPage]);
+
+    //   // rename target page
+    //   const newPath = '/v5_ParentForRename4/renamedChildForRename4';
+    //   const renamedPage = await renamePage(childPage, newPath, dummyUser2, { createRedirectPage: true });
+    //   const pageRedirect = await PageRedirect.find({ fromPath: childPage.path, toPath: renamedPage.path });
+
+    // expect(xssSpy).toHaveBeenCalled();
+    //   expect(renamedPage.path).toBe(newPath);
+    //   expect(renamedPage.parent).toStrictEqual(parentPage._id);
+    //   expect(pageRedirect.length).toBeGreaterThan(0);
+    // });
+
+    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, grandchild]);
+
+      const newPath = '/v5_ParentForRename5/renamedChildForRename5';
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      // find child of renamed page
+      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(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', isEmpty: true });
+      const grandchild = await Page.findOne({ parent: childPage._id, path: '/v5_ChildForRename7/v5_GrandchildForRename7' });
+
+      expectAllToBeTruthy([parentPage, childPage, grandchild]);
+
+      const newPath = '/v5_ParentForRename7/renamedChildForRename7';
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      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(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' });
+      expectAllToBeTruthy([page]);
+
+      const newPath = '/v5_ParentForRename9';
+      let isThrown;
+      try {
+        await renamePage(page, newPath, dummyUser1, {});
+      }
+      catch (err) {
+        isThrown = true;
+      }
+
+      expect(isThrown).toBe(true);
+    });
+  });
+
+  describe('Duplicate', () => {
+
+    const duplicate = async(page, newPagePath, user, isRecursively) => {
+      // mock return value
+      const mockedResumableDuplicateDescendants = jest.spyOn(crowi.pageService, 'resumableDuplicateDescendants').mockReturnValue(null);
+      const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
+      const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively);
+
+      // retrieve the arguments passed when calling method resumableDuplicateDescendants inside duplicate method
+      const argsForResumableDuplicateDescendants = mockedResumableDuplicateDescendants.mock.calls[0];
+
+      // restores the original implementation
+      mockedResumableDuplicateDescendants.mockRestore();
+      mockedCreateAndSendNotifications.mockRestore();
+
+      // duplicate descendants
+      if (isRecursively) {
+        await crowi.pageService.resumableDuplicateDescendants(...argsForResumableDuplicateDescendants);
+      }
+
+      return duplicatedPage;
+    };
+
+    test('Should duplicate single page', async() => {
+      const page = await Page.findOne({ path: '/v5_PageForDuplicate1' });
+      expectAllToBeTruthy([page]);
+
+      const newPagePath = '/duplicatedv5PageForDuplicate1';
+      const duplicatedPage = await duplicate(page, newPagePath, dummyUser1, false);
+
+      const duplicatedRevision = await Revision.findOne({ pageId: duplicatedPage._id });
+      const baseRevision = await Revision.findOne({ pageId: page._id });
+
+      // new path
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(duplicatedPage._id).not.toStrictEqual(page._id);
+      expect(duplicatedPage.revision).toStrictEqual(duplicatedRevision._id);
+      expect(duplicatedRevision.body).toEqual(baseRevision.body);
+    });
+
+    test('Should NOT duplicate single empty page', async() => {
+      const page = await Page.findOne({ path: '/v5_PageForDuplicate2' });
+      expectAllToBeTruthy([page]);
+
+      let isThrown;
+      let duplicatedPage;
+      try {
+        const newPagePath = '/duplicatedv5PageForDuplicate2';
+        duplicatedPage = await duplicate(page, newPagePath, dummyUser1, false);
+      }
+      catch (err) {
+        isThrown = true;
+      }
+
+      expect(duplicatedPage).toBeUndefined();
+      expect(isThrown).toBe(true);
+    });
+
+    test('Should duplicate multiple pages', async() => {
+      const basePage = await Page.findOne({ path: '/v5_PageForDuplicate3' });
+      const revision = await Revision.findOne({ pageId: basePage._id });
+      const childPage1 = await Page.findOne({ path: '/v5_PageForDuplicate3/v5_Child_1_ForDuplicate3' }).populate({ path: 'revision', model: 'Revision' });
+      const childPage2 = await Page.findOne({ path: '/v5_PageForDuplicate3/v5_Child_2_ForDuplicate3' }).populate({ path: 'revision', model: 'Revision' });
+      const revisionForChild1 = childPage1.revision;
+      const revisionForChild2 = childPage2.revision;
+      expectAllToBeTruthy([basePage, revision, childPage1, childPage2, revisionForChild1, revisionForChild2]);
+
+      const newPagePath = '/duplicatedv5PageForDuplicate3';
+      const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, true);
+      const duplicatedChildPage1 = await Page.findOne({ parent: duplicatedPage._id, path: '/duplicatedv5PageForDuplicate3/v5_Child_1_ForDuplicate3' })
+        .populate({ path: 'revision', model: 'Revision' });
+      const duplicatedChildPage2 = await Page.findOne({ parent: duplicatedPage._id, path: '/duplicatedv5PageForDuplicate3/v5_Child_2_ForDuplicate3' })
+        .populate({ path: 'revision', model: 'Revision' });
+      const revisionForDuplicatedPage = await Revision.findOne({ pageId: duplicatedPage._id });
+      const revisionBodyForDupChild1 = duplicatedChildPage1.revision;
+      const revisionBodyForDupChild2 = duplicatedChildPage2.revision;
+
+      expectAllToBeTruthy([duplicatedPage, duplicatedChildPage1, duplicatedChildPage2,
+                           revisionForDuplicatedPage, revisionBodyForDupChild1, revisionBodyForDupChild2]);
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(duplicatedChildPage1.path).toBe('/duplicatedv5PageForDuplicate3/v5_Child_1_ForDuplicate3');
+      expect(duplicatedChildPage2.path).toBe('/duplicatedv5PageForDuplicate3/v5_Child_2_ForDuplicate3');
+
+    });
+
+    test('Should duplicate multiple pages with empty child in it', async() => {
+      const basePage = await Page.findOne({ path: '/v5_PageForDuplicate4' });
+      const baseChild = await Page.findOne({ parent: basePage._id, isEmpty: true });
+      const baseGrandchild = await Page.findOne({ parent: baseChild._id });
+      expectAllToBeTruthy([basePage, baseChild, baseGrandchild]);
+
+      const newPagePath = '/duplicatedv5PageForDuplicate4';
+      const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, true);
+      const duplicatedChild = await Page.findOne({ parent: duplicatedPage._id });
+      const duplicatedGrandchild = await Page.findOne({ parent: duplicatedChild._id });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expectAllToBeTruthy([duplicatedPage, duplicatedGrandchild]);
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(duplicatedChild.path).toBe('/duplicatedv5PageForDuplicate4/v5_empty_PageForDuplicate4');
+      expect(duplicatedGrandchild.path).toBe('/duplicatedv5PageForDuplicate4/v5_empty_PageForDuplicate4/v5_grandchild_PageForDuplicate4');
+      expect(duplicatedChild.isEmpty).toBe(true);
+      expect(duplicatedGrandchild.parent).toStrictEqual(duplicatedChild._id);
+      expect(duplicatedChild.parent).toStrictEqual(duplicatedPage._id);
+
+    });
+
+    test('Should duplicate tags', async() => {
+      const basePage = await Page.findOne({ path: '/v5_PageForDuplicate5' });
+      const tag1 = await Tag.findOne({ name: 'duplicate_Tag1' });
+      const tag2 = await Tag.findOne({ name:  'duplicate_Tag2' });
+      const basePageTagRelation1 = await PageTagRelation.findOne({ relatedTag: tag1._id });
+      const basePageTagRelation2 = await PageTagRelation.findOne({ relatedTag: tag2._id });
+      expectAllToBeTruthy([basePage, tag1, tag2, basePageTagRelation1, basePageTagRelation2]);
+
+      const newPagePath = '/duplicatedv5PageForDuplicate5';
+      const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, false);
+      const duplicatedTagRelations = await PageTagRelation.find({ relatedPage: duplicatedPage._id });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(duplicatedTagRelations.length).toBeGreaterThanOrEqual(2);
+    });
+
+    test('Should NOT duplicate comments', async() => {
+      const basePage = await Page.findOne({ path: '/v5_PageForDuplicate6' });
+      const basePageComments = await Comment.find({ page: basePage._id });
+      expectAllToBeTruthy([basePage, ...basePageComments]);
+
+      const newPagePath = '/duplicatedv5PageForDuplicate6';
+      const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, false);
+      const duplicatedComments = await Comment.find({ page: duplicatedPage._id });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(basePageComments.length).not.toBe(duplicatedComments.length);
+    });
+
+    test('Should duplicate empty page with descendants', async() => {
+      const basePage = await Page.findOne({ path: '/v5_empty_PageForDuplicate7' });
+      const basePageChild = await Page.findOne({ parent: basePage._id }).populate({ path: 'revision', model: 'Revision' });
+      const basePageGrandhild = await Page.findOne({ parent: basePageChild._id }).populate({ path: 'revision', model: 'Revision' });
+      expectAllToBeTruthy([basePage, basePageChild, basePageGrandhild, basePageChild.revision, basePageGrandhild.revision]);
+
+      const newPagePath = '/duplicatedv5EmptyPageForDuplicate7';
+      const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, true);
+      const duplicatedChild = await Page.findOne({ parent: duplicatedPage._id }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedGrandchild = await Page.findOne({ parent: duplicatedChild._id }).populate({ path: 'revision', model: 'Revision' });
+
+      expectAllToBeTruthy([duplicatedPage, duplicatedChild, duplicatedGrandchild, duplicatedChild.revision, duplicatedGrandchild.revision]);
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(duplicatedPage.isEmpty).toBe(true);
+      expect(duplicatedChild.revision.body).toBe(basePageChild.revision.body);
+      expect(duplicatedGrandchild.revision.body).toBe(basePageGrandhild.revision.body);
+      expect(duplicatedChild.path).toBe('/duplicatedv5EmptyPageForDuplicate7/v5_child_PageForDuplicate7');
+      expect(duplicatedGrandchild.path).toBe('/duplicatedv5EmptyPageForDuplicate7/v5_child_PageForDuplicate7/v5_grandchild_PageForDuplicate7');
+      expect(duplicatedGrandchild.parent).toStrictEqual(duplicatedChild._id);
+      expect(duplicatedChild.parent).toStrictEqual(duplicatedPage._id);
+    });
+  });
+  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('PageService page operations with non-public pages', () => {
+  // TODO: write test code
+});

+ 11 - 1
packages/core/src/test/util/page-path-utils.test.js

@@ -1,5 +1,5 @@
 import {
-  isTopPage, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths,
+  isTopPage, isMovablePage, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths,
 } from '~/utils/page-path-utils';
 
 describe('TopPage Path test', () => {
@@ -21,6 +21,16 @@ describe('TopPage Path test', () => {
   });
 });
 
+describe('isMovablePage test', () => {
+  test('should decide deletable or not', () => {
+    expect(isMovablePage('/')).toBeFalsy();
+    expect(isMovablePage('/hoge')).toBeTruthy();
+    expect(isMovablePage('/user')).toBeFalsy();
+    expect(isMovablePage('/user/xxx')).toBeFalsy();
+    expect(isMovablePage('/user/xxx123')).toBeFalsy();
+    expect(isMovablePage('/user/xxx/hoge')).toBeTruthy();
+  });
+});
 
 describe('convertToNewAffiliationPath test', () => {
   test('Child path is not converted normally', () => {

+ 26 - 23
packages/core/src/utils/page-path-utils.ts

@@ -11,38 +11,48 @@ export const isTopPage = (path: string): boolean => {
 };
 
 /**
- * Whether path belongs to the trash page
+ * Whether path is the top page of users
  * @param path
  */
-export const isTrashPage = (path: string): boolean => {
-  // https://regex101.com/r/BSDdRr/1
-  if (path.match(/^\/trash(\/.*)?$/)) {
+export const isUsersTopPage = (path: string): boolean => {
+  return path === '/user';
+};
+
+/**
+ * Whether path is user's home page
+ * @param path
+ */
+export const isUsersHomePage = (path: string): boolean => {
+  // https://regex101.com/r/utVQct/1
+  if (path.match(/^\/user\/[^/]+$/)) {
     return true;
   }
-
   return false;
 };
 
 /**
- * Whether path belongs to the user page
+ * Whether path is the protected pages for systems
  * @param path
  */
-export const isUserPage = (path: string): boolean => {
-  // https://regex101.com/r/SxPejV/1
-  if (path.match(/^\/user(\/.*)?$/)) {
-    return true;
-  }
+export const isUsersProtectedPages = (path: string): boolean => {
+  return isUsersTopPage(path) || isUsersHomePage(path);
+};
 
-  return false;
+/**
+ * Whether path is movable
+ * @param path
+ */
+export const isMovablePage = (path: string): boolean => {
+  return !isTopPage(path) && !isUsersProtectedPages(path);
 };
 
 /**
- * Whether path is right under the path '/user'
+ * Whether path belongs to the trash page
  * @param path
  */
-export const isUserNamePage = (path: string): boolean => {
-  // https://regex101.com/r/GUZntH/1
-  if (path.match(/^\/user\/[^/]+$/)) {
+export const isTrashPage = (path: string): boolean => {
+  // https://regex101.com/r/BSDdRr/1
+  if (path.match(/^\/trash(\/.*)?$/)) {
     return true;
   }
 
@@ -62,13 +72,6 @@ export const isSharedPage = (path: string): boolean => {
   return false;
 };
 
-const restrictedPatternsToDelete: Array<RegExp> = [
-  /^\/user\/[^/]+$/, // user page
-];
-export const isDeletablePage = (path: string): boolean => {
-  return !restrictedPatternsToDelete.some(pattern => path.match(pattern));
-};
-
 const restrictedPatternsToCreate: Array<RegExp> = [
   /\^|\$|\*|\+|#|%|\?/,
   /^\/-\/.*/,

+ 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"