Kaynağa Gözat

Merge branch 'master' into feat/4572-get-contributors-with-gc-api

ryohi15 5 yıl önce
ebeveyn
işleme
868dd3ff53
51 değiştirilmiş dosya ile 2230 ekleme ve 445 silme
  1. 14 2
      CHANGES.md
  2. 5 7
      README.md
  3. 1 1
      package.json
  4. 3 0
      resource/Contributor.js
  5. 8 0
      resource/locales/en_US/translation.json
  6. 8 0
      resource/locales/ja_JP/translation.json
  7. 9 1
      resource/locales/zh_CN/translation.json
  8. 17 1
      src/client/js/app.jsx
  9. 10 4
      src/client/js/components/ContentLinkButtons.jsx
  10. 5 3
      src/client/js/components/EmptyTrashModal.jsx
  11. 22 0
      src/client/js/components/Page/DuplicatedAlert.jsx
  12. 20 0
      src/client/js/components/Page/RedirectedAlert.jsx
  13. 20 0
      src/client/js/components/Page/RenamedAlert.jsx
  14. 10 2
      src/client/js/components/Page/TrashPageAlert.jsx
  15. 7 3
      src/client/js/components/PageHistory.jsx
  16. 47 21
      src/client/js/components/PageHistory/PageRevisionList.jsx
  17. 6 0
      src/client/js/components/PageHistory/Revision.jsx
  18. 123 0
      src/client/js/components/RevisionComparer/RevisionComparer.jsx
  19. 67 0
      src/client/js/components/RevisionComparer/RevisionSelector.jsx
  20. 14 0
      src/client/js/services/AdminAppContainer.js
  21. 2 0
      src/client/js/services/PageContainer.js
  22. 5 0
      src/client/js/services/PageHistoryContainer.js
  23. 129 0
      src/client/js/services/RevisionComparerContainer.js
  24. 5 2
      src/client/styles/scss/_on-edit.scss
  25. 73 0
      src/client/styles/scss/_page-history.scss
  26. 0 68
      src/client/styles/scss/_page.scss
  27. 1 0
      src/client/styles/scss/style-app.scss
  28. 2 1
      src/server/crowi/index.js
  29. 3 0
      src/server/events/page.js
  30. 8 204
      src/server/models/page.js
  31. 1 2
      src/server/models/user-group.js
  32. 11 20
      src/server/routes/apiv3/pages.js
  33. 11 1
      src/server/routes/apiv3/revisions.js
  34. 1 11
      src/server/routes/attachment.js
  35. 7 25
      src/server/routes/page.js
  36. 23 3
      src/server/service/attachment.js
  37. 18 1
      src/server/service/file-uploader/aws.js
  38. 17 12
      src/server/service/file-uploader/gcs.js
  39. 16 3
      src/server/service/file-uploader/gridfs.js
  40. 6 0
      src/server/service/file-uploader/local.js
  41. 5 0
      src/server/service/file-uploader/none.js
  42. 4 0
      src/server/service/file-uploader/uploader.js
  43. 684 30
      src/server/service/page.js
  44. 48 7
      src/server/service/search-delegator/elasticsearch.js
  45. 3 0
      src/server/service/search.js
  46. 1 1
      src/server/views/installer.html
  47. 1 1
      src/server/views/layout/layout.html
  48. 1 1
      src/server/views/login.html
  49. 3 7
      src/server/views/widget/page_alerts.html
  50. 2 0
      src/server/views/widget/page_content.html
  51. 723 0
      src/test/service/page.test.js

+ 14 - 2
CHANGES.md

@@ -1,8 +1,20 @@
 # CHANGES
 
-## v4.2.8-RC
+## v4.2.9-RC
 
-* Fix: Fixed the display of updtedAt and createdAt being reversed 
+* Improvement: Add batchSize option for flow rate limit about stream 
+
+## v4.2.8
+
+* Improvement: Performance for pages to rename/duplicate/delete/revert pages
+* Fix: Preview scrollbar doesn't sync to editor
+    * Introduced by v4.2.6
+* Fix: Failed to save temporaryUrlCached with using gcs
+    * Introduced by v4.2.3
+* Fix: Fixed not being able to update ses settings
+    * Introduced by v4.2.0
+* Fix: Fixed the display of updtedAt and createdAt being reversed
+* Fix: Pass app title value through the XSS filter
 
 ## v4.2.7
 

+ 5 - 7
README.md

@@ -11,9 +11,6 @@
 <p align="center">
   <a href="https://docs.growi.org">Documentation</a> / <a href="https://demo.growi.org">Demo</a>
 </p>
-<p align="center">
-  <a href="https://heroku.com/deploy"><img src="https://www.herokucdn.com/deploy/button.png"></a>
-</p>
 
 
 GROWI
