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

Merge pull request #3368 from weseek/imprv/refacter-recursively

Imprv/refacter recursively
itizawa 5 лет назад
Родитель
Сommit
bf7083d6df

+ 5 - 1
CHANGES.md

@@ -2,7 +2,11 @@
 
 ## v4.2.8-RC
 
-* Fix: Fixed the display of updtedAt and createdAt being reversed 
+* Fix: Fixed the display of updtedAt and createdAt being reversed
+* Improvement: Improved page control performance with stream and bulk
+    * rename, duplicate, delete, deleteCompletely, revrtDeleted
+* Fix: Failed to save temporaryUrlCached with using gcs
+    * Introduced by v4.2.3
 
 ## v4.2.7
 

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

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

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

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

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

+ 47 - 5
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
    */
@@ -503,11 +511,7 @@ class ElasticsearchDelegator {
   deletePages(pages) {
     const self = this;
     const body = [];
-
-    pages.map((page) => {
-      self.prepareBodyForDelete(body, page);
-      return;
-    });
+    pages.forEach(page => self.prepareBodyForDelete(body, page));
 
     logger.debug('deletePages(): Sending Request to ES', body);
     return this.client.bulk({
@@ -963,6 +967,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));

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

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

@@ -0,0 +1,724 @@
+/* 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').mockImplementation();
+      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 });
+
+      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();
+    });
+  });
+
+
+});