Quellcode durchsuchen

Implemented legacy pages migration process

Taichi Masuyama vor 4 Jahren
Ursprung
Commit
a28441cb67

+ 2 - 3
packages/app/src/client/services/AdminAppContainer.js

@@ -452,10 +452,9 @@ export default class AdminAppContainer extends Container {
   /**
   /**
    * Start v5 page migration
    * Start v5 page migration
    * @memberOf AdminAppContainer
    * @memberOf AdminAppContainer
-   * @property action takes only 'initialMigration' for now. 'initialMigration' will start or resume migration
    */
    */
-  async v5PageMigrationHandler(action) {
-    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration', { action });
+  async v5PageMigrationHandler() {
+    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration');
     const { isV5Compatible } = response.data;
     const { isV5Compatible } = response.data;
     return { isV5Compatible };
     return { isV5Compatible };
   }
   }

+ 2 - 2
packages/app/src/components/Admin/App/V5PageMigration.tsx

@@ -6,7 +6,7 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 
 
 type Props = {
 type Props = {
-  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: (action: string) => Promise<{ isV5Compatible: boolean }> },
+  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
 }
 }
 
 
 const V5PageMigration: FC<Props> = (props: Props) => {
 const V5PageMigration: FC<Props> = (props: Props) => {
@@ -17,7 +17,7 @@ const V5PageMigration: FC<Props> = (props: Props) => {
   const onConfirm = async() => {
   const onConfirm = async() => {
     setIsV5PageMigrationModalShown(false);
     setIsV5PageMigrationModalShown(false);
     try {
     try {
-      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler('initialMigration');
+      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler();
       if (isV5Compatible) {
       if (isV5Compatible) {
 
 
         return toastSuccess(t('admin:v5_page_migration.already_upgraded'));
         return toastSuccess(t('admin:v5_page_migration.already_upgraded'));

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

@@ -165,7 +165,7 @@ schema.statics.createEmptyPage = async function(
  * @param exPage a page document to be replaced
  * @param exPage a page document to be replaced
  * @returns Promise<void>
  * @returns Promise<void>
  */
  */
-schema.statics.replaceTargetWithEmptyPage = async function(exPage): Promise<void> {
+schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?): Promise<void> {
   // find parent
   // find parent
   const parent = await this.findOne({ _id: exPage.parent });
   const parent = await this.findOne({ _id: exPage.parent });
   if (parent == null) {
   if (parent == null) {
@@ -173,7 +173,7 @@ schema.statics.replaceTargetWithEmptyPage = async function(exPage): Promise<void
   }
   }
 
 
   // create empty page at path
   // create empty page at path
-  const newTarget = await this.createEmptyPage(exPage.path, parent);
+  const newTarget = pageToReplaceWith == null ? await this.createEmptyPage(exPage.path, parent) : pageToReplaceWith;
 
 
   // find children by ex-page _id
   // find children by ex-page _id
   const children = await this.find({ parent: exPage._id });
   const children = await this.find({ parent: exPage._id });

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

@@ -179,14 +179,14 @@ module.exports = (crowi) => {
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
     ],
     ],
-
     duplicatePage: [
     duplicatePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
     ],
     ],
-    v5PageMigration: [
-      body('action').isString().withMessage('action is required'),
+    legacyPagesMigration: [
+      body('pageIds').isArray().withMessage('pageIds is required'),
+      body('isRecursively').isBoolean().withMessage('isRecursively is required'),
     ],
     ],
   };
   };
 
 
@@ -708,26 +708,14 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
-  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.v5PageMigration, apiV3FormValidator, async(req, res) => {
-    const { action, pageIds } = req.body;
+  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const Page = crowi.model('Page');
     const Page = crowi.model('Page');
 
 
     try {
     try {
-      switch (action) {
-        case 'initialMigration':
-          if (!isV5Compatible) {
-            // this method throws and emit socketIo event when error occurs
-            crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC); // not await
-          }
-          break;
-        case 'privateLegacyPages':
-          crowi.pageService.v5MigrationByPageIds(pageIds);
-          break;
-
-        default:
-          logger.error(`${action} action is not supported.`);
-          return res.apiv3Err(new ErrorV3('This action is not supported.', 'not_supported'), 400);
+      if (!isV5Compatible) {
+        // this method throws and emit socketIo event when error occurs
+        crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC); // not await
       }
       }
     }
     }
     catch (err) {
     catch (err) {
@@ -737,6 +725,26 @@ module.exports = (crowi) => {
     return res.apiv3({ isV5Compatible });
     return res.apiv3({ isV5Compatible });
   });
   });
 
 
+  // eslint-disable-next-line max-len
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+    const { pageIds, isRecursively } = req.body;
+
+    if (isRecursively) {
+      // this method innerly uses socket to send message
+      crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds);
+    }
+    else {
+      try {
+        await crowi.pageService.normalizeParentByPageIds(pageIds);
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
+      }
+    }
+
+    return res.apiv3({});
+  });
+
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
     try {
     try {
       const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
       const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');

+ 3 - 0
packages/app/src/server/service/page-grant.ts

@@ -226,6 +226,7 @@ class PageGrantService {
     const ancestors = await builderForAncestors
     const ancestors = await builderForAncestors
       .addConditionToListOnlyAncestors(targetPath)
       .addConditionToListOnlyAncestors(targetPath)
       .addConditionToSortPagesByDescPath()
       .addConditionToSortPagesByDescPath()
+      .addConditionAsMigrated()
       .query
       .query
       .exec();
       .exec();
     const testAncestor = ancestors[0];
     const testAncestor = ancestors[0];
@@ -268,6 +269,7 @@ class PageGrantService {
         $match: {
         $match: {
           path: new RegExp(`^${startsPattern}`),
           path: new RegExp(`^${startsPattern}`),
           isEmpty: { $ne: true },
           isEmpty: { $ne: true },
+          parent: { $ne: null },
         },
         },
       },
       },
       {
       {
@@ -310,6 +312,7 @@ class PageGrantService {
 
 
   /**
   /**
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
+   * Only v5 schema pages will be used to compare.
    * @returns Promise<boolean>
    * @returns Promise<boolean>
    */
    */
   async isGrantNormalized(
   async isGrantNormalized(

+ 54 - 5
packages/app/src/server/service/page.ts

@@ -14,6 +14,7 @@ import {
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import ActivityDefine from '../util/activityDefine';
 import ActivityDefine from '../util/activityDefine';
 import { IPage } from '~/interfaces/page';
 import { IPage } from '~/interfaces/page';
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
 
 
 const debug = require('debug')('growi:services:page');
 const debug = require('debug')('growi:services:page');
 
 
@@ -860,7 +861,7 @@ class PageService {
     // replace with an empty page
     // replace with an empty page
     const shouldReplace = !isRecursively && await Page.exists({ parent: page._id });
     const shouldReplace = !isRecursively && await Page.exists({ parent: page._id });
     if (shouldReplace) {
     if (shouldReplace) {
-      await Page.replaceTargetWithEmptyPage(page);
+      await Page.replaceTargetWithPage(page);
     }
     }
 
 
     if (isRecursively) {
     if (isRecursively) {
@@ -1090,7 +1091,7 @@ class PageService {
     // replace with an empty page
     // replace with an empty page
     const shouldReplace = !isRecursively && !isTrashPage(page.path) && await Page.exists({ parent: page._id });
     const shouldReplace = !isRecursively && !isTrashPage(page.path) && await Page.exists({ parent: page._id });
     if (shouldReplace) {
     if (shouldReplace) {
-      await Page.replaceTargetWithEmptyPage(page);
+      await Page.replaceTargetWithPage(page);
     }
     }
 
 
     await this.deleteCompletelyOperation(ids, paths);
     await this.deleteCompletelyOperation(ids, paths);
@@ -1450,9 +1451,57 @@ class PageService {
     await inAppNotificationService.emitSocketIo(targetUsers);
     await inAppNotificationService.emitSocketIo(targetUsers);
   }
   }
 
 
-  async v5MigrationByPageIds(pageIds) {
-    const Page = mongoose.model('Page');
+  async normalizeParentByPageIds(pageIds: ObjectIdLike[]): Promise<void> {
+    for await (const pageId of pageIds) {
+      try {
+        await this.normalizeParentByPageId(pageId);
+      }
+      catch (err) {
+        // socket.emit('normalizeParentByPageIds', { error: err.message }); TODO: use socket to tell user
+      }
+    }
+  }
+
+  private async normalizeParentByPageId(pageId: ObjectIdLike) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const target = await Page.findById(pageId);
+    if (target == null) {
+      throw Error('target does not exist');
+    }
+
+    const {
+      path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
+    } = target;
+
+    /*
+     * UserGroup & Owner validation
+     */
+    if (target.grant !== Page.GRANT_RESTRICTED) {
+      let isGrantNormalized = false;
+      try {
+        const shouldCheckDescendants = true;
+
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${path}"`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('This page cannot be migrated since the selected grant or grantedGroup is not assignable to this page.');
+      }
+    }
+    else {
+      throw Error('Restricted pages can not be migrated');
+    }
+
+    // getParentAndFillAncestors
+    const parent = await Page.getParentAndFillAncestors(target.path);
+
+    return Page.updateOne({ _id: pageId }, { parent: parent._id });
+  }
 
 
+  async normalizeParentRecursivelyByPageIds(pageIds) {
     if (pageIds == null || pageIds.length === 0) {
     if (pageIds == null || pageIds.length === 0) {
       logger.error('pageIds is null or 0 length.');
       logger.error('pageIds is null or 0 length.');
       return;
       return;
@@ -1467,7 +1516,7 @@ class PageService {
     }
     }
     catch (err) {
     catch (err) {
       logger.error('V5 initial miration failed.', err);
       logger.error('V5 initial miration failed.', err);
-      // socket.emit('v5InitialMirationFailed', { error: err.message }); TODO: use socket to tell user
+      // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
 
 
       throw err;
       throw err;
     }
     }

+ 2 - 2
packages/app/src/test/integration/service/v5-migration.test.js

@@ -16,7 +16,7 @@ describe('V5 page migration', () => {
   });
   });
 
 
 
 
-  describe('v5MigrationByPageIds()', () => {
+  describe('normalizeParentRecursivelyByPageIds()', () => {
     test('should migrate all pages specified by pageIds', async() => {
     test('should migrate all pages specified by pageIds', async() => {
       jest.restoreAllMocks();
       jest.restoreAllMocks();
 
 
@@ -50,7 +50,7 @@ describe('V5 page migration', () => {
 
 
       const pageIds = pages.map(page => page._id);
       const pageIds = pages.map(page => page._id);
       // migrate
       // migrate
-      await crowi.pageService.v5MigrationByPageIds(pageIds);
+      await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds);
 
 
       const migratedPages = await Page.find({
       const migratedPages = await Page.find({
         path: {
         path: {