Taichi Masuyama 4 лет назад
Родитель
Сommit
8a2167f39b

+ 5 - 3
packages/app/src/server/models/page.ts

@@ -160,7 +160,7 @@ schema.statics.createEmptyPage = async function(path: string, parent) {
  * @param pageToReplaceWith (optional) a page document to replace with
  * @returns Promise<void>
  */
-schema.statics.replaceTargetEmptyPage = async function(exPage, pageToReplaceWith?): Promise<void> {
+schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?): Promise<void> {
   // find parent
   const parent = await this.findOne({ _id: exPage.parent });
   if (parent == null) {
@@ -169,7 +169,7 @@ schema.statics.replaceTargetEmptyPage = async function(exPage, pageToReplaceWith
 
   // create empty page at path
   let newTarget = pageToReplaceWith;
-  if (newTarget) {
+  if (newTarget == null) {
     newTarget = await this.createEmptyPage(exPage.path, parent);
   }
 
@@ -187,7 +187,9 @@ schema.statics.replaceTargetEmptyPage = async function(exPage, pageToReplaceWith
   };
   const operationsForChildren = {
     updateMany: {
-      filter: children.map(d => d._id),
+      filter: {
+        _id: { $in: children.map(d => d._id) },
+      },
       update: {
         parent: newTarget._id,
       },

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

@@ -174,7 +174,7 @@ module.exports = (crowi) => {
     ],
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
-      body('revisionId').isMongoId().withMessage('revisionId is required'),
+      body('revisionId').optional.isMongoId().withMessage('revisionId is required'), // required when v4
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
@@ -453,8 +453,13 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.put('/rename', accessTokenParser, loginRequiredStrictly, csrf, validator.renamePage, apiV3FormValidator, async(req, res) => {
+  router.put('/rename', /* accessTokenParser, loginRequiredStrictly, csrf, */validator.renamePage, apiV3FormValidator, async(req, res) => {
     const { pageId, isRecursively, revisionId } = req.body;
+    // v4 compatible validation
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    if (!isV5Compatible || revisionId == null) {
+      return res.apiv3Err(new ErrorV3('revisionId must be a mongoId', 'invalid_body'), 400);
+    }
 
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
 
@@ -479,13 +484,13 @@ module.exports = (crowi) => {
     let page;
 
     try {
-      page = await Page.findByIdAndViewer(pageId, req.user);
+      page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
       if (page == null) {
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
       }
 
-      if (!page.isUpdatable(revisionId)) {
+      if (!page.isEmpty && !page.isUpdatable(revisionId)) {
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
       }
       page = await crowi.pageService.renamePage(page, newPagePath, req.user, options, isRecursively);

+ 164 - 14
packages/app/src/server/service/page.ts

@@ -180,12 +180,11 @@ class PageService {
     const Page = this.crowi.model('Page');
     const { PageQueryBuilder } = Page;
 
-    const builder = new PageQueryBuilder(Page.find())
+    const builder = new PageQueryBuilder(Page.find(), true)
       .addConditionToExcludeRedirect()
       .addConditionToListOnlyDescendants(targetPagePath);
 
     await Page.addConditionToFilteringByViewerToEdit(builder, userToOperate);
-
     return builder
       .query
       .lean()
@@ -195,7 +194,7 @@ class PageService {
   // TODO: implement recursive rename
   async renamePage(page, newPagePath, user, options, isRecursively = false) {
     // v4 compatible process
-    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible') && page.parent != null;
     if (!isV5Compatible) {
       return this.renamePageV4(page, newPagePath, user, options, isRecursively);
     }
@@ -237,12 +236,17 @@ class PageService {
     /*
      * replace target
      */
+    const escapedPath = escapeStringRegexp(page.path);
+    const shouldReplaceTarget = createRedirectPage
+      || await Page.countDocuments({ path: { $regex: new RegExp(`^${escapedPath}(\\/[^/]+)\\/?$`, 'gi') }, parent: { $ne: null } }) > 0;
     let pageToReplaceWith = null;
     if (createRedirectPage) {
       const body = `redirect ${newPagePath}`;
       pageToReplaceWith = await Page.create(path, body, user, { redirectTo: newPagePath });
     }
-    await Page.replaceTargetWithPage(page, pageToReplaceWith);
+    if (shouldReplaceTarget) {
+      await Page.replaceTargetWithPage(page, pageToReplaceWith);
+    }
 
     /*
      * update target
@@ -303,7 +307,92 @@ class PageService {
   }
 
 
-  private async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
+  private async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix, isV5Compatible, grant?, grantedUsers?, grantedGroup?) {
+    // v4 compatible process
+    if (!isV5Compatible) {
+      return this.renameDescendantsV4(pages, user, options, oldPagePathPrefix, newPagePathPrefix);
+    }
+
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+
+    const { updateMetadata, createRedirectPage } = options;
+
+    const updatePathOperations: any[] = [];
+    const insertRediectPageOperations: any[] = [];
+    const insertRediectRevisionOperations: any[] = [];
+
+    pages.forEach((page) => {
+      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+
+      // increment updatePathOperations
+      let update;
+      if (updateMetadata && !page.isEmpty) {
+        update = {
+          $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: new Date() },
+        };
+      }
+      else {
+        update = {
+          $set: { path: newPagePath },
+        };
+      }
+      updatePathOperations.push({
+        updateOne: {
+          filter: {
+            _id: page._id,
+          },
+          update,
+        },
+      });
+
+      // create redirect page
+      if (createRedirectPage && !page.isEmpty) {
+        const revisionId = new mongoose.Types.ObjectId();
+        insertRediectPageOperations.push({
+          insertOne: {
+            document: {
+              path: page.path,
+              revision: revisionId,
+              creator: user._id,
+              lastUpdateUser: user._id,
+              status: Page.STATUS_PUBLISHED,
+              redirectTo: newPagePath,
+              grant,
+              grantedUsers,
+              grantedGroup,
+            },
+          },
+        });
+        insertRediectRevisionOperations.push({
+          insertOne: {
+            document: {
+              _id: revisionId, pageId: page._id, body: `redirected ${newPagePath}`, author: user._id, format: 'markdown',
+            },
+          },
+        });
+      }
+    });
+
+    try {
+      await Page.bulkWrite(updatePathOperations);
+      // Execute after unorderedBulkOp to prevent duplication
+      if (createRedirectPage) {
+        await Page.bulkWrite(insertRediectPageOperations);
+        await Revision.bulkWrite(insertRediectRevisionOperations);
+        // fill ancestors(parents) of redirectPages
+      }
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error(`Failed to rename pages: ${err}`);
+      }
+    }
+
+    this.pageEvent.emit('updateMany', pages, user);
+  }
+
+  private async renameDescendantsV4(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
     const Page = this.crowi.model('Page');
 
     const pageCollection = mongoose.connection.collection('pages');
@@ -356,10 +445,69 @@ class PageService {
     this.pageEvent.emit('updateMany', pages, user);
   }
 
-  /**
-   * Create rename stream
-   */
   private async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
+    // v4 compatible process
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible') && targetPage.parent != null;
+    if (!isV5Compatible) {
+      return this.renameDescendantsWithStreamV4(targetPage, newPagePath, user, options);
+    }
+
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
+
+    const renameDescendants = this.renameDescendants.bind(this);
+    const normalizeParentOfTree = this.normalizeParentOfTree.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, isV5Compatible, targetPage.grant, targetPage.grantedUsers, targetPage.grantedGroup,
+          );
+          logger.debug(`Renaming pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('Renaming error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      async final(callback) {
+        const Page = mongoose.model('Page') as PageModel;
+        // normalize parent of descendant pages
+        if (targetPage.grant !== Page.GRANT_RESTRICTED && targetPage.grant !== Page.GRANT_SPECIFIED) {
+          try {
+            await normalizeParentOfTree(targetPage.path);
+          }
+          catch (err) {
+            logger.error('Failed to normalize descendants afrer rename:', err);
+            throw err;
+          }
+        }
+
+        logger.debug(`Renaming pages has completed: (totalCount=${count})`);
+
+        // update path
+        targetPage.path = newPagePath;
+        pageEvent.emit('syncDescendantsUpdate', targetPage, user);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(readStream);
+  }
+
+  private async renameDescendantsWithStreamV4(targetPage, newPagePath, user, options = {}) {
 
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
@@ -1021,6 +1169,11 @@ class PageService {
     await inAppNotificationService.emitSocketIo(targetUsers);
   }
 
+  async normalizeParentOfTree(rootPath: string): Promise<void> {
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(rootPath)}`, 'i');
+    return this._v5RecursiveMigration(null, [pathRegExp]);
+  }
+
   async v5MigrationByPageIds(pageIds) {
     const Page = mongoose.model('Page');
 
@@ -1158,7 +1311,7 @@ class PageService {
   }
 
   // TODO: use websocket to show progress
-  async _v5RecursiveMigration(grant, regexps, publicOnly = false) {
+  async _v5RecursiveMigration(grant, regexps, publicOnly = false): Promise<void> {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
     const Page = this.crowi.model('Page');
@@ -1237,15 +1390,12 @@ class PageService {
 
           // modify to adjust for RegExp
           let parentPath = parent.path === '/' ? '' : parent.path;
-          // inject \ before brackets
-          ['(', ')', '[', ']', '{', '}'].forEach((bracket) => {
-            parentPath = parentPath.replace(bracket, `\\${bracket}`);
-          });
+          parentPath = escapeStringRegexp(parentPath);
 
           const filter: any = {
             // regexr.com/6889f
             // ex. /parent/any_child OR /any_level1
-            path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'g') },
+            path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'gi') },
           };
           if (grant != null) {
             filter.grant = grant;