@@ -33,8 +30,8 @@ Table Of Contents
 
 - [Features](#features)
 - [Quick Start for Production](#quick-start-for-production)
-    - [Heroku](#heroku)
     - [docker-compose](#docker-compose)
+    - [Helm (Experimental)](#helm)
     - [On-premise](#on-premise)
 - [Environment Variables](#environment-variables)
 - [Documentation](#documentation)
@@ -61,14 +58,15 @@ Features
 Quick Start for Production
 ===========================
 
-### Heroku
-
-- [GROWI Docs: Launch on Heroku](https://docs.growi.org/en/admin-guide/getting-started/heroku.html) ([en](https://docs.growi.org/en/admin-guide/getting-started/heroku.html)/[ja](https://docs.growi.org/ja/admin-guide/getting-started/heroku.html))
 
 ### docker-compose
 
 - [GROWI Docs: Launch with docker-compose](https://docs.growi.org/en/admin-guide/getting-started/docker-compose.html) ([en](https://docs.growi.org/en/admin-guide/getting-started/docker-compose.html)/[ja](https://docs.growi.org/ja/admin-guide/getting-started/docker-compose.html))
 
+### Helm (Experimental)
+
+- [GROWI Helm Chart](https://github.com/weseek/helm-charts/tree/master/charts/growi)
+
 ### On-premise
 
 **[Migration Guide from Crowi](https://docs.growi.org/en/admin-guide/migration-guide/from-crowi-onpremise.html) ([en](https://docs.growi.org/en/admin-guide/migration-guide/from-crowi-onpremise.html)/[ja](https://docs.growi.org/ja/admin-guide/migration-guide/from-crowi-onpremise.html))** is here.

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.8-RC",
+  "version": "4.2.9-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 3 - 0
resource/Contributor.js

@@ -103,6 +103,9 @@ const contributors = [
           { position: 'The University of Tokyo', name: 'Takashi Yoneuchi' },
           { position: 'DeCurret', name: 'Yusuke Tanomogi' },
           { position: 'Flatt Security', name: 'stypr' },
+          { position: 'Flatt Security', name: 'Azara/Norihide Saito' },
+          { position: 'CyberAgent, Inc.', name: 'Daisuke Takahashi' },
+          { position: 'Mitsui Bussan Secure Directions, Inc.', name: 'Yuji Tounai' },
         ],
       },
     ],

+ 8 - 0
resource/locales/en_US/translation.json

@@ -323,6 +323,14 @@
     "outdated": "Page is updated someone and now outdated.",
     "user_not_admin": "Only admin user can delete completely"
   },
+  "page_history": {
+    "revision_list": "Revision list",
+    "revision": "version",
+    "comparing_source": "Source",
+    "comparing_target": "Target",
+    "comparing_revisions": "Comparing versions",
+    "comparing_with_latest": "Always compare with the latest version"
+  },
   "modal_rename": {
     "label": {
       "Move/Rename page": "Move/Rename page",

+ 8 - 0
resource/locales/ja_JP/translation.json

@@ -325,6 +325,14 @@
     "outdated": "ページが他のユーザーによって更新されました。",
     "user_not_admin": "権限のあるユーザーのみが完全削除できます"
   },
+  "page_history": {
+    "revision_list": "更新履歴",
+    "revision": "バージョン",
+    "comparing_source": "ソース",
+    "comparing_target": "ターゲット",
+    "comparing_revisions": "比較",
+    "comparing_with_latest": "常に最新バージョンと比較する"
+  },
   "modal_rename": {
     "label": {
       "Move/Rename page": "ページを移動/名前変更する",

+ 9 - 1
resource/locales/zh_CN/translation.json

@@ -303,7 +303,15 @@
 		"already_exists": "新建页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"user_not_admin": "仅管理员用户可以完全删除"
-	},
+  },
+  "page_history": {
+    "revision_list": "修订清单",
+    "revision": "版本",
+    "comparing_source": "源头",
+    "comparing_target": "目标",
+    "comparing_revisions": "比较版本",
+    "comparing_with_latest": "一定要与最新版本进行比较"
+  },
 	"modal_rename": {
 		"label": {
 			"Move/Rename page": "页面 移动/重命名",

+ 17 - 1
src/client/js/app.jsx

@@ -17,6 +17,9 @@ import PageTimeline from './components/PageTimeline';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageManagement from './components/Page/PageManagement';
 import ShareLinkAlert from './components/Page/ShareLinkAlert';
+import DuplicatedAlert from './components/Page/DuplicatedAlert';
+import RedirectedAlert from './components/Page/RedirectedAlert';
+import RenamedAlert from './components/Page/RenamedAlert';
 import TrashPageList from './components/TrashPageList';
 import TrashPageAlert from './components/Page/TrashPageAlert';
 import NotFoundPage from './components/NotFoundPage';
@@ -37,10 +40,12 @@ import GrowiSubNavigationSwitcher from './components/Navbar/GrowiSubNavigationSw
 import NavigationContainer from './services/NavigationContainer';
 import PageContainer from './services/PageContainer';
 import PageHistoryContainer from './services/PageHistoryContainer';
+import RevisionComparerContainer from './services/RevisionComparerContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
 import PersonalContainer from './services/PersonalContainer';
+import PageAccessoriesContainer from './services/PageAccessoriesContainer';
 
 import { appContainer, componentMappings } from './base';
 
@@ -55,12 +60,15 @@ const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const navigationContainer = new NavigationContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
+const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
 const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
+const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
-  appContainer, socketIoContainer, navigationContainer, pageContainer, pageHistoryContainer, commentContainer, editorContainer, tagContainer, personalContainer,
+  appContainer, socketIoContainer, navigationContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
+  commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -100,6 +108,9 @@ Object.assign(componentMappings, {
   'grw-fab-container': <Fab />,
 
   'share-link-alert': <ShareLinkAlert />,
+  'duplicated-alert': <DuplicatedAlert />,
+  'redirected-alert': <RedirectedAlert />,
+  'renamed-alert': <RenamedAlert />,
 });
 
 // additional definitions if data exists
@@ -114,6 +125,11 @@ if (pageContainer.state.pageId != null) {
     'recent-created-icon': <RecentlyCreatedIcon />,
     'user-bookmark-icon': <BookmarkIcon />,
   });
+
+  // show the Page accessory modal when query of "compare" is requested
+  if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
+    pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
+  }
 }
 if (pageContainer.state.creator != null) {
   Object.assign(componentMappings, {

+ 10 - 4
src/client/js/components/ContentLinkButtons.jsx

@@ -1,6 +1,8 @@
 import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 
+import { isTopPage } from '@commons/util/path-utils';
+import AppContainer from '../services/AppContainer';
 import NavigationContainer from '../services/NavigationContainer';
 import PageContainer from '../services/PageContainer';
 
@@ -16,9 +18,12 @@ const WIKI_HEADER_LINK = 120;
  */
 const ContentLinkButtons = (props) => {
 
-  const { navigationContainer, pageContainer } = props;
-  const { pageUser } = pageContainer.state;
+  const { appContainer, navigationContainer, pageContainer } = props;
+  const { pageUser, path } = pageContainer.state;
   const { isPageExist } = pageContainer.state;
+  const { isSharedUser } = appContainer;
+
+  const isTopPagePath = isTopPage(path);
 
   // get element for smoothScroll
   const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
@@ -71,7 +76,7 @@ const ContentLinkButtons = (props) => {
 
   return (
     <>
-      {isPageExist && <CommentLinkButton />}
+      {isPageExist && !isSharedUser && !isTopPagePath && <CommentLinkButton />}
 
       <div className="mt-3 d-flex justify-content-between">
         {pageUser && <><BookMarkLinkButton /><RecentlyCreatedLinkButton /></>}
@@ -82,8 +87,9 @@ const ContentLinkButtons = (props) => {
 };
 
 ContentLinkButtons.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
-export default withUnstatedContainers(ContentLinkButtons, [NavigationContainer, PageContainer]);
+export default withUnstatedContainers(ContentLinkButtons, [AppContainer, NavigationContainer, PageContainer]);

+ 5 - 3
src/client/js/components/EmptyTrashModal.jsx

@@ -8,12 +8,13 @@ import {
 import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 
+import SocketIoContainer from '../services/SocketIoContainer';
 import AppContainer from '../services/AppContainer';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 const EmptyTrashModal = (props) => {
   const {
-    t, isOpen, onClose, appContainer,
+    t, isOpen, onClose, appContainer, socketIoContainer,
   } = props;
 
   const [errs, setErrs] = useState(null);
@@ -22,7 +23,7 @@ const EmptyTrashModal = (props) => {
     setErrs(null);
 
     try {
-      await appContainer.apiv3Delete('/pages/empty-trash');
+      await appContainer.apiv3Delete('/pages/empty-trash', { socketClientId: socketIoContainer.getSocketClientId() });
       window.location.reload();
     }
     catch (err) {
@@ -55,12 +56,13 @@ const EmptyTrashModal = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const EmptyTrashModalWrapper = withUnstatedContainers(EmptyTrashModal, [AppContainer]);
+const EmptyTrashModalWrapper = withUnstatedContainers(EmptyTrashModal, [AppContainer, SocketIoContainer]);
 
 
 EmptyTrashModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  socketIoContainer: PropTypes.instanceOf(SocketIoContainer),
 
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,

+ 22 - 0
src/client/js/components/Page/DuplicatedAlert.jsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+
+const DuplicatedAlert = (props) => {
+  const { t } = props;
+
+  return (
+    <div className="alert alert-success py-3 px-4">
+      <strong>
+        { t('Duplicated') }:{t('page_page.notice.duplicated')}
+      </strong>
+    </div>
+  );
+};
+
+DuplicatedAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(DuplicatedAlert);

+ 20 - 0
src/client/js/components/Page/RedirectedAlert.jsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+
+const RedirectedAlert = (props) => {
+  const { t } = props;
+
+  return (
+    <>
+      <strong>{ t('Redirected') }:</strong>{ t('page_page.notice.redirected')}
+    </>
+  );
+};
+
+RedirectedAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(RedirectedAlert);

+ 20 - 0
src/client/js/components/Page/RenamedAlert.jsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+
+const RenamedAlert = (props) => {
+  const { t } = props;
+
+  return (
+    <>
+      <strong>{ t('Moved') }:</strong>{t('page_page.notice.moved')}
+    </>
+  );
+};
+
+RenamedAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(RenamedAlert);

+ 10 - 2
src/client/js/components/Page/TrashPageAlert.jsx

@@ -15,7 +15,7 @@ import PageDeleteModal from '../PageDeleteModal';
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const {
-    path, isDeleted, lastUpdateUsername, updatedAt, isAbleToDeleteCompletely,
+    path, isDeleted, lastUpdateUsername, updatedAt, deletedUserName, deletedAt, isAbleToDeleteCompletely,
   } = pageContainer.state;
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
@@ -110,7 +110,15 @@ const TrashPageAlert = (props) => {
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
-          {isDeleted && <span><br /><UserPicture user={{ username: lastUpdateUsername }} /> Deleted by {lastUpdateUsername} at {updatedAt}</span>}
+          {isDeleted && (
+            <>
+              <br />
+              <UserPicture user={{ username: deletedUserName || lastUpdateUsername }} />
+              <span className="ml-2">
+                Deleted by {deletedUserName || lastUpdateUsername} at {deletedAt || updatedAt}
+              </span>
+            </>
+          )}
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
           <span>{ pageContainer.isAbleToShowEmptyTrashButton && renderEmptyButton()}</span>

+ 7 - 3
src/client/js/components/PageHistory.jsx

@@ -10,7 +10,8 @@ import PageRevisionList from './PageHistory/PageRevisionList';
 
 import PageHistroyContainer from '../services/PageHistoryContainer';
 import PaginationWrapper from './PaginationWrapper';
-
+import RevisionComparer from './RevisionComparer/RevisionComparer';
+import RevisionComparerContainer from '../services/RevisionComparerContainer';
 
 const logger = loggerFactory('growi:PageHistory');
 
@@ -44,6 +45,7 @@ function PageHistory(props) {
     throw new Promise(async() => {
       try {
         await props.pageHistoryContainer.retrieveRevisions(1);
+        await props.revisionComparerContainer.initRevisions();
       }
       catch (err) {
         toastError(err);
@@ -66,7 +68,7 @@ function PageHistory(props) {
   }
 
   return (
-    <div>
+    <div className="revision-history">
       <PageRevisionList
         pageHistoryContainer={pageHistoryContainer}
         revisions={revisions}
@@ -75,15 +77,17 @@ function PageHistory(props) {
         onDiffOpenClicked={onDiffOpenClicked}
       />
       {pager()}
+      <RevisionComparer />
     </div>
   );
 
 }
 
-const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer]);
+const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer, RevisionComparerContainer]);
 
 PageHistory.propTypes = {
   pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
+  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
 };
 
 export default RenderPageHistoryWrapper;

+ 47 - 21
src/client/js/components/PageHistory/PageRevisionList.jsx

@@ -6,6 +6,7 @@ import PageHistroyContainer from '../../services/PageHistoryContainer';
 
 import Revision from './Revision';
 import RevisionDiff from './RevisionDiff';
+import RevisionSelector from '../RevisionComparer/RevisionSelector';
 
 class PageRevisionList extends React.Component {
 
@@ -31,25 +32,36 @@ class PageRevisionList extends React.Component {
    * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
    */
   renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
+    const { latestRevision } = this.props.pageHistoryContainer.state;
     const revisionId = revision._id;
     const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
 
-    const classNames = ['revision-history-outer'];
+    const classNames = ['revision-history-outer', 'row', 'no-gutters'];
     if (isContiguousNodiff) {
       classNames.push('revision-history-outer-contiguous-nodiff');
     }
 
     return (
       <div className={classNames.join(' ')} key={`revision-history-${revisionId}`}>
-        <Revision
-          t={this.props.t}
-          revision={revision}
-          revisionDiffOpened={revisionDiffOpened}
-          hasDiff={hasDiff}
-          isCompactNodiffRevisions={this.state.isCompactNodiffRevisions}
-          onDiffOpenClicked={this.props.onDiffOpenClicked}
-          key={`revision-history-rev-${revisionId}`}
-        />
+        <div className="col-8" key={`revision-history-top-${revisionId}`}>
+          <Revision
+            t={this.props.t}
+            revision={revision}
+            isLatestRevision={revision === latestRevision}
+            revisionDiffOpened={revisionDiffOpened}
+            hasDiff={hasDiff}
+            isCompactNodiffRevisions={this.state.isCompactNodiffRevisions}
+            onDiffOpenClicked={this.props.onDiffOpenClicked}
+            key={`revision-history-rev-${revisionId}`}
+          />
+        </div>
+        <div className="col-4 align-self-center">
+          <RevisionSelector
+            revision={revision}
+            hasDiff={hasDiff}
+            key={`revision-compare-target-selector-${revisionId}`}
+          />
+        </div>
         { hasDiff
           && (
           <RevisionDiff
@@ -101,19 +113,33 @@ class PageRevisionList extends React.Component {
 
     return (
       <React.Fragment>
-        <div className="custom-control custom-checkbox custom-checkbox-info float-right">
-          <input
-            type="checkbox"
-            id="cbCompactize"
-            className="custom-control-input"
-            checked={this.state.isCompactNodiffRevisions}
-            onChange={this.cbCompactizeChangeHandler}
-          />
-          <label className="custom-control-label" htmlFor="cbCompactize">{ t('Shrink versions that have no diffs') }</label>
+        <div className="d-flex">
+          <h3>{t('page_history.revision_list')}</h3>
+          <div className="custom-control custom-checkbox custom-checkbox-info ml-auto">
+            <input
+              type="checkbox"
+              id="cbCompactize"
+              className="custom-control-input"
+              checked={this.state.isCompactNodiffRevisions}
+              onChange={this.cbCompactizeChangeHandler}
+            />
+            <label className="custom-control-label" htmlFor="cbCompactize">{ t('Shrink versions that have no diffs') }</label>
+          </div>
         </div>
-        <div className="clearfix"></div>
+        <hr />
         <div className={classNames.join(' ')}>
-          {revisionList}
+          <div className="revision-history-list-container">
+            <div className="revision-history-list-content-header sticky-top bg-white">
+              <div className="row no-gutters">
+                <div className="col-8">{ t('page_history.revision') }</div>
+                <div className="col-2 text-center">{ t('page_history.comparing_source') }</div>
+                <div className="col-2 text-center">{ t('page_history.comparing_target') }</div>
+              </div>
+            </div>
+            <div className="revision-history-list-content-body">
+              {revisionList}
+            </div>
+          </div>
         </div>
       </React.Fragment>
     );

+ 6 - 0
src/client/js/components/PageHistory/Revision.jsx

@@ -66,6 +66,11 @@ export default class Revision extends React.Component {
         <div className="ml-2">
           <div className="revision-history-author">
             <strong><Username user={author}></Username></strong>
+            { this.props.isLatestRevision
+              && (
+              <span className="badge badge-info ml-2">Latest</span>
+              )
+            }
           </div>
           <div className="revision-history-meta">
             <p>
@@ -111,6 +116,7 @@ export default class Revision extends React.Component {
 Revision.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   revision: PropTypes.object,
+  isLatestRevision: PropTypes.bool.isRequired,
   revisionDiffOpened: PropTypes.bool.isRequired,
   hasDiff: PropTypes.bool.isRequired,
   isCompactNodiffRevisions: PropTypes.bool.isRequired,

+ 123 - 0
src/client/js/components/RevisionComparer/RevisionComparer.jsx

@@ -0,0 +1,123 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import {
+  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import RevisionComparerContainer from '../../services/RevisionComparerContainer';
+
+import RevisionDiff from '../PageHistory/RevisionDiff';
+
+/* eslint-disable react/prop-types */
+const DropdownItemContents = ({ title, contents }) => (
+  <>
+    <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
+    <div className="card well mb-1 p-2">{contents}</div>
+  </>
+);
+/* eslint-enable react/prop-types */
+
+function encodeSpaces(str) {
+  if (str == null) {
+    return null;
+  }
+
+  // Encode SPACE and IDEOGRAPHIC SPACE
+  return str.replace(/ /g, '%20').replace(/\u3000/g, '%E3%80%80');
+}
+
+const RevisionComparer = (props) => {
+
+  const [dropdownOpen, setDropdownOpen] = useState(false);
+
+  const { t, revisionComparerContainer } = props;
+
+  function toggleDropdown() {
+    setDropdownOpen(!dropdownOpen);
+  }
+
+  const pagePathUrl = () => {
+    const { origin } = window.location;
+    const { path } = revisionComparerContainer.pageContainer.state;
+    const { sourceRevision, targetRevision } = revisionComparerContainer.state;
+
+    const urlParams = (sourceRevision && targetRevision ? `?compare=${sourceRevision._id}...${targetRevision._id}` : '');
+    return encodeSpaces(decodeURI(`${origin}/${path}${urlParams}`));
+  };
+
+  const { sourceRevision, targetRevision } = revisionComparerContainer.state;
+  const showDiff = (sourceRevision && targetRevision);
+
+  return (
+    <div className="revision-compare">
+      <div className="d-flex">
+        <h3 className="align-self-center mb-0">{ t('page_history.comparing_revisions') }</h3>
+        <div className="align-self-center ml-3">
+          <div className="custom-control custom-switch">
+            <input
+              type="checkbox"
+              className="custom-control-input"
+              id="comparingWithLatest"
+              checked={revisionComparerContainer.state.compareWithLatest}
+              onChange={() => revisionComparerContainer.toggleCompareWithLatest()}
+            />
+            <label className="custom-control-label" htmlFor="comparingWithLatest">
+              { t('page_history.comparing_with_latest') }
+            </label>
+          </div>
+        </div>
+        <Dropdown
+          className="grw-copy-dropdown align-self-center ml-auto"
+          isOpen={dropdownOpen}
+          toggle={() => toggleDropdown()}
+        >
+          <DropdownToggle
+            caret
+            className="d-block text-muted bg-transparent btn-copy border-0 py-0"
+          >
+            <i className="ti-clipboard"></i>
+          </DropdownToggle>
+          <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: null } }}>
+            {/* Page path URL */}
+            <CopyToClipboard text={pagePathUrl()}>
+              <DropdownItem className="px-3">
+                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={pagePathUrl()} />
+              </DropdownItem>
+            </CopyToClipboard>
+            <DropdownItem divider className="my-0"></DropdownItem>
+          </DropdownMenu>
+        </Dropdown>
+      </div>
+
+      <hr />
+
+      <div className="revision-compare-outer">
+        { showDiff && (
+          <RevisionDiff
+            revisionDiffOpened
+            previousRevision={sourceRevision}
+            currentRevision={targetRevision}
+          />
+        )}
+      </div>
+    </div>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const RevisionComparerWrapper = withUnstatedContainers(RevisionComparer, [RevisionComparerContainer]);
+
+RevisionComparer.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
+
+  revisions: PropTypes.array,
+};
+
+export default withTranslation()(RevisionComparerWrapper);

+ 67 - 0
src/client/js/components/RevisionComparer/RevisionSelector.jsx

@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import { withLoadingSppiner } from '../SuspenseUtils';
+
+import RevisionComparerContainer from '../../services/RevisionComparerContainer';
+
+const RevisionSelector = (props) => {
+
+  const { revision, hasDiff, revisionComparerContainer } = props;
+  const { sourceRevision, targetRevision } = revisionComparerContainer.state;
+
+  if (!hasDiff) {
+    return <></>;
+  }
+
+  return (
+    <React.Fragment>
+      <div className="container-fluid px-0">
+        <div className="row no-gutters">
+          <div className="col text-center">
+            <div className="custom-control custom-radio custom-control-inline mr-0">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id={`compareSource-${revision._id}`}
+                name="compareSource"
+                value={revision._id}
+                checked={revision._id === sourceRevision?._id}
+                onChange={() => revisionComparerContainer.setState({ sourceRevision: revision })}
+              />
+              <label className="custom-control-label" htmlFor={`compareSource-${revision._id}`} />
+            </div>
+          </div>
+          <div className="col text-center">
+            <div className="custom-control custom-radio custom-control-inline mr-0">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id={`compareTarget-${revision._id}`}
+                name="compareTarget"
+                value={revision._id}
+                checked={revision._id === targetRevision?._id}
+                onChange={() => revisionComparerContainer.setState({ targetRevision: revision })}
+                disabled={revisionComparerContainer.state.compareWithLatest}
+              />
+              <label className="custom-control-label" htmlFor={`compareTarget-${revision._id}`} />
+            </div>
+          </div>
+        </div>
+      </div>
+    </React.Fragment>
+  );
+
+};
+
+const RevisionSelectorWrapper = withUnstatedContainers(withLoadingSppiner(RevisionSelector), [RevisionComparerContainer]);
+
+RevisionSelector.propTypes = {
+  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
+
+  revision: PropTypes.object,
+  hasDiff: PropTypes.bool.isRequired,
+};
+
+export default RevisionSelectorWrapper;

+ 14 - 0
src/client/js/services/AdminAppContainer.js

@@ -201,6 +201,20 @@ export default class AdminAppContainer extends Container {
     this.setState({ smtpPassword });
   }
 
+  /**
+   * Change sesAccessKeyId
+   */
+  changeSesAccessKeyId(sesAccessKeyId) {
+    this.setState({ sesAccessKeyId });
+  }
+
+  /**
+   * Change sesSecretAccessKey
+   */
+  changeSesSecretAccessKey(sesSecretAccessKey) {
+    this.setState({ sesSecretAccessKey });
+  }
+
   /**
    * Change s3Region
    */

+ 2 - 0
src/client/js/services/PageContainer.js

@@ -60,6 +60,7 @@ export default class PageContainer extends Container {
       sumOfBookmarks: 0,
       createdAt: mainContent.getAttribute('data-page-created-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
+      deletedAt: mainContent.getAttribute('data-page-deleted-at') || null,
 
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
@@ -80,6 +81,7 @@ export default class PageContainer extends Container {
       remoteRevisionId: revisionId,
       revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
+      deleteUsername: mainContent.getAttribute('data-page-delete-username') || null,
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,

+ 5 - 0
src/client/js/services/PageHistoryContainer.js

@@ -24,6 +24,7 @@ export default class PageHistoryContainer extends Container {
 
       // set dummy rivisions for using suspense
       revisions: this.dummyRevisions,
+      latestRevision: this.dummyRevisions,
       diffOpened: {},
 
       totalPages: 0,
@@ -96,6 +97,10 @@ export default class PageHistoryContainer extends Container {
     this.setState({ revisions: rev });
     this.setState({ diffOpened });
 
+    if (selectedPage === 1) {
+      this.setState({ latestRevision: rev[0] });
+    }
+
     // load 0, and last default
     if (rev[0]) {
       this.fetchPageRevisionBody(rev[0]);

+ 129 - 0
src/client/js/services/RevisionComparerContainer.js

@@ -0,0 +1,129 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+const logger = loggerFactory('growi:PageHistoryContainer');
+
+/**
+ * Service container for personal settings page (RevisionCompare.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class RevisionComparerContainer extends Container {
+
+  constructor(appContainer, pageContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.pageContainer = pageContainer;
+
+    this.state = {
+      errMessage: null,
+
+      sourceRevision: null,
+      targetRevision: null,
+      latestRevision: null,
+      compareWithLatest: true,
+    };
+
+    this.initRevisions = this.initRevisions.bind(this);
+    this.toggleCompareWithLatest = this.toggleCompareWithLatest.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'RevisionComparerContainer';
+  }
+
+  /**
+   * Initialize the revisions
+   */
+  async initRevisions() {
+    const latestRevision = await this.fetchLatestRevision();
+
+    const [sourceRevisionId, targetRevisionId] = this.getRevisionIDsToCompareAsParam();
+    const sourceRevision = sourceRevisionId ? await this.fetchRevision(sourceRevisionId) : latestRevision;
+    const targetRevision = targetRevisionId ? await this.fetchRevision(targetRevisionId) : latestRevision;
+    const compareWithLatest = targetRevisionId ? false : this.state.compareWithLatest;
+
+    this.setState({
+      sourceRevision, targetRevision, latestRevision, compareWithLatest,
+    });
+  }
+
+  /**
+   * Get the IDs of the comparison source and target from "window.location" as an array
+   */
+  getRevisionIDsToCompareAsParam() {
+    const searchParams = {};
+    for (const param of window.location.search?.substr(1)?.split('&')) {
+      const [k, v] = param.split('=');
+      searchParams[k] = v;
+    }
+    if (!searchParams.compare) {
+      return [];
+    }
+
+    return searchParams.compare.split('...') || [];
+  }
+
+  /**
+   * Fetch the latest revision
+   */
+  async fetchLatestRevision() {
+    const { pageId, shareLinkId } = this.pageContainer.state;
+
+    try {
+      const res = await this.appContainer.apiv3Get('/revisions/list', {
+        pageId, shareLinkId, page: 1, limit: 1,
+      });
+      return res.data.docs[0];
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ errorMessage: err.message });
+      logger.error(err);
+    }
+    return null;
+  }
+
+  /**
+   * Fetch the revision of the specified ID
+   * @param {string} revision ID
+   */
+  async fetchRevision(revisionId) {
+    const { pageId, shareLinkId } = this.pageContainer.state;
+
+    try {
+      const res = await this.appContainer.apiv3Get(`/revisions/${revisionId}`, {
+        pageId, shareLinkId,
+      });
+      return res.data.revision;
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ errorMessage: err.message });
+      logger.error(err);
+    }
+    return null;
+  }
+
+  /**
+   * toggle state "compareWithLatest", and if true, set "targetRevision" to the latest revision
+   */
+  toggleCompareWithLatest() {
+    const { compareWithLatest } = this.state;
+    const newCompareWithLatest = !compareWithLatest;
+
+    this.setState(
+      Object.assign(
+        { compareWithLatest: newCompareWithLatest },
+        (newCompareWithLatest === true ? { targetRevision: this.state.latestRevision } : {}),
+      ),
+    );
+  }
+
+}

+ 5 - 2
src/client/styles/scss/_on-edit.scss

@@ -232,12 +232,15 @@ body.on-edit {
     }
 
     .page-editor-preview-container {
-      overflow-y: scroll;
     }
 
     .page-editor-preview-body {
-      max-width: 980px;
       padding: 18px 15px 0;
+      overflow-y: scroll;
+    }
+
+    .wiki {
+      max-width: 980px;
       margin: 0 auto;
     }
 

+ 73 - 0
src/client/styles/scss/_page-history.scss

@@ -0,0 +1,73 @@
+.revision-history {
+  .revision-history-list {
+    .revision-history-list-container {
+      min-height: 200px;
+      max-height: calc(100vh - 100px - 550px);
+      overflow: auto;
+    }
+
+    .revision-history-list-content-body {
+      .revision-history-outer {
+        // add border-top except of first element
+        &:not(:first-of-type) {
+          @extend .border-top;
+        }
+
+        .revision-history-main {
+          .picture-lg {
+            width: 32px;
+            height: 32px;
+          }
+
+          .revision-history-meta {
+            a:hover {
+              cursor: pointer;
+            }
+          }
+
+          .caret {
+            transition: 0.4s;
+            transform: rotate(-90deg);
+
+            &.caret-opened {
+              transform: rotate(0deg);
+            }
+          }
+        }
+
+        .revision-history-main-nodiff {
+          .picture-container {
+            min-width: 32px;
+            text-align: center; // centering .picture
+          }
+        }
+
+        .revision-history-diff {
+          padding-left: 40px;
+          color: $gray-900;
+          table-layout: fixed;
+        }
+      }
+
+      li {
+        position: relative;
+        list-style: none;
+      }
+    }
+  }
+
+  // compacted list
+  .revision-history-list-compact {
+    .revision-history-outer-contiguous-nodiff {
+      border-top: unset !important; // force unset border
+    }
+  }
+
+  .revision-compare {
+    .revision-compare-outer {
+      min-height: 100px;
+      max-height: 250px;
+      overflow: auto;
+    }
+  }
+}

+ 0 - 68
src/client/styles/scss/_page.scss

@@ -1,74 +1,6 @@
 // import diff2html styles
 @import '~diff2html/bundles/css/diff2html.min.css';
 
-.revision-history {
-  .revision-history-list {
-    .revision-history-outer {
-      // add border-top except of first element
-      &:not(:first-of-type) {
-        @extend .border-top;
-      }
-
-      .revision-history-main {
-        .picture-lg {
-          width: 32px;
-          height: 32px;
-        }
-
-        .revision-history-meta {
-          a:hover {
-            cursor: pointer;
-          }
-        }
-
-        .caret {
-          transition: 0.4s;
-          transform: rotate(-90deg);
-
-          &.caret-opened {
-            transform: rotate(0deg);
-          }
-        }
-      }
-
-      .revision-history-main-nodiff {
-        .picture-container {
-          min-width: 32px;
-          text-align: center; // centering .picture
-        }
-      }
-
-      .revision-history-diff {
-        padding-left: 40px;
-        color: $gray-900;
-        table-layout: fixed;
-      }
-    }
-
-    li {
-      position: relative;
-      list-style: none;
-    }
-  }
-
-  // compacted list
-  .revision-history-list-compact {
-    .revision-history-outer-contiguous-nodiff {
-      border-top: unset !important; // force unset border
-    }
-  }
-
-  // adjust
-  // this is for diff2html. hide page name from diff view
-  .d2h-file-header {
-    display: none;
-  }
-
-  .d2h-diff-tbody {
-    background-color: white;
-  }
-}
-
 /**
  * for table with handsontable modal button
  */

+ 1 - 0
src/client/styles/scss/style-app.scss

@@ -55,6 +55,7 @@
 @import 'page-path';
 @import 'page';
 @import 'page-presentation';
+@import 'page-history';
 @import 'search';
 @import 'shortcuts';
 @import 'sidebar';

+ 2 - 1
src/server/crowi/index.js

@@ -147,13 +147,14 @@ Crowi.prototype.initForTest = async function() {
     // this.setupSlack(),
     // this.setupCsrf(),
     // this.setUpFileUpload(),
-    // this.setupAttachmentService(),
+    this.setupAttachmentService(),
     this.setUpAcl(),
     // this.setUpCustomize(),
     // this.setUpRestQiitaAPI(),
     // this.setupUserGroup(),
     // this.setupExport(),
     // this.setupImport(),
+    this.setupPageService(),
   ]);
 
   // globalNotification depends on slack and mailer

+ 3 - 0
src/server/events/page.js

@@ -15,5 +15,8 @@ PageEvent.prototype.onCreate = function(page, user) {
 PageEvent.prototype.onUpdate = function(page, user) {
   debug('onUpdate event fired');
 };
+PageEvent.prototype.onCreateMany = function(pages, user) {
+  debug('onCreateMany event fired');
+};
 
 module.exports = PageEvent;

+ 8 - 204
src/server/models/page.js

@@ -14,7 +14,7 @@ const differenceInYears = require('date-fns/differenceInYears');
 
 const { pathUtils } = require('growi-commons');
 const templateChecker = require('@commons/util/template-checker');
-const { isTopPage } = require('@commons/util/path-utils');
+const { isTopPage, isTrashPage } = require('@commons/util/path-utils');
 const escapeStringRegexp = require('escape-string-regexp');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
@@ -66,6 +66,8 @@ const pageSchema = new mongoose.Schema({
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
   createdAt: { type: Date, default: Date.now },
   updatedAt: { type: Date, default: Date.now },
+  deleteUser: { type: ObjectId, ref: 'User' },
+  deletedAt: { type: Date },
 }, {
   toJSON: { getters: true },
   toObject: { getters: true },
@@ -107,6 +109,7 @@ const populateDataToShowRevision = (page, userPublicFields) => {
     .populate([
       { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
       { path: 'creator', model: 'User', select: userPublicFields },
+      { path: 'deleteUser', model: 'User', select: userPublicFields },
       { path: 'grantedGroup', model: 'UserGroup' },
       { path: 'revision', model: 'Revision', populate: {
         path: 'author', model: 'User', select: userPublicFields,
@@ -293,6 +296,7 @@ module.exports = function(crowi) {
     pageEvent = crowi.event('page');
     pageEvent.on('create', pageEvent.onCreate);
     pageEvent.on('update', pageEvent.onUpdate);
+    pageEvent.on('createMany', pageEvent.onCreateMany);
   }
 
   function validateCrowi() {
@@ -302,7 +306,7 @@ module.exports = function(crowi) {
   }
 
   pageSchema.methods.isDeleted = function() {
-    return (this.status === STATUS_DELETED) || checkIfTrashed(this.path);
+    return (this.status === STATUS_DELETED) || isTrashPage(this.path);
   };
 
   pageSchema.methods.isPublic = function() {
@@ -1102,121 +1106,6 @@ module.exports = function(crowi) {
     }
   };
 
-  pageSchema.statics.deletePage = async function(pageData, user, options = {}) {
-    const newPath = this.getDeletedPageName(pageData.path);
-    const isTrashed = checkIfTrashed(pageData.path);
-
-    if (isTrashed) {
-      throw new Error('This method does NOT support deleting trashed pages.');
-    }
-
-    const socketClientId = options.socketClientId || null;
-    if (this.isDeletableName(pageData.path)) {
-
-      pageData.status = STATUS_DELETED;
-      const updatedPageData = await this.rename(pageData, newPath, user, { socketClientId, createRedirectPage: true });
-
-      return updatedPageData;
-    }
-
-    return Promise.reject(new Error('Page is not deletable.'));
-  };
-
-  const checkIfTrashed = (path) => {
-    return (path.search(/^\/trash/) !== -1);
-  };
-
-  pageSchema.statics.deletePageRecursively = async function(targetPage, user, options = {}) {
-    const isTrashed = checkIfTrashed(targetPage.path);
-
-    if (isTrashed) {
-      throw new Error('This method does NOT supports deleting trashed pages.');
-    }
-
-    // find manageable descendants (this array does not include GRANT_RESTRICTED)
-    const pages = await this.findManageableListWithDescendants(targetPage, user, options);
-
-    await Promise.all(pages.map((page) => {
-      return this.deletePage(page, user, options);
-    }));
-  };
-
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.revertDeletedPage = async function(page, user, options = {}) {
-    const newPath = this.getRevertDeletedPageName(page.path);
-
-    const originPage = await this.findByPath(newPath);
-    if (originPage != null) {
-      // 削除時、元ページの path には必ず redirectTo 付きで、ページが作成される。
-      // そのため、そいつは削除してOK
-      // が、redirectTo ではないページが存在している場合それは何かがおかしい。(データ補正が必要)
-      if (originPage.redirectTo !== page.path) {
-        throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
-      }
-
-      await this.completelyDeletePage(originPage, options);
-    }
-
-    page.status = STATUS_PUBLISHED;
-    page.lastUpdateUser = user;
-    debug('Revert deleted the page', page, newPath);
-    const updatedPage = await this.rename(page, newPath, user, {});
-
-    return updatedPage;
-  };
-
-  pageSchema.statics.revertDeletedPageRecursively = async function(targetPage, user, options = {}) {
-    const findOpts = { includeTrashed: true };
-    const pages = await this.findManageableListWithDescendants(targetPage, user, findOpts);
-
-    let updatedPage = null;
-    await Promise.all(pages.map((page) => {
-      const isParent = (page.path === targetPage.path);
-      const p = this.revertDeletedPage(page, user, options);
-      if (isParent) {
-        updatedPage = p;
-      }
-      return p;
-    }));
-
-    return updatedPage;
-  };
-
-  /**
-   * This is danger.
-   */
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.completelyDeletePage = async function(pageData, user, options = {}) {
-    validateCrowi();
-
-    const { _id, path } = pageData;
-    const socketClientId = options.socketClientId || null;
-
-    logger.debug('Deleting completely', path);
-
-    await crowi.pageService.deleteCompletely(_id, path);
-
-    if (socketClientId != null) {
-      pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
-    }
-    return pageData;
-  };
-
-  /**
-   * Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-   */
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.completelyDeletePageRecursively = async function(targetPage, user, options = {}) {
-    const findOpts = { includeTrashed: true };
-
-    // find manageable descendants (this array does not include GRANT_RESTRICTED)
-    const pages = await this.findManageableListWithDescendants(targetPage, user, findOpts);
-
-    await Promise.all(pages.map((page) => {
-      return this.completelyDeletePage(page, user, options);
-    }));
-  };
-
   pageSchema.statics.removeByPath = function(path) {
     if (path == null) {
       throw new Error('path is required');
@@ -1247,66 +1136,6 @@ module.exports = function(crowi) {
     await this.removeRedirectOriginPageByPath(redirectPage.path);
   };
 
-  pageSchema.statics.rename = async function(pageData, newPagePath, user, options) {
-    validateCrowi();
-
-    const Page = this;
-    const Revision = crowi.model('Revision');
-    const path = pageData.path;
-    const createRedirectPage = options.createRedirectPage || false;
-    const updateMetadata = options.updateMetadata || false;
-    const socketClientId = options.socketClientId || null;
-
-    // sanitize path
-    newPagePath = crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
-
-    // update Page
-    pageData.path = newPagePath;
-    if (updateMetadata) {
-      pageData.lastUpdateUser = user;
-      pageData.updatedAt = Date.now();
-    }
-    const updatedPageData = await pageData.save();
-
-    // update Rivisions
-    await Revision.updateRevisionListByPath(path, { path: newPagePath }, {});
-
-    if (createRedirectPage) {
-      const body = `redirect ${newPagePath}`;
-      await Page.create(path, body, user, { redirectTo: newPagePath });
-    }
-
-    pageEvent.emit('delete', pageData, user, socketClientId);
-    pageEvent.emit('create', updatedPageData, user, socketClientId);
-
-    return updatedPageData;
-  };
-
-  pageSchema.statics.renameRecursively = async function(targetPage, newPagePathPrefix, user, options) {
-    validateCrowi();
-
-    const path = targetPage.path;
-    const pathRegExp = new RegExp(`^${escapeStringRegexp(path)}`, 'i');
-
-    // sanitize path
-    newPagePathPrefix = crowi.xss.process(newPagePathPrefix); // eslint-disable-line no-param-reassign
-
-    // find manageable descendants
-    const pages = await this.findManageableListWithDescendants(targetPage, user, options);
-
-    // TODO GW-4634 use stream
-    const promise = pages.map((page) => {
-      const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
-      return this.rename(page, newPagePath, user, options);
-    });
-
-    await Promise.allSettled(promise);
-
-    targetPage.path = newPagePathPrefix;
-    return targetPage;
-
-  };
-
   pageSchema.statics.findListByPathsArray = async function(paths) {
     const queryBuilder = new PageQueryBuilder(this.find());
     queryBuilder.addConditionToListByPathsArray(paths);
@@ -1314,33 +1143,6 @@ module.exports = function(crowi) {
     return await queryBuilder.query.exec();
   };
 
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.handlePrivatePagesForDeletedGroup = async function(deletedGroup, action, transferToUserGroupId) {
-    const Page = mongoose.model('Page');
-
-    const pages = await this.find({ grantedGroup: deletedGroup });
-
-    switch (action) {
-      case 'public':
-        await Promise.all(pages.map((page) => {
-          return Page.publicizePage(page);
-        }));
-        break;
-      case 'delete':
-        await Promise.all(pages.map((page) => {
-          return Page.completelyDeletePage(page);
-        }));
-        break;
-      case 'transfer':
-        await Promise.all(pages.map((page) => {
-          return Page.transferPageToGroup(page, transferToUserGroupId);
-        }));
-        break;
-      default:
-        throw new Error('Unknown action for private pages');
-    }
-  };
-
   pageSchema.statics.publicizePage = async function(page) {
     page.grantedGroup = null;
     page.grant = GRANT_PUBLIC;
@@ -1409,6 +1211,8 @@ module.exports = function(crowi) {
 
   };
 
+  pageSchema.statics.STATUS_PUBLISHED = STATUS_PUBLISHED;
+  pageSchema.statics.STATUS_DELETED = STATUS_DELETED;
   pageSchema.statics.GRANT_PUBLIC = GRANT_PUBLIC;
   pageSchema.statics.GRANT_RESTRICTED = GRANT_RESTRICTED;
   pageSchema.statics.GRANT_SPECIFIED = GRANT_SPECIFIED;

+ 1 - 2
src/server/models/user-group.js

@@ -92,7 +92,6 @@ class UserGroup {
   // グループの完全削除
   static async removeCompletelyById(deleteGroupId, action, transferToUserGroupId) {
     const UserGroupRelation = mongoose.model('UserGroupRelation');
-    const Page = mongoose.model('Page');
 
     const groupToDelete = await this.findById(deleteGroupId);
     if (groupToDelete == null) {
@@ -102,7 +101,7 @@ class UserGroup {
 
     await Promise.all([
       UserGroupRelation.removeAllByUserGroup(deletedGroup),
-      Page.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId),
+      UserGroup.crowi.pageService.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId),
     ]);
 
     return deletedGroup;

+ 11 - 20
src/server/routes/apiv3/pages.js

@@ -4,7 +4,7 @@ const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line
 const express = require('express');
 const pathUtils = require('growi-commons').pathUtils;
 
-const { body } = require('express-validator/check');
+const { body } = require('express-validator');
 const { query } = require('express-validator');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -395,13 +395,7 @@ module.exports = (crowi) => {
       if (!page.isUpdatable(revisionId)) {
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
       }
-
-      if (isRecursively) {
-        page = await Page.renameRecursively(page, newPagePath, req.user, options);
-      }
-      else {
-        page = await Page.rename(page, newPagePath, req.user, options);
-      }
+      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options, isRecursively);
     }
     catch (err) {
       logger.error(err);
@@ -423,7 +417,9 @@ module.exports = (crowi) => {
     return res.apiv3(result);
   });
 
-
+  validator.emptyTrash = [
+    query('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
+  ];
   /**
    * @swagger
    *
@@ -435,9 +431,12 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to remove all trash pages
    */
-  router.delete('/empty-trash', loginRequired, adminRequired, csrf, async(req, res) => {
+  router.delete('/empty-trash', accessTokenParser, loginRequired, adminRequired, csrf, validator.emptyTrash, apiV3FormValidator, async(req, res) => {
+    const socketClientId = parseInt(req.query.socketClientId);
+    const options = { socketClientId };
+
     try {
-      const pages = await Page.completelyDeletePageRecursively({ path: '/trash' }, req.user);
+      const pages = await crowi.pageService.deletePageRecursivelyCompletely({ path: '/trash' }, req.user, options);
       return res.apiv3({ pages });
     }
     catch (err) {
@@ -537,15 +536,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Not Founded the page', 'notfound_or_forbidden'), 404);
     }
 
-    let newParentPage;
-
-    if (isRecursively) {
-      newParentPage = await crowi.pageService.duplicateRecursively(page, newPagePath, req.user);
-    }
-    else {
-      newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user);
-    }
-
+    const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
     const result = { page: serializePageSecurely(newParentPage) };
 
     page.path = newPagePath;

+ 11 - 1
src/server/routes/apiv3/revisions.js

@@ -91,7 +91,17 @@ module.exports = (crowi) => {
    *            name: pageId
    *            schema:
    *              type: string
-   *              description:  page id
+   *              description: page id
+   *          - in: query
+   *            name: page
+   *            description: selected page number
+   *            schema:
+   *              type: number
+   *          - in: query
+   *            name: limit
+   *            description: page item limit
+   *            schema:
+   *              type: number
    *        responses:
    *          200:
    *            description: Return revisions belong to page

+ 1 - 11
src/server/routes/attachment.js

@@ -244,16 +244,6 @@ module.exports = function(crowi, app) {
     }
   }
 
-  async function removeAttachment(attachmentId) {
-    const { fileUploadService } = crowi;
-
-    // retrieve data from DB to get a completely populated instance
-    const attachment = await Attachment.findById(attachmentId);
-
-    await fileUploadService.deleteFile(attachment);
-
-    return attachment.remove();
-  }
 
   const actions = {};
   const api = {};
@@ -637,7 +627,7 @@ module.exports = function(crowi, app) {
     }
 
     try {
-      await removeAttachment(attachment);
+      await attachmentService.removeAttachment(attachment);
     }
     catch (err) {
       logger.error(err);

+ 7 - 25
src/server/routes/page.js

@@ -238,6 +238,9 @@ module.exports = function(crowi, app) {
     if (page.revision.author != null) {
       renderVars.revision.author = renderVars.revision.author.toObject();
     }
+    if (page.deleteUser != null) {
+      renderVars.page.deleteUser = renderVars.page.deleteUser.toObject();
+    }
   }
 
   function addRenderVarsForPresentation(renderVars, page) {
@@ -1187,24 +1190,14 @@ module.exports = function(crowi, app) {
         if (!req.user.canDeleteCompletely(page.creator)) {
           return res.json(ApiResponse.error('You can not delete completely', 'user_not_admin'));
         }
-        if (isRecursively) {
-          await Page.completelyDeletePageRecursively(page, req.user, options);
-        }
-        else {
-          await Page.completelyDeletePage(page, req.user, options);
-        }
+        await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
       }
       else {
         if (!page.isUpdatable(previousRevision)) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
 
-        if (isRecursively) {
-          await Page.deletePageRecursively(page, req.user, options);
-        }
-        else {
-          await Page.deletePage(page, req.user, options);
-        }
+        await crowi.pageService.deletePage(page, req.user, options, isRecursively);
       }
     }
     catch (err) {
@@ -1247,13 +1240,7 @@ module.exports = function(crowi, app) {
       if (page == null) {
         throw new Error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden');
       }
-
-      if (isRecursively) {
-        page = await Page.revertDeletedPageRecursively(page, req.user, { socketClientId });
-      }
-      else {
-        page = await Page.revertDeletedPage(page, req.user, { socketClientId });
-      }
+      page = await crowi.pageService.revertDeletedPage(page, req.user, { socketClientId }, isRecursively);
     }
     catch (err) {
       logger.error('Error occured while get setting', err);
@@ -1359,12 +1346,7 @@ module.exports = function(crowi, app) {
         return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
       }
 
-      if (isRecursively) {
-        page = await Page.renameRecursively(page, newPagePath, req.user, options);
-      }
-      else {
-        page = await Page.rename(page, newPagePath, req.user, options);
-      }
+      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options, isRecursively);
     }
     catch (err) {
       logger.error(err);

+ 23 - 3
src/server/service/attachment.js

@@ -1,5 +1,5 @@
 const logger = require('@alias/logger')('growi:service:AttachmentService'); // eslint-disable-line no-unused-vars
-
+const mongoose = require('mongoose');
 const fs = require('fs');
 
 
@@ -43,14 +43,34 @@ class AttachmentService {
     return attachment;
   }
 
+  async removeAllAttachments(attachments) {
+    const { fileUploadService } = this.crowi;
+    const attachmentsCollection = mongoose.connection.collection('attachments');
+    const unorderAttachmentsBulkOp = attachmentsCollection.initializeUnorderedBulkOp();
+
+    if (attachments.length === 0) {
+      return;
+    }
+
+    attachments.forEach((attachment) => {
+      unorderAttachmentsBulkOp.find({ _id: attachment._id }).remove();
+    });
+    await unorderAttachmentsBulkOp.execute();
+
+    await fileUploadService.deleteFiles(attachments);
+
+    return;
+  }
+
   async removeAttachment(attachmentId) {
     const Attachment = this.crowi.model('Attachment');
     const { fileUploadService } = this.crowi;
-
     const attachment = await Attachment.findById(attachmentId);
+
     await fileUploadService.deleteFile(attachment);
+    await attachment.remove();
 
-    return attachment.remove();
+    return;
   }
 
 }

+ 18 - 1
src/server/service/file-uploader/aws.js

@@ -115,11 +115,28 @@ module.exports = function(crowi) {
     return lib.deleteFileByFilePath(filePath);
   };
 
-  lib.deleteFileByFilePath = async function(filePath) {
+  lib.deleteFiles = async function(attachments) {
     if (!this.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
 
+    const filePaths = attachments.map((attachment) => {
+      return { Key: getFilePathOnStorage(attachment) };
+    });
+
+    const totalParams = {
+      Bucket: awsConfig.bucket,
+      Delete: { Objects: filePaths },
+    };
+    return s3.deleteObjects(totalParams).promise();
+  };
+
+  lib.deleteFileByFilePath = async function(filePath) {
+    if (!this.getIsUploadable()) {
+      throw new Error('AWS is not configured.');
+    }
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
 

+ 17 - 12
src/server/service/file-uploader/gcs.js

@@ -71,7 +71,7 @@ module.exports = function(crowi) {
 
     // issue signed url (default: expires 120 seconds)
     // https://cloud.google.com/storage/docs/access-control/signed-urls
-    const signedUrl = await file.getSignedUrl({
+    const [signedUrl] = await file.getSignedUrl({
       action: 'read',
       expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
     });
@@ -87,28 +87,33 @@ module.exports = function(crowi) {
 
   };
 
-  lib.deleteFile = async function(attachment) {
+  lib.deleteFile = function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
-    return lib.deleteFileByFilePath(filePath);
+    return lib.deleteFilesByFilePaths([filePath]);
   };
 
-  lib.deleteFileByFilePath = async function(filePath) {
+  lib.deleteFiles = function(attachments) {
+    const filePaths = attachments.map((attachment) => {
+      return getFilePathOnStorage(attachment);
+    });
+    return lib.deleteFilesByFilePaths(filePaths);
+  };
+
+  lib.deleteFilesByFilePaths = function(filePaths) {
     if (!this.getIsUploadable()) {
       throw new Error('GCS is not configured.');
     }
 
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());
-    const file = myBucket.file(filePath);
 
-    // check file exists
-    const isExists = await isFileExists(file);
-    if (!isExists) {
-      logger.warn(`Any object that relate to the Attachment (${filePath}) does not exist in GCS`);
-      return;
-    }
+    const files = filePaths.map((filePath) => {
+      return myBucket.file(filePath);
+    });
 
-    return file.delete();
+    files.forEach((file) => {
+      file.delete({ ignoreNotFound: true });
+    });
   };
 
   lib.uploadFile = function(fileStream, attachment) {

+ 16 - 3
src/server/service/file-uploader/gridfs.js

@@ -6,7 +6,7 @@ module.exports = function(crowi) {
   const Uploader = require('./uploader');
   const lib = new Uploader(crowi);
   const COLLECTION_NAME = 'attachmentFiles';
-  // const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
+  const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 
   // instantiate mongoose-gridfs
   const { createModel } = require('mongoose-gridfs');
@@ -15,8 +15,9 @@ module.exports = function(crowi) {
     bucketName: COLLECTION_NAME,
     connection: mongoose.connection,
   });
+
   // get Collection instance of chunk
-  // const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
+  const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
 
   // create promisified method
   AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
@@ -39,10 +40,22 @@ module.exports = function(crowi) {
       logger.warn(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in GridFS`);
       return;
     }
-
     return AttachmentFile.promisifiedUnlink({ _id: attachmentFile._id });
   };
 
+  lib.deleteFiles = async function(attachments) {
+    const filenameValues = attachments.map((attachment) => {
+      return attachment.fileName;
+    });
+    const fileIdObjects = await AttachmentFile.find({ filename: { $in: filenameValues } }, { _id: 1 });
+    const idsRelatedFiles = fileIdObjects.map((obj) => { return obj._id });
+
+    return Promise.all([
+      AttachmentFile.deleteMany({ filename: { $in: filenameValues } }),
+      chunkCollection.deleteMany({ files_id: { $in: idsRelatedFiles } }),
+    ]);
+  };
+
   /**
    * get size of data uploaded files using (Promise wrapper)
    */

+ 6 - 0
src/server/service/file-uploader/local.js

@@ -35,6 +35,12 @@ module.exports = function(crowi) {
     return lib.deleteFileByFilePath(filePath);
   };
 
+  lib.deleteFiles = async function(attachments) {
+    attachments.map((attachment) => {
+      return this.deleteFile(attachment);
+    });
+  };
+
   lib.deleteFileByFilePath = async function(filePath) {
     // check file exists
     try {

+ 5 - 0
src/server/service/file-uploader/none.js

@@ -14,6 +14,11 @@ module.exports = function(crowi) {
     throw new Error('not implemented');
   };
 
+  lib.deleteFiles = function(filePath) {
+    debug(`File deletion: ${filePath}`);
+    throw new Error('not implemented');
+  };
+
   lib.uploadFile = function(filePath, contentType, fileStream, options) {
     debug(`File uploading: ${filePath}`);
     throw new Error('not implemented');

+ 4 - 0
src/server/service/file-uploader/uploader.js

@@ -29,6 +29,10 @@ class Uploader {
     return !!this.configManager.getConfig('crowi', 'app:fileUpload');
   }
 
+  deleteFiles() {
+    throw new Error('Implemnt this');
+  }
+
   /**
    * Check files size limits for all uploaders
    *

+ 684 - 30
src/server/service/page.js

@@ -1,14 +1,191 @@
 const mongoose = require('mongoose');
 const escapeStringRegexp = require('escape-string-regexp');
+const logger = require('@alias/logger')('growi:models:page');
+const debug = require('debug')('growi:models:page');
+const { Writable } = require('stream');
+const { createBatchStream } = require('@server/util/batch-stream');
+const { isTrashPage } = require('@commons/util/path-utils');
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 
+const BULK_REINDEX_SIZE = 100;
+
 class PageService {
 
   constructor(crowi) {
     this.crowi = crowi;
+    this.pageEvent = crowi.event('page');
+
+    // init
+    this.pageEvent.on('create', this.pageEvent.onCreate);
+    this.pageEvent.on('update', this.pageEvent.onUpdate);
+    this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
+  }
+
+  /**
+   * go back by using redirectTo and return the paths
+   *  ex: when
+   *    '/page1' redirects to '/page2' and
+   *    '/page2' redirects to '/page3'
+   *    and given '/page3',
+   *    '/page1' and '/page2' will be return
+   *
+   * @param {string} redirectTo
+   * @param {object} redirectToPagePathMapping
+   * @param {array} pagePaths
+   */
+  prepareShoudDeletePagesByRedirectTo(redirectTo, redirectToPagePathMapping, pagePaths = []) {
+    const pagePath = redirectToPagePathMapping[redirectTo];
+
+    if (pagePath == null) {
+      return pagePaths;
+    }
+
+    pagePaths.push(pagePath);
+    return this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping, pagePaths);
+  }
+
+
+  async renamePage(page, newPagePath, user, options, isRecursively = false) {
+
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+    const path = page.path;
+    const createRedirectPage = options.createRedirectPage || false;
+    const updateMetadata = options.updateMetadata || false;
+    const socketClientId = options.socketClientId || null;
+
+    // sanitize path
+    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+
+    const update = {};
+    // update Page
+    update.path = newPagePath;
+    if (updateMetadata) {
+      update.lastUpdateUser = user;
+      update.updatedAt = Date.now();
+    }
+    const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
+
+    // update Rivisions
+    await Revision.updateRevisionListByPath(path, { path: newPagePath }, {});
+
+    if (createRedirectPage) {
+      const body = `redirect ${newPagePath}`;
+      await Page.create(path, body, user, { redirectTo: newPagePath });
+    }
+
+    if (isRecursively) {
+      this.renameDescendantsWithStream(page, newPagePath, user, options);
+    }
+
+    this.pageEvent.emit('delete', page, user, socketClientId);
+    this.pageEvent.emit('create', renamedPage, user, socketClientId);
+
+    return renamedPage;
+  }
+
+
+  async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
+    const Page = this.crowi.model('Page');
+
+    const pageCollection = mongoose.connection.collection('pages');
+    const revisionCollection = mongoose.connection.collection('revisions');
+    const { updateMetadata, createRedirectPage } = options;
+
+    const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const createRediectPageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const revisionUnorderedBulkOp = revisionCollection.initializeUnorderedBulkOp();
+    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
+
+    pages.forEach((page) => {
+      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+      const revisionId = new mongoose.Types.ObjectId();
+
+      if (updateMetadata) {
+        unorderedBulkOp.find({ _id: page._id }).update([{ $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: { $toDate: Date.now() } } }]);
+      }
+      else {
+        unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
+      }
+      if (createRedirectPage) {
+        createRediectPageBulkOp.insert({
+          path: page.path, revision: revisionId, creator: user._id, lastUpdateUser: user._id, status: Page.STATUS_PUBLISHED, redirectTo: newPagePath,
+        });
+        createRediectRevisionBulkOp.insert({
+          _id: revisionId, path: page.path, body: `redirect ${newPagePath}`, author: user._id, format: 'markdown',
+        });
+      }
+      revisionUnorderedBulkOp.find({ path: page.path }).update({ $set: { path: newPagePath } }, { multi: true });
+    });
+
+    try {
+      await unorderedBulkOp.execute();
+      await revisionUnorderedBulkOp.execute();
+      // Execute after unorderedBulkOp to prevent duplication
+      if (createRedirectPage) {
+        await createRediectPageBulkOp.execute();
+        await createRediectRevisionBulkOp.execute();
+      }
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error('Failed to rename pages: ', err);
+      }
+    }
+
+    this.pageEvent.emit('updateMany', pages, user);
+  }
+
+  /**
+   * Create rename stream
+   */
+  async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
+    const Page = this.crowi.model('Page');
+    const newPagePathPrefix = newPagePath;
+    const { PageQueryBuilder } = Page;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
+
+    const readStream = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(targetPage.path)
+      .addConditionToFilteringByViewer(user)
+      .query
+      .lean()
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
+
+    const renameDescendants = this.renameDescendants.bind(this);
+    const pageEvent = this.pageEvent;
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await renameDescendants(batch, user, options, pathRegExp, newPagePathPrefix);
+          logger.debug(`Reverting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('revertPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
+        // update  path
+        targetPage.path = newPagePath;
+        pageEvent.emit('syncDescendants', targetPage, user);
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
   }
 
-  async deleteCompletely(pageId, pagePath) {
+
+  async deleteCompletelyOperation(pageIds, pagePaths) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     const Bookmark = this.crowi.model('Bookmark');
     const Comment = this.crowi.model('Comment');
@@ -16,33 +193,34 @@ class PageService {
     const PageTagRelation = this.crowi.model('PageTagRelation');
     const ShareLink = this.crowi.model('ShareLink');
     const Revision = this.crowi.model('Revision');
-
-    return Promise.all([
-      Bookmark.removeBookmarksByPageId(pageId),
-      Comment.removeCommentsByPageId(pageId),
-      PageTagRelation.remove({ relatedPage: pageId }),
-      ShareLink.remove({ relatedPage: pageId }),
-      Revision.removeRevisionsByPath(pagePath),
-      Page.findByIdAndRemove(pageId),
-      Page.removeRedirectOriginPageByPath(pagePath),
-      this.removeAllAttachments(pageId),
-    ]);
-  }
-
-  async removeAllAttachments(pageId) {
     const Attachment = this.crowi.model('Attachment');
+
     const { attachmentService } = this.crowi;
+    const attachments = await Attachment.find({ page: { $in: pageIds } });
 
-    const attachments = await Attachment.find({ page: pageId });
+    const pages = await Page.find({ redirectTo: { $ne: null } });
+    const redirectToPagePathMapping = {};
+    pages.forEach((page) => {
+      redirectToPagePathMapping[page.redirectTo] = page.path;
+    });
 
-    const promises = attachments.map(async(attachment) => {
-      return attachmentService.removeAttachment(attachment._id);
+    const redirectedFromPagePaths = [];
+    pagePaths.forEach((pagePath) => {
+      redirectedFromPagePaths.push(...this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping));
     });
 
-    return Promise.all(promises);
+    return Promise.all([
+      Bookmark.deleteMany({ page: { $in: pageIds } }),
+      Comment.deleteMany({ page: { $in: pageIds } }),
+      PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
+      ShareLink.deleteMany({ relatedPage: { $in: pageIds } }),
+      Revision.deleteMany({ path: { $in: pagePaths } }),
+      Page.deleteMany({ $or: [{ path: { $in: pagePaths } }, { path: { $in: redirectedFromPagePaths } }, { _id: { $in: pageIds } }] }),
+      attachmentService.removeAllAttachments(attachments),
+    ]);
   }
 
-  async duplicate(page, newPagePath, user) {
+  async duplicate(page, newPagePath, user, isRecursively) {
     const Page = this.crowi.model('Page');
     const PageTagRelation = mongoose.model('PageTagRelation');
     // populate
@@ -54,10 +232,16 @@ class PageService {
     options.grantUserGroupId = page.grantedGroup;
     options.grantedUsers = page.grantedUsers;
 
+    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+
     const createdPage = await Page.create(
       newPagePath, page.revision.body, user, options,
     );
 
+    if (isRecursively) {
+      this.duplicateDescendantsWithStream(page, newPagePath, user);
+    }
+
     // take over tags
     const originTags = await page.findRelatedTagsById();
     let savedTags = [];
@@ -72,28 +256,498 @@ class PageService {
     return result;
   }
 
-  async duplicateRecursively(page, newPagePath, user) {
+  /**
+   * Receive the object with oldPageId and newPageId and duplicate the tags from oldPage to newPage
+   * @param {Object} pageIdMapping e.g. key: oldPageId, value: newPageId
+   */
+  async duplicateTags(pageIdMapping) {
+    const PageTagRelation = mongoose.model('PageTagRelation');
+
+    // convert pageId from string to ObjectId
+    const pageIds = Object.keys(pageIdMapping);
+    const stage = { $or: pageIds.map((pageId) => { return { relatedPage: mongoose.Types.ObjectId(pageId) } }) };
+
+    const pagesAssociatedWithTag = await PageTagRelation.aggregate([
+      {
+        $match: stage,
+      },
+      {
+        $group: {
+          _id: '$relatedTag',
+          relatedPages: { $push: '$relatedPage' },
+        },
+      },
+    ]);
+
+    const newPageTagRelation = [];
+    pagesAssociatedWithTag.forEach(({ _id, relatedPages }) => {
+      // relatedPages
+      relatedPages.forEach((pageId) => {
+        newPageTagRelation.push({
+          relatedPage: pageIdMapping[pageId], // newPageId
+          relatedTag: _id,
+        });
+      });
+    });
+
+    return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
+  }
+
+  async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix) {
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+
+    const paths = pages.map(page => (page.path));
+    const revisions = await Revision.find({ path: { $in: paths } });
+
+    // Mapping to set to the body of the new revision
+    const pathRevisionMapping = {};
+    revisions.forEach((revision) => {
+      pathRevisionMapping[revision.path] = revision;
+    });
+
+    // key: oldPageId, value: newPageId
+    const pageIdMapping = {};
+    const newPages = [];
+    const newRevisions = [];
+
+    pages.forEach((page) => {
+      const newPageId = new mongoose.Types.ObjectId();
+      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+      const revisionId = new mongoose.Types.ObjectId();
+      pageIdMapping[page._id] = newPageId;
+
+      newPages.push({
+        _id: newPageId,
+        path: newPagePath,
+        creator: user._id,
+        grant: page.grant,
+        grantedGroup: page.grantedGroup,
+        grantedUsers: page.grantedUsers,
+        lastUpdateUser: user._id,
+        redirectTo: null,
+        revision: revisionId,
+      });
+
+      newRevisions.push({
+        _id: revisionId, path: newPagePath, body: pathRevisionMapping[page.path].body, author: user._id, format: 'markdown',
+      });
+
+    });
+
+    await Page.insertMany(newPages, { ordered: false });
+    await Revision.insertMany(newRevisions, { ordered: false });
+    await this.duplicateTags(pageIdMapping);
+  }
+
+  async duplicateDescendantsWithStream(page, newPagePath, user) {
     const Page = this.crowi.model('Page');
     const newPagePathPrefix = newPagePath;
     const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
 
-    const pages = await Page.findManageableListWithDescendants(page, user);
+    const { PageQueryBuilder } = Page;
+
+    const readStream = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(page.path)
+      .addConditionToFilteringByViewer(user)
+      .query
+      .lean()
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
+
+    const duplicateDescendants = this.duplicateDescendants.bind(this);
+    const pageEvent = this.pageEvent;
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix);
+          logger.debug(`Adding pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Adding pages has completed: (totalCount=${count})`);
+        // update  path
+        page.path = newPagePath;
+        pageEvent.emit('syncDescendants', page, user);
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+  }
+
+
+  async deletePage(page, user, options = {}, isRecursively = false) {
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+
+    const newPath = Page.getDeletedPageName(page.path);
+    const isTrashed = isTrashPage(page.path);
+
+    if (isTrashed) {
+      throw new Error('This method does NOT support deleting trashed pages.');
+    }
+
+    const socketClientId = options.socketClientId || null;
+    if (!Page.isDeletableName(page.path)) {
+      throw new Error('Page is not deletable.');
+    }
+
+    if (isRecursively) {
+      this.deleteDescendantsWithStream(page, user, options);
+    }
+
+    // update Rivisions
+    await Revision.updateRevisionListByPath(page.path, { path: newPath }, {});
+    const deletedPage = await Page.findByIdAndUpdate(page._id, {
+      $set: {
+        path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
+      },
+    }, { new: true });
+    const body = `redirect ${newPath}`;
+    await Page.create(page.path, body, user, { redirectTo: newPath });
+
+    this.pageEvent.emit('delete', page, user, socketClientId);
+    this.pageEvent.emit('create', deletedPage, user, socketClientId);
+
+    return deletedPage;
+  }
+
+  async deleteDescendants(pages, user) {
+    const Page = this.crowi.model('Page');
+
+    const pageCollection = mongoose.connection.collection('pages');
+    const revisionCollection = mongoose.connection.collection('revisions');
+
+    const deletePageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const updateRevisionListOp = revisionCollection.initializeUnorderedBulkOp();
+    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
+    const newPagesForRedirect = [];
+
+    pages.forEach((page) => {
+      const newPath = Page.getDeletedPageName(page.path);
+      const revisionId = new mongoose.Types.ObjectId();
+      const body = `redirect ${newPath}`;
+
+      deletePageBulkOp.find({ _id: page._id }).update({
+        $set: {
+          path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
+        },
+      });
+      updateRevisionListOp.find({ path: page.path }).update({ $set: { path: newPath } });
+      createRediectRevisionBulkOp.insert({
+        _id: revisionId, path: page.path, body, author: user._id, format: 'markdown',
+      });
+
+      newPagesForRedirect.push({
+        path: page.path,
+        creator: user._id,
+        grant: page.grant,
+        grantedGroup: page.grantedGroup,
+        grantedUsers: page.grantedUsers,
+        lastUpdateUser: user._id,
+        redirectTo: newPath,
+        revision: revisionId,
+      });
+    });
+
+    try {
+      await deletePageBulkOp.execute();
+      await updateRevisionListOp.execute();
+      await createRediectRevisionBulkOp.execute();
+      await Page.insertMany(newPagesForRedirect, { ordered: false });
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error('Failed to revert pages: ', err);
+      }
+    }
+  }
+
+  /**
+   * Create delete stream
+   */
+  async deleteDescendantsWithStream(targetPage, user, options = {}) {
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const readStream = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(targetPage.path)
+      .addConditionToFilteringByViewer(user)
+      .query
+      .lean()
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
+
+    const deleteDescendants = this.deleteDescendants.bind(this);
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          deleteDescendants(batch, user);
+          logger.debug(`Reverting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('revertPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+  }
+
+  // delete multiple pages
+  async deleteMultipleCompletely(pages, user, options = {}) {
+    const ids = pages.map(page => (page._id));
+    const paths = pages.map(page => (page.path));
+    const socketClientId = options.socketClientId || null;
+
+    logger.debug('Deleting completely', paths);
+
+    await this.deleteCompletelyOperation(ids, paths);
+
+    this.pageEvent.emit('deleteCompletely', pages, user, socketClientId); // update as renamed page
+
+    return;
+  }
+
+  async deleteCompletely(page, user, options = {}, isRecursively = false) {
+    const ids = [page._id];
+    const paths = [page.path];
+    const socketClientId = options.socketClientId || null;
+
+    logger.debug('Deleting completely', paths);
+
+    await this.deleteCompletelyOperation(ids, paths);
+
+    if (isRecursively) {
+      this.deleteCompletelyDescendantsWithStream(page, user, options);
+    }
 
-    const promise = pages.map(async(page) => {
-      const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
-      return this.duplicate(page, newPagePath, user);
+    this.pageEvent.emit('delete', page, user, socketClientId); // update as renamed page
+
+    return;
+  }
+
+  /**
+   * Create delete completely stream
+   */
+  async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const readStream = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(targetPage.path)
+      .addConditionToFilteringByViewer(user)
+      .query
+      .lean()
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
+
+    const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await deleteMultipleCompletely(batch, user, options);
+          logger.debug(`Adding pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Adding pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
     });
 
-    const newPath = page.path.replace(pathRegExp, newPagePathPrefix);
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+  }
+
+  async revertDeletedDescendants(pages, user) {
+    const Page = this.crowi.model('Page');
+    const pageCollection = mongoose.connection.collection('pages');
+    const revisionCollection = mongoose.connection.collection('revisions');
 
-    await Promise.allSettled(promise);
+    const removePageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const revertPageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const revertRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
 
-    const newParentpage = await Page.findByPath(newPath);
+    // e.g. key: '/test'
+    const pathToPageMapping = {};
+    const toPaths = pages.map(page => Page.getRevertDeletedPageName(page.path));
+    const toPages = await Page.find({ path: { $in: toPaths } });
+    toPages.forEach((toPage) => {
+      pathToPageMapping[toPage.path] = toPage;
+    });
 
-    // TODO GW-4634 use stream
-    return newParentpage;
+    pages.forEach((page) => {
+
+      // e.g. page.path = /trash/test, toPath = /test
+      const toPath = Page.getRevertDeletedPageName(page.path);
+
+      if (pathToPageMapping[toPath] != null) {
+      // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
+      // So, it's ok to delete the page
+      // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
+        if (pathToPageMapping[toPath].redirectTo === page.path) {
+          removePageBulkOp.find({ path: toPath }).remove();
+        }
+      }
+      revertPageBulkOp.find({ _id: page._id }).update({
+        $set: {
+          path: toPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
+        },
+      });
+      revertRevisionBulkOp.find({ path: page.path }).update({ $set: { path: toPath } }, { multi: true });
+    });
+
+    try {
+      await removePageBulkOp.execute();
+      await revertPageBulkOp.execute();
+      await revertRevisionBulkOp.execute();
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error('Failed to revert pages: ', err);
+      }
+    }
   }
 
+  async revertDeletedPage(page, user, options = {}, isRecursively = false) {
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+
+    const newPath = Page.getRevertDeletedPageName(page.path);
+    const originPage = await Page.findByPath(newPath);
+    if (originPage != null) {
+      // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
+      // So, it's ok to delete the page
+      // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
+      if (originPage.redirectTo !== page.path) {
+        throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
+      }
+      await this.deleteCompletely(originPage, options);
+    }
+
+    if (isRecursively) {
+      this.revertDeletedDescendantsWithStream(page, user, options);
+    }
+
+    page.status = Page.STATUS_PUBLISHED;
+    page.lastUpdateUser = user;
+    debug('Revert deleted the page', page, newPath);
+    const updatedPage = await Page.findByIdAndUpdate(page._id, {
+      $set: {
+        path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
+      },
+    }, { new: true });
+    await Revision.updateMany({ path: page.path }, { $set: { path: newPath } });
+
+    return updatedPage;
+  }
+
+  /**
+   * Create revert stream
+   */
+  async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const readStream = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(targetPage.path)
+      .addConditionToFilteringByViewer(user)
+      .query
+      .lean()
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
+
+    const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          revertDeletedDescendants(batch, user);
+          logger.debug(`Reverting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('revertPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+  }
+
+
+  async handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId) {
+    const Page = this.crowi.model('Page');
+    const pages = await Page.find({ grantedGroup: deletedGroup });
+
+    switch (action) {
+      case 'public':
+        await Promise.all(pages.map((page) => {
+          return Page.publicizePage(page);
+        }));
+        break;
+      case 'delete':
+        return this.deleteMultiplePagesCompletely(pages);
+      case 'transfer':
+        await Promise.all(pages.map((page) => {
+          return Page.transferPageToGroup(page, transferToUserGroupId);
+        }));
+        break;
+      default:
+        throw new Error('Unknown action for private pages');
+    }
+  }
+
+  validateCrowi() {
+    if (this.crowi == null) {
+      throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
+    }
+  }
 
 }
 

+ 48 - 7
src/server/service/search-delegator/elasticsearch.js

@@ -352,6 +352,14 @@ class ElasticsearchDelegator {
     return this.updateOrInsertPages(() => Page.findById(pageId));
   }
 
+  updateOrInsertDescendantsPagesById(page, user) {
+    const Page = mongoose.model('Page');
+    const { PageQueryBuilder } = Page;
+    const builder = new PageQueryBuilder(Page.find());
+    builder.addConditionToListWithDescendants(page.path);
+    return this.updateOrInsertPages(() => builder.query);
+  }
+
   /**
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    */
@@ -382,7 +390,7 @@ class ElasticsearchDelegator {
         { path: 'revision', model: 'Revision', select: 'body' },
       ])
       .lean()
-      .cursor();
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
 
     let skipped = 0;
     const thinOutStream = new Transform({
@@ -501,13 +509,8 @@ class ElasticsearchDelegator {
   }
 
   deletePages(pages) {
-    const self = this;
     const body = [];
-
-    pages.map((page) => {
-      self.prepareBodyForDelete(body, page);
-      return;
-    });
+    pages.forEach(page => this.prepareBodyForDelete(body, page));
 
     logger.debug('deletePages(): Sending Request to ES', body);
     return this.client.bulk({
@@ -963,6 +966,44 @@ class ElasticsearchDelegator {
     return this.updateOrInsertPageById(page._id);
   }
 
+  // remove pages whitch should nod Indexed
+  async syncPagesUpdated(pages, user) {
+    const shoudDeletePages = [];
+    pages.forEach((page) => {
+      logger.debug('SearchClient.syncPageUpdated', page.path);
+      if (!this.shouldIndexed(page)) {
+        shoudDeletePages.append(page);
+      }
+    });
+
+    // delete if page should not indexed
+    try {
+      if (shoudDeletePages.length !== 0) {
+        await this.deletePages(shoudDeletePages);
+      }
+    }
+    catch (err) {
+      logger.error('deletePages:ES Error', err);
+    }
+  }
+
+  async syncDescendantsPagesUpdated(parentPage, user) {
+    return this.updateOrInsertDescendantsPagesById(parentPage, user);
+  }
+
+  async syncPagesDeletedCompletely(pages, user) {
+    for (let i = 0; i < pages.length; i++) {
+      logger.debug('SearchClient.syncPageDeleted', pages[i].path);
+    }
+
+    try {
+      return await this.deletePages(pages);
+    }
+    catch (err) {
+      logger.error('deletePages:ES Error', err);
+    }
+  }
+
   async syncPageDeleted(page, user) {
     logger.debug('SearchClient.syncPageDeleted', page.path);
 

+ 3 - 0
src/server/service/search.js

@@ -58,7 +58,10 @@ class SearchService {
     const pageEvent = this.crowi.event('page');
     pageEvent.on('create', this.delegator.syncPageUpdated.bind(this.delegator));
     pageEvent.on('update', this.delegator.syncPageUpdated.bind(this.delegator));
+    pageEvent.on('deleteCompletely', this.delegator.syncPagesDeletedCompletely.bind(this.delegator));
     pageEvent.on('delete', this.delegator.syncPageDeleted.bind(this.delegator));
+    pageEvent.on('updateMany', this.delegator.syncPagesUpdated.bind(this.delegator));
+    pageEvent.on('syncDescendants', this.delegator.syncDescendantsPagesUpdated.bind(this.delegator));
 
     const bookmarkEvent = this.crowi.event('bookmark');
     bookmarkEvent.on('create', this.delegator.syncBookmarkChanged.bind(this.delegator));

+ 1 - 1
src/server/views/installer.html

@@ -10,7 +10,7 @@
 
   <meta name="viewport" content="width=device-width,initial-scale=1">
 
-  <meta name="apple-mobile-web-app-title" content="{{ appService.getAppTitle() }}">
+  <meta name="apple-mobile-web-app-title" content="{{ appService.getAppTitle() | preventXss }}">
 
   {% include './widget/headers/favicon.html' %}
   {% include './widget/headers/ie11-polyfills.html' %}

+ 1 - 1
src/server/views/layout/layout.html

@@ -10,7 +10,7 @@
 
   <meta name="viewport" content="width=device-width,initial-scale=1">
 
-  <meta name="apple-mobile-web-app-title" content="{{ appService.getAppTitle() }}">
+  <meta name="apple-mobile-web-app-title" content="{{ appService.getAppTitle() | preventXss }}">
 
   {{ getConfig('crowi', 'customize:header') | default('') }}
 

+ 1 - 1
src/server/views/login.html

@@ -34,7 +34,7 @@
     <div class="col-md-12">
       <div class="login-header mx-auto">
         <div class="logo mb-3">{% include 'widget/logo.html' %}</div>
-        <h1>{{ appService.getAppTitle() }}</h1>
+        <h1>{{ appService.getAppTitle() | preventXss }}</h1>
 
           <div class="login-form-errors px-3">
             {% if isLdapSetupFailed() %}

+ 3 - 7
src/server/views/widget/page_alerts.html

@@ -33,10 +33,10 @@
       <span>
         {% set fromPath = req.query.renamedFrom or req.query.redirectFrom %}
         {% if redirectFrom or req.query.redirectFrom %}
-          <strong>{{ t('Redirected') }}:</strong> {{ t('page_page.notice.redirected', fromPath | preventXss) }}
+        <div id="redirected-alert"></div>
         {% endif %}
         {% if req.query.renamedFrom %}
-          <strong>{{ t('Moved') }}:</strong> {{ t('page_page.notice.moved', fromPath | preventXss) }}
+        <div id="renamed-alert"></div>
         {% endif %}
       </span>
       {% set hasRedirectLink = redirectFrom or req.query.redirectFrom or req.query.withRedirect %}
@@ -50,11 +50,7 @@
     {% endif %}
 
     {% if req.query.duplicated and not page.isDeleted() %}
-    <div class="alert alert-success py-3 px-4">
-      <span>
-        <strong>{{ t('Duplicated') }}: </strong> {{ t('page_page.notice.duplicated', req.query.duplicated | preventXss) }}
-      </span>
-    </div>
+    <div id="duplicated-alert"></div>
     {% endif %}
 
     {% if req.query.unlinked %}

+ 2 - 0
src/server/views/widget/page_content.html

@@ -23,6 +23,8 @@
   data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"
   data-page-last-update-username="{% if page && page.lastUpdateUser %}{{ page.lastUpdateUser.name }}{% endif %}"
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
+  data-page-delete-username="{% if page && page.deleteUser %}{{ page.deleteUser.name }}{% endif %}"
+  data-page-deleted-at="{% if page %}{{ page.deletedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-page-ids-of-seen-users="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"

+ 723 - 0
src/test/service/page.test.js

@@ -0,0 +1,723 @@
+/* eslint-disable no-unused-vars */
+const mongoose = require('mongoose');
+
+const { getInstance } = require('../setup-crowi');
+
+let testUser1;
+let testUser2;
+let parentTag;
+let childTag;
+
+let parentForRename1;
+let parentForRename2;
+let parentForRename3;
+let parentForRename4;
+
+let childForRename1;
+let childForRename2;
+let childForRename3;
+
+let parentForDuplicate;
+
+let parentForDelete1;
+let parentForDelete2;
+
+let childForDelete;
+
+let parentForDeleteCompletely;
+
+let parentForRevert1;
+let parentForRevert2;
+
+let childForDuplicate;
+let childForDeleteCompletely;
+
+let childForRevert;
+
+describe('PageService', () => {
+
+  let crowi;
+  let Page;
+  let Revision;
+  let User;
+  let Tag;
+  let PageTagRelation;
+  let Bookmark;
+  let Comment;
+  let ShareLink;
+  let xssSpy;
+
+  beforeAll(async(done) => {
+    crowi = await getInstance();
+
+    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');
+
+    await User.insertMany([
+      { name: 'someone1', username: 'someone1', email: 'someone1@example.com' },
+      { name: 'someone2', username: 'someone2', email: 'someone2@example.com' },
+    ]);
+
+    testUser1 = await User.findOne({ username: 'someone1' });
+    testUser2 = await User.findOne({ username: 'someone2' });
+
+    await Page.insertMany([
+      {
+        path: '/parentForRename1',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename2',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename3',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename4',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename1/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename2/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename3/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForDuplicate',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+        revision: '600d395667536503354cbe91',
+      },
+      {
+        path: '/parentForDuplicate/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+        revision: '600d395667536503354cbe92',
+      },
+      {
+        path: '/parentForDelete1',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForDelete2',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForDelete/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForDeleteCompletely',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForDeleteCompletely/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/trash/parentForRevert1',
+        status: Page.STATUS_DELETED,
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/trash/parentForRevert2',
+        status: Page.STATUS_DELETED,
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/trash/parentForRevert/child',
+        status: Page.STATUS_DELETED,
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+    ]);
+
+    parentForRename1 = await Page.findOne({ path: '/parentForRename1' });
+    parentForRename2 = await Page.findOne({ path: '/parentForRename2' });
+    parentForRename3 = await Page.findOne({ path: '/parentForRename3' });
+    parentForRename4 = await Page.findOne({ path: '/parentForRename4' });
+
+    parentForDuplicate = await Page.findOne({ path: '/parentForDuplicate' });
+
+    parentForDelete1 = await Page.findOne({ path: '/parentForDelete1' });
+    parentForDelete2 = await Page.findOne({ path: '/parentForDelete2' });
+
+    parentForDeleteCompletely = await Page.findOne({ path: '/parentForDeleteCompletely' });
+    parentForRevert1 = await Page.findOne({ path: '/trash/parentForRevert1' });
+    parentForRevert2 = await Page.findOne({ path: '/trash/parentForRevert2' });
+
+    childForRename1 = await Page.findOne({ path: '/parentForRename1/child' });
+    childForRename2 = await Page.findOne({ path: '/parentForRename2/child' });
+    childForRename3 = await Page.findOne({ path: '/parentForRename3/child' });
+
+    childForDuplicate = await Page.findOne({ path: '/parentForDuplicate/child' });
+    childForDelete = await Page.findOne({ path: '/parentForDelete/child' });
+    childForDeleteCompletely = await Page.findOne({ path: '/parentForDeleteCompletely/child' });
+    childForRevert = await Page.findOne({ path: '/trash/parentForRevert/child' });
+
+
+    await Tag.insertMany([
+      { name: 'Parent' },
+      { name: 'Child' },
+    ]);
+
+    parentTag = await Tag.findOne({ name: 'Parent' });
+    childTag = await Tag.findOne({ name: 'Child' });
+
+    await PageTagRelation.insertMany([
+      { relatedPage: parentForDuplicate, relatedTag: parentTag },
+      { relatedPage: childForDuplicate, relatedTag: childTag },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: '600d395667536503354cbe91',
+        path: parentForDuplicate.path,
+        body: 'duplicateBody',
+      },
+      {
+        _id: '600d395667536503354cbe92',
+        path: childForDuplicate.path,
+        body: 'duplicateChildBody',
+      },
+    ]);
+
+    xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
+
+
+    done();
+  });
+
+  describe('rename page', () => {
+    let pageEventSpy;
+    let renameDescendantsWithStreamSpy;
+    const dateToUse = new Date('2000-01-01');
+    const socketClientId = null;
+
+    beforeEach(async(done) => {
+      jest.spyOn(global.Date, 'now').mockImplementation(() => dateToUse);
+      pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit').mockImplementation();
+      renameDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'renameDescendantsWithStream').mockImplementation();
+      done();
+    });
+
+    describe('renamePage()', () => {
+
+      test('rename page without options', async() => {
+
+        const resultPage = await crowi.pageService.renamePage(parentForRename1, '/renamed1', testUser2, {});
+        const redirectedFromPage = await Page.findOne({ path: '/parentForRename1' });
+        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename1' });
+
+        expect(xssSpy).toHaveBeenCalled();
+        expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename1, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+        expect(resultPage.path).toBe('/renamed1');
+        expect(resultPage.updatedAt).toEqual(parentForRename1.updatedAt);
+        expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+        expect(redirectedFromPage).toBeNull();
+        expect(redirectedFromPageRevision).toBeNull();
+      });
+
+      test('rename page with updateMetadata option', async() => {
+
+        const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true });
+        const redirectedFromPage = await Page.findOne({ path: '/parentForRename2' });
+        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename2' });
+
+        expect(xssSpy).toHaveBeenCalled();
+        expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename2, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+        expect(resultPage.path).toBe('/renamed2');
+        expect(resultPage.updatedAt).toEqual(dateToUse);
+        expect(resultPage.lastUpdateUser).toEqual(testUser2._id);
+
+        expect(redirectedFromPage).toBeNull();
+        expect(redirectedFromPageRevision).toBeNull();
+      });
+
+      test('rename page with createRedirectPage option', async() => {
+
+        const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true });
+        const redirectedFromPage = await Page.findOne({ path: '/parentForRename3' });
+        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename3' });
+
+        expect(xssSpy).toHaveBeenCalled();
+        expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename3, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+        expect(resultPage.path).toBe('/renamed3');
+        expect(resultPage.updatedAt).toEqual(parentForRename3.updatedAt);
+        expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+        expect(redirectedFromPage).not.toBeNull();
+        expect(redirectedFromPage.path).toBe('/parentForRename3');
+        expect(redirectedFromPage.redirectTo).toBe('/renamed3');
+
+        expect(redirectedFromPageRevision).not.toBeNull();
+        expect(redirectedFromPageRevision.path).toBe('/parentForRename3');
+        expect(redirectedFromPageRevision.body).toBe('redirect /renamed3');
+      });
+
+      test('rename page with isRecursively', async() => {
+
+        const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { }, true);
+        const redirectedFromPage = await Page.findOne({ path: '/parentForRename4' });
+        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename4' });
+
+        expect(xssSpy).toHaveBeenCalled();
+        expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename4, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+        expect(resultPage.path).toBe('/renamed4');
+        expect(resultPage.updatedAt).toEqual(parentForRename4.updatedAt);
+        expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+        expect(redirectedFromPage).toBeNull();
+        expect(redirectedFromPageRevision).toBeNull();
+      });
+
+    });
+
+    test('renameDescendants without options', async() => {
+      const oldPagePathPrefix = new RegExp('^/parentForRename1', 'i');
+      const newPagePathPrefix = '/renamed1';
+
+      await crowi.pageService.renameDescendants([childForRename1], testUser2, {}, oldPagePathPrefix, newPagePathPrefix);
+      const resultPage = await Page.findOne({ path: '/renamed1/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForRename1/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename1/child' });
+
+      expect(resultPage).not.toBeNull();
+      expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename1], testUser2);
+
+      expect(resultPage.path).toBe('/renamed1/child');
+      expect(resultPage.updatedAt).toEqual(childForRename1.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).toBeNull();
+      expect(redirectedFromPageRevision).toBeNull();
+    });
+
+    test('renameDescendants with updateMetadata option', async() => {
+      const oldPagePathPrefix = new RegExp('^/parentForRename2', 'i');
+      const newPagePathPrefix = '/renamed2';
+
+      await crowi.pageService.renameDescendants([childForRename2], testUser2, { updateMetadata: true }, oldPagePathPrefix, newPagePathPrefix);
+      const resultPage = await Page.findOne({ path: '/renamed2/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForRename2/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename2/child' });
+
+      expect(resultPage).not.toBeNull();
+      expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename2], testUser2);
+
+      expect(resultPage.path).toBe('/renamed2/child');
+      expect(resultPage.updatedAt).toEqual(dateToUse);
+      expect(resultPage.lastUpdateUser).toEqual(testUser2._id);
+
+      expect(redirectedFromPage).toBeNull();
+      expect(redirectedFromPageRevision).toBeNull();
+    });
+
+    test('renameDescendants with createRedirectPage option', async() => {
+      const oldPagePathPrefix = new RegExp('^/parentForRename3', 'i');
+      const newPagePathPrefix = '/renamed3';
+
+      await crowi.pageService.renameDescendants([childForRename3], testUser2, { createRedirectPage: true }, oldPagePathPrefix, newPagePathPrefix);
+      const resultPage = await Page.findOne({ path: '/renamed3/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForRename3/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename3/child' });
+
+      expect(resultPage).not.toBeNull();
+      expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename3], testUser2);
+
+      expect(resultPage.path).toBe('/renamed3/child');
+      expect(resultPage.updatedAt).toEqual(childForRename3.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForRename3/child');
+      expect(redirectedFromPage.redirectTo).toBe('/renamed3/child');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForRename3/child');
+      expect(redirectedFromPageRevision.body).toBe('redirect /renamed3/child');
+    });
+  });
+
+
+  describe('duplicate page', () => {
+    let duplicateDescendantsWithStreamSpy;
+
+    jest.mock('../../server/models/serializers/page-serializer');
+    const { serializePageSecurely } = require('../../server/models/serializers/page-serializer');
+    serializePageSecurely.mockImplementation(page => page);
+
+    beforeEach(async(done) => {
+      duplicateDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'duplicateDescendantsWithStream').mockImplementation();
+      done();
+    });
+
+    test('duplicate page (isRecursively: false)', async() => {
+      const dummyId = '600d395667536503354c9999';
+      crowi.models.Page.findRelatedTagsById = jest.fn().mockImplementation(() => { return parentTag });
+      const originTagsMock = jest.spyOn(Page, 'findRelatedTagsById').mockImplementation(() => { return parentTag });
+      jest.spyOn(PageTagRelation, 'updatePageTags').mockImplementation(() => { return [dummyId, parentTag.name] });
+      jest.spyOn(PageTagRelation, 'listTagNamesByPage').mockImplementation(() => { return [parentTag.name] });
+
+      const resultPage = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicate', testUser2, false);
+      const duplicatedToPageRevision = await Revision.findOne({ path: '/newParentDuplicate' });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicateDescendantsWithStreamSpy).not.toHaveBeenCalled();
+      expect(serializePageSecurely).toHaveBeenCalled();
+      expect(resultPage.path).toBe('/newParentDuplicate');
+      expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(duplicatedToPageRevision._id).not.toEqual(parentForDuplicate.revision._id);
+      expect(resultPage.grant).toEqual(parentForDuplicate.grant);
+      expect(resultPage.tags).toEqual([originTagsMock().name]);
+    });
+
+    test('duplicate page (isRecursively: true)', async() => {
+      const dummyId = '600d395667536503354c9999';
+      crowi.models.Page.findRelatedTagsById = jest.fn().mockImplementation(() => { return parentTag });
+      const originTagsMock = jest.spyOn(Page, 'findRelatedTagsById').mockImplementation(() => { return parentTag });
+      jest.spyOn(PageTagRelation, 'updatePageTags').mockImplementation(() => { return [dummyId, parentTag.name] });
+      jest.spyOn(PageTagRelation, 'listTagNamesByPage').mockImplementation(() => { return [parentTag.name] });
+
+      const resultPageRecursivly = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicateRecursively', testUser2, true);
+      const duplicatedRecursivelyToPageRevision = await Revision.findOne({ path: '/newParentDuplicateRecursively' });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicateDescendantsWithStreamSpy).toHaveBeenCalled();
+      expect(serializePageSecurely).toHaveBeenCalled();
+      expect(resultPageRecursivly.path).toBe('/newParentDuplicateRecursively');
+      expect(resultPageRecursivly.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(duplicatedRecursivelyToPageRevision._id).not.toEqual(parentForDuplicate.revision._id);
+      expect(resultPageRecursivly.grant).toEqual(parentForDuplicate.grant);
+      expect(resultPageRecursivly.tags).toEqual([originTagsMock().name]);
+    });
+
+    test('duplicateDescendants()', async() => {
+      const duplicateTagsMock = await jest.spyOn(crowi.pageService, 'duplicateTags').mockImplementationOnce();
+      await crowi.pageService.duplicateDescendants([childForDuplicate], testUser2, parentForDuplicate.path, '/newPathPrefix');
+
+      const childForDuplicateRevision = await Revision.findOne({ path: childForDuplicate.path });
+      const insertedPage = await Page.findOne({ path: '/newPathPrefix/child' });
+      const insertedRevision = await Revision.findOne({ path: '/newPathPrefix/child' });
+
+      expect(insertedPage).not.toBeNull();
+      expect(insertedPage.path).toEqual('/newPathPrefix/child');
+      expect(insertedPage.lastUpdateUser).toEqual(testUser2._id);
+
+      expect([insertedRevision]).not.toBeNull();
+      expect(insertedRevision.path).toEqual('/newPathPrefix/child');
+      expect(insertedRevision._id).not.toEqual(childForDuplicateRevision._id);
+      expect(insertedRevision.body).toEqual(childForDuplicateRevision.body);
+
+      expect(duplicateTagsMock).toHaveBeenCalled();
+    });
+
+    test('duplicateTags()', async() => {
+      const pageIdMapping = {
+        [parentForDuplicate._id]: '60110bdd85339d7dc732dddd',
+      };
+      const duplicateTagsReturn = await crowi.pageService.duplicateTags(pageIdMapping);
+      const parentoForDuplicateTag = await PageTagRelation.findOne({ relatedPage: parentForDuplicate._id });
+      expect(duplicateTagsReturn).toHaveLength(1);
+      expect(duplicateTagsReturn[0].relatedTag).toEqual(parentoForDuplicateTag.relatedTag);
+    });
+  });
+
+  describe('delete page', () => {
+    let getDeletedPageNameSpy;
+    let pageEventSpy;
+    let deleteDescendantsWithStreamSpy;
+    const dateToUse = new Date('2000-01-01');
+    const socketClientId = null;
+
+    beforeEach(async(done) => {
+      jest.spyOn(global.Date, 'now').mockImplementation(() => dateToUse);
+      getDeletedPageNameSpy = jest.spyOn(Page, 'getDeletedPageName');
+      pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit');
+      deleteDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'deleteDescendantsWithStream').mockImplementation();
+      done();
+    });
+
+    test('delete page without options', async() => {
+      const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete1' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete1' });
+
+      expect(getDeletedPageNameSpy).toHaveBeenCalled();
+      expect(deleteDescendantsWithStreamSpy).not.toHaveBeenCalled();
+
+      expect(resultPage.status).toBe(Page.STATUS_DELETED);
+      expect(resultPage.path).toBe('/trash/parentForDelete1');
+      expect(resultPage.deleteUser).toEqual(testUser2._id);
+      expect(resultPage.deletedAt).toEqual(dateToUse);
+      expect(resultPage.updatedAt).toEqual(parentForDelete1.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForDelete1');
+      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete1');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForDelete1');
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete1');
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, testUser2, socketClientId);
+      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+    });
+
+    test('delete page with isRecursively', async() => {
+      const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true);
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete2' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete2' });
+
+      expect(getDeletedPageNameSpy).toHaveBeenCalled();
+      expect(deleteDescendantsWithStreamSpy).toHaveBeenCalled();
+
+      expect(resultPage.status).toBe(Page.STATUS_DELETED);
+      expect(resultPage.path).toBe('/trash/parentForDelete2');
+      expect(resultPage.deleteUser).toEqual(testUser2._id);
+      expect(resultPage.deletedAt).toEqual(dateToUse);
+      expect(resultPage.updatedAt).toEqual(parentForDelete2.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForDelete2');
+      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete2');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForDelete2');
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete2');
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, testUser2, socketClientId);
+      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+    });
+
+
+    test('deleteDescendants', async() => {
+      await crowi.pageService.deleteDescendants([childForDelete], testUser2);
+      const resultPage = await Page.findOne({ path: '/trash/parentForDelete/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete/child' });
+
+      expect(resultPage.status).toBe(Page.STATUS_DELETED);
+      expect(resultPage.path).toBe('/trash/parentForDelete/child');
+      expect(resultPage.deleteUser).toEqual(testUser2._id);
+      expect(resultPage.deletedAt).toEqual(dateToUse);
+      expect(resultPage.updatedAt).toEqual(childForDelete.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForDelete/child');
+      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete/child');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForDelete/child');
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete/child');
+    });
+  });
+
+  describe('delete page completely', () => {
+    let pageEventSpy;
+    let deleteCompletelyOperationSpy;
+    let deleteCompletelyDescendantsWithStreamSpy;
+    const socketClientId = null;
+
+    let deleteManyBookmarkSpy;
+    let deleteManyCommentSpy;
+    let deleteManyPageTagRelationSpy;
+    let deleteManyShareLinkSpy;
+    let deleteManyRevisionSpy;
+    let deleteManyPageSpy;
+    let removeAllAttachmentsSpy;
+
+    beforeEach(async(done) => {
+      pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit');
+      deleteCompletelyOperationSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyOperation');
+      deleteCompletelyDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyDescendantsWithStream').mockImplementation();
+
+      deleteManyBookmarkSpy = jest.spyOn(Bookmark, 'deleteMany').mockImplementation();
+      deleteManyCommentSpy = jest.spyOn(Comment, 'deleteMany').mockImplementation();
+      deleteManyPageTagRelationSpy = jest.spyOn(PageTagRelation, 'deleteMany').mockImplementation();
+      deleteManyShareLinkSpy = jest.spyOn(ShareLink, 'deleteMany').mockImplementation();
+      deleteManyRevisionSpy = jest.spyOn(Revision, 'deleteMany').mockImplementation();
+      deleteManyPageSpy = jest.spyOn(Page, 'deleteMany').mockImplementation();
+      removeAllAttachmentsSpy = jest.spyOn(crowi.attachmentService, 'removeAllAttachments').mockImplementation();
+      done();
+    });
+    test('deleteCompletelyOperation', async() => {
+      await crowi.pageService.deleteCompletelyOperation([parentForDeleteCompletely._id], [parentForDeleteCompletely.path], { });
+
+      expect(deleteManyBookmarkSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyCommentSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyPageTagRelationSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyShareLinkSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ path: { $in: [parentForDeleteCompletely.path] } });
+      expect(deleteManyPageSpy).toHaveBeenCalledWith({
+        $or: [{ path: { $in: [parentForDeleteCompletely.path] } },
+              { path: { $in: [] } },
+              { _id: { $in: [parentForDeleteCompletely._id] } }],
+      });
+      expect(removeAllAttachmentsSpy).toHaveBeenCalled();
+    });
+
+    test('delete completely without options', async() => {
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { });
+
+      expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
+      expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2, socketClientId);
+    });
+
+
+    test('delete completely with isRecursively', async() => {
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true);
+
+      expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
+      expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2, socketClientId);
+    });
+  });
+
+  describe('revert page', () => {
+    let getRevertDeletedPageNameSpy;
+    let findByPathSpy;
+    let findSpy;
+    let deleteCompletelySpy;
+    let revertDeletedDescendantsWithStreamSpy;
+
+    beforeEach(async(done) => {
+      getRevertDeletedPageNameSpy = jest.spyOn(Page, 'getRevertDeletedPageName');
+      deleteCompletelySpy = jest.spyOn(crowi.pageService, 'deleteCompletely').mockImplementation();
+      revertDeletedDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'revertDeletedDescendantsWithStream').mockImplementation();
+      done();
+    });
+
+    test('revert deleted page when the redirect from page exists', async() => {
+
+      findByPathSpy = jest.spyOn(Page, 'findByPath').mockImplementation(() => {
+        return { redirectTo: '/trash/parentForRevert1' };
+      });
+
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert1, testUser2);
+
+      expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert1.path);
+      expect(findByPathSpy).toHaveBeenCalledWith('/parentForRevert1');
+      expect(deleteCompletelySpy).toHaveBeenCalled();
+      expect(revertDeletedDescendantsWithStreamSpy).not.toHaveBeenCalled();
+
+      expect(resultPage.path).toBe('/parentForRevert1');
+      expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(resultPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(resultPage.deleteUser).toBeNull();
+      expect(resultPage.deletedAt).toBeNull();
+    });
+
+    test('revert deleted page when the redirect from page does not exist', async() => {
+
+      findByPathSpy = jest.spyOn(Page, 'findByPath').mockImplementation(() => {
+        return null;
+      });
+
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert2, testUser2, {}, true);
+
+      expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert2.path);
+      expect(findByPathSpy).toHaveBeenCalledWith('/parentForRevert2');
+      expect(deleteCompletelySpy).not.toHaveBeenCalled();
+      expect(revertDeletedDescendantsWithStreamSpy).toHaveBeenCalled();
+
+      expect(resultPage.path).toBe('/parentForRevert2');
+      expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(resultPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(resultPage.deleteUser).toBeNull();
+      expect(resultPage.deletedAt).toBeNull();
+    });
+
+    test('revert deleted descendants', async() => {
+
+      findSpy = jest.spyOn(Page, 'find').mockImplementation(() => {
+        return [{ path: '/parentForRevert/child', redirectTo: '/trash/parentForRevert/child' }];
+      });
+
+      await crowi.pageService.revertDeletedDescendants([childForRevert], testUser2);
+      const resultPage = await Page.findOne({ path: '/parentForRevert/child' });
+      const revrtedFromPage = await Page.findOne({ path: '/trash/parentForRevert/child' });
+      const revrtedFromPageRevision = await Revision.findOne({ path: '/trash/parentForRevert/child' });
+
+      expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(childForRevert.path);
+      expect(findSpy).toHaveBeenCalledWith({ path: { $in: ['/parentForRevert/child'] } });
+
+      expect(resultPage.path).toBe('/parentForRevert/child');
+      expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(resultPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(resultPage.deleteUser).toBeNull();
+      expect(resultPage.deletedAt).toBeNull();
+
+      expect(revrtedFromPage).toBeNull();
+      expect(revrtedFromPageRevision).toBeNull();
+    });
+  });
+
+
+});