Explorar o código

Merge pull request #5852 from weseek/feat/enable-wildcard-plpages-convert

feat: Enable to convert pages by path for admin users
Yuki Takei %!s(int64=3) %!d(string=hai) anos
pai
achega
6af20f53dd

+ 1 - 0
packages/app/src/interfaces/errors/v5-conversion-error.ts

@@ -2,6 +2,7 @@ export const V5ConversionErrCode = {
   GRANT_INVALID: 'GrantInvalid',
   PAGE_NOT_FOUND: 'PageNotFound',
   DUPLICATE_PAGES_FOUND: 'DuplicatePagesFound',
+  FORBIDDEN: 'Forbidden',
 } as const;
 
 export type V5ConversionErrCode = typeof V5ConversionErrCode[keyof typeof V5ConversionErrCode];

+ 1 - 1
packages/app/src/server/events/user.js

@@ -21,7 +21,7 @@ UserEvent.prototype.onActivated = async function(user) {
 
     // create user page
     try {
-      await Page.create(userPagePath, body, user, {});
+      await this.crowi.pageService.create(userPagePath, body, user, {});
 
       // page created
       debug('User page created', page);

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

@@ -248,14 +248,14 @@ export const getPageSchema = (crowi) => {
   };
 
   pageSchema.methods.applyScope = function(user, grant, grantUserGroupId) {
-    // reset
+    // Reset
     this.grantedUsers = [];
     this.grantedGroup = null;
 
     this.grant = grant || GRANT_PUBLIC;
 
-    if (grant !== GRANT_PUBLIC && grant !== GRANT_USER_GROUP && grant !== GRANT_RESTRICTED) {
-      this.grantedUsers.push(user._id);
+    if (grant === GRANT_OWNER) {
+      this.grantedUsers.push(user?._id ?? user);
     }
 
     if (grant === GRANT_USER_GROUP) {

+ 33 - 236
packages/app/src/server/models/page.ts

@@ -10,15 +10,15 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
+import { IPage, IPageHasId } from '~/interfaces/page';
 import { IUserHasId } from '~/interfaces/user';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
-import { IPage, IPageHasId } from '../../interfaces/page';
+
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
-import { PageRedirectModel } from './page-redirect';
 
 const { addTrailingSlash, normalizePath } = pathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
@@ -51,11 +51,9 @@ type PaginatedPages = {
   offset: number
 }
 
-export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
+export type CreateMethod = (path: string, body: string, user, options: PageCreateOptions) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
-  createEmptyPagesByPaths(paths: string[], user: any | null, onlyMigratedAsExistingPages?: boolean, andFilter?): Promise<void>
-  getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
@@ -65,6 +63,7 @@ export interface PageModel extends Model<PageDocument> {
   generateGrantCondition(
     user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
+  createSystematically(path: string, mrkdwn: string, options: PageCreateOptions): Promise<PageDocument | null>
 
   PageQueryBuilder: typeof PageQueryBuilder
 
@@ -481,58 +480,6 @@ export class PageQueryBuilder {
 
 }
 
-/**
- * Create empty pages if the page in paths didn't exist
- * @param onlyMigratedAsExistingPages Determine whether to include non-migrated pages as existing pages. If a page exists,
- * an empty page will not be created at that page's path.
- */
-schema.statics.createEmptyPagesByPaths = async function(paths: string[], user: any | null, onlyMigratedAsExistingPages = true, filter?): Promise<void> {
-  const aggregationPipeline: any[] = [];
-  // 1. Filter by paths
-  aggregationPipeline.push({ $match: { path: { $in: paths } } });
-  // 2. Normalized condition
-  if (onlyMigratedAsExistingPages) {
-    aggregationPipeline.push({
-      $match: {
-        $or: [
-          { grant: GRANT_PUBLIC },
-          { parent: { $ne: null } },
-          { path: '/' },
-        ],
-      },
-    });
-  }
-  // 3. Add custom pipeline
-  if (filter != null) {
-    aggregationPipeline.push({ $match: filter });
-  }
-  // 4. Add grant conditions
-  let userGroups = null;
-  if (user != null) {
-    const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
-    userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-  }
-  const grantCondition = this.generateGrantCondition(user, userGroups);
-  aggregationPipeline.push({ $match: grantCondition });
-
-  // Run aggregation
-  const existingPages = await this.aggregate(aggregationPipeline);
-
-
-  const existingPagePaths = existingPages.map(page => page.path);
-  // paths to create empty pages
-  const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
-
-  // insertMany empty pages
-  try {
-    await this.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
-  }
-  catch (err) {
-    logger.error('Failed to insert empty pages.', err);
-    throw err;
-  }
-};
-
 schema.statics.createEmptyPage = async function(
     path: string, parent: any, descendantCount = 0, // TODO: improve type including IPage at https://redmine.weseek.co.jp/issues/86506
 ): Promise<PageDocument & { _id: any }> {
@@ -600,73 +547,6 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
   return this.findById(newTarget._id);
 };
 
-/**
- * Find parent or create parent if not exists.
- * It also updates parent of ancestors
- * @param path string
- * @returns Promise<PageDocument>
- */
-schema.statics.getParentAndFillAncestors = async function(path: string, user): Promise<PageDocument> {
-  const parentPath = nodePath.dirname(path);
-
-  const builder1 = new PageQueryBuilder(this.find({ path: parentPath }), true);
-  const pagesCanBeParent = await builder1
-    .addConditionAsMigrated()
-    .query
-    .exec();
-
-  if (pagesCanBeParent.length >= 1) {
-    return pagesCanBeParent[0]; // the earliest page will be the result
-  }
-
-  /*
-   * Fill parents if parent is null
-   */
-  const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
-
-  // just create ancestors with empty pages
-  await this.createEmptyPagesByPaths(ancestorPaths, user);
-
-  // find ancestors
-  const builder2 = new PageQueryBuilder(this.find(), true);
-
-  // avoid including not normalized pages
-  builder2.addConditionToFilterByApplicableAncestors(ancestorPaths);
-
-  const ancestors = await builder2
-    .addConditionToListByPathsArray(ancestorPaths)
-    .addConditionToSortPagesByDescPath()
-    .query
-    .exec();
-
-  const ancestorsMap = new Map(); // Map<path, page>
-  ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page)); // the earlier element should be the true ancestor
-
-  // bulkWrite to update ancestors
-  const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
-  const operations = nonRootAncestors.map((page) => {
-    const parentPath = nodePath.dirname(page.path);
-    return {
-      updateOne: {
-        filter: {
-          _id: page._id,
-        },
-        update: {
-          parent: ancestorsMap.get(parentPath)._id,
-        },
-      },
-    };
-  });
-  await this.bulkWrite(operations);
-
-  const parentId = ancestorsMap.get(parentPath)._id; // get parent page id to fetch updated parent parent
-  const createdParent = await this.findOne({ _id: parentId });
-  if (createdParent == null) {
-    throw Error('updated parent not Found');
-  }
-  return createdParent;
-};
-
 // Utility function to add viewer condition to PageQueryBuilder instance
 const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroups = null): Promise<void> => {
   let relatedUserGroups = userGroups;
@@ -848,11 +728,11 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
 /*
  * Utils from obsolete-page.js
  */
-async function pushRevision(pageData, newRevision, user) {
+export async function pushRevision(pageData, newRevision, user) {
   await newRevision.save();
 
   pageData.revision = newRevision;
-  pageData.lastUpdateUser = user;
+  pageData.lastUpdateUser = user?._id ?? user;
   pageData.updatedAt = Date.now();
 
   return pageData.save();
@@ -999,6 +879,21 @@ schema.statics.removeEmptyPages = async function(pageIdsToNotRemove: ObjectIdLik
   });
 };
 
+// TODO: implement this method
+// schema.statics.findNotEmptyParentByPathRecursively = async function(path: string): Promise<PageDocument | null> {
+// Find a page on the tree by path
+// Find not empty parent
+//   const parent = await this.findById(target.parent);
+
+//   const shouldContinue = parent != null && parent.isEmpty;
+
+//   if (shouldContinue) {
+//     return this.findNotEmptyParentByPathRecursively(parent);
+//   }
+
+//   return target;
+// };
+
 schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
 
 export function generateGrantCondition(
@@ -1047,7 +942,9 @@ schema.statics.generateGrantCondition = generateGrantCondition;
 export type PageCreateOptions = {
   format?: string
   grantUserGroupId?: ObjectIdLike
+  grantedUserIds?: ObjectIdLike[]
   grant?: number
+  isSystematically?: boolean
 }
 
 /*
@@ -1059,123 +956,23 @@ export default (crowi: Crowi): any => {
     pageEvent = crowi.event('page');
   }
 
-  schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
-    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null || crowi.pageOperationService == null) {
+  /**
+   * Use this method when the system needs to create a page. Only available when v5 compatible
+   */
+  schema.statics.createSystematically = async function(path: string, mrkdwn: string, options: PageCreateOptions) {
+    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null) {
       throw Error('Crowi is not setup');
     }
 
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    // v4 compatible process
     if (!isV5Compatible) {
-      return this.createV4(path, body, user, options);
-    }
-
-    const canOperate = await crowi.pageOperationService.canOperate(false, null, path);
-    if (!canOperate) {
-      throw Error(`Cannot operate create to path "${path}" right now.`);
-    }
-
-    const Page = this;
-    const Revision = crowi.model('Revision');
-    const {
-      format = 'markdown', grantUserGroupId,
-    } = options;
-    let grant = options.grant;
-
-    // sanitize path
-    path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
-    // throw if exists
-    const isExist = (await this.count({ path, isEmpty: false })) > 0; // not validate empty page
-    if (isExist) {
-      throw new Error('Cannot create new page to existed path');
-    }
-    // force public
-    if (isTopPage(path)) {
-      grant = GRANT_PUBLIC;
+      throw Error('This method is only available when v5 compatibale.');
     }
 
-    // find an existing empty page
-    const emptyPage = await Page.findOne({ path, isEmpty: true });
+    const dummyUser = { _id: new mongoose.Types.ObjectId() };
 
-    /*
-     * UserGroup & Owner validation
-     */
-    if (grant !== GRANT_RESTRICTED) {
-      let isGrantNormalized = false;
-      try {
-        // It must check descendants as well if emptyTarget is not null
-        const shouldCheckDescendants = emptyPage != null;
-        const newGrantedUserIds = grant === GRANT_OWNER ? [user._id] as IObjectId[] : undefined;
-
-        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(user, path, grant, newGrantedUserIds, grantUserGroupId, shouldCheckDescendants);
-      }
-      catch (err) {
-        logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
-        throw err;
-      }
-      if (!isGrantNormalized) {
-        throw Error('The selected grant or grantedGroup is not assignable to this page.');
-      }
-    }
-
-    /*
-     * update empty page if exists, if not, create a new page
-     */
-    let page;
-    if (emptyPage != null && grant !== GRANT_RESTRICTED) {
-      page = emptyPage;
-      const descendantCount = await this.recountDescendantCount(page._id);
-
-      page.descendantCount = descendantCount;
-      page.isEmpty = false;
-    }
-    else {
-      page = new Page();
-    }
-
-    page.path = path;
-    page.creator = user;
-    page.lastUpdateUser = user;
-    page.status = STATUS_PUBLISHED;
-
-    // set parent to null when GRANT_RESTRICTED
-    const isGrantRestricted = grant === GRANT_RESTRICTED;
-    if (isTopPage(path) || isGrantRestricted) {
-      page.parent = null;
-    }
-    else {
-      const parent = await Page.getParentAndFillAncestors(path, user);
-      page.parent = parent._id;
-    }
-
-    page.applyScope(user, grant, grantUserGroupId);
-
-    let savedPage = await page.save();
-
-    /*
-     * After save
-     */
-    // Delete PageRedirect if exists
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
-    try {
-      await PageRedirect.deleteOne({ fromPath: path });
-      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
-    }
-    catch (err) {
-      // no throw
-      logger.error('Failed to delete PageRedirect');
-    }
-
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
-
-    pageEvent.emit('create', savedPage, user);
-
-    // update descendantCount asynchronously
-    await crowi.pageService.updateDescendantCountOfAncestors(savedPage._id, 1, false);
-
-    return savedPage;
+    options.isSystematically = true;
+    return (crowi.pageService.create as CreateMethod)(path, mrkdwn, dummyUser, options);
   };
 
   const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
@@ -1226,7 +1023,7 @@ export default (crowi: Crowi): any => {
       }
 
       if (!wasOnTree) {
-        const newParent = await this.getParentAndFillAncestors(newPageData.path, user);
+        const newParent = await crowi.pageService.getParentAndFillAncestors(newPageData.path, user);
         newPageData.parent = newParent._id;
       }
     }

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

@@ -204,12 +204,15 @@ module.exports = (crowi) => {
         .custom(v => v === 'true' || v === true || v == null)
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
     ],
+    convertPagesByPath: [
+      body('convertPath').optional().isString().withMessage('convertPath must be a string'),
+    ],
   };
 
   async function createPageAction({
     path, body, user, options,
   }) {
-    const createdPage = await Page.create(path, body, user, options);
+    const createdPage = await crowi.pageService.create(path, body, user, options);
     return createdPage;
   }
 
@@ -823,29 +826,33 @@ module.exports = (crowi) => {
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
 
+
   // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
-    const { convertPath, pageIds: _pageIds, isRecursively } = req.body;
+  router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
+    const { convertPath } = req.body;
 
     // Convert by path
-    if (convertPath != null) {
-      const normalizedPath = pathUtils.normalizePath(convertPath);
-      try {
-        await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
-      }
-      catch (err) {
-        logger.error(err);
-
-        if (isV5ConversionError(err)) {
-          return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
-        }
+    const normalizedPath = pathUtils.normalizePath(convertPath);
+    try {
+      await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
+    }
+    catch (err) {
+      logger.error(err);
 
-        return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
+      if (isV5ConversionError(err)) {
+        return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
       }
 
-      return res.apiv3({});
+      return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
     }
 
+    return res.apiv3({});
+  });
+
+  // eslint-disable-next-line max-len
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+    const { pageIds: _pageIds, isRecursively } = req.body;
+
     // Convert by pageIds
     const pageIds = _pageIds == null ? [] : _pageIds;
 

+ 1 - 1
packages/app/src/server/routes/attachment.js

@@ -437,7 +437,7 @@ module.exports = function(crowi, app) {
     if (pageId == null) {
       logger.debug('Create page before file upload');
 
-      page = await Page.create(pagePath, `# ${pagePath}`, req.user, { grant: Page.GRANT_OWNER });
+      page = await crowi.pageService.create(pagePath, `# ${pagePath}`, req.user, { grant: Page.GRANT_OWNER });
       pageCreated = true;
       pageId = page._id;
     }

+ 1 - 1
packages/app/src/server/routes/page.js

@@ -792,7 +792,7 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
     }
 
-    const createdPage = await Page.create(pagePath, body, req.user, options);
+    const createdPage = await crowi.pageService.create(pagePath, body, req.user, options);
 
     let savedTags;
     if (pageTags != null) {

+ 1 - 6
packages/app/src/server/service/installer.ts

@@ -43,14 +43,9 @@ export class InstallerService {
   }
 
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
-
-    // TODO typescriptize models/user.js and remove eslint-disable-next-line
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const Page = mongoose.model('Page') as any;
-
     try {
       const markdown = fs.readFileSync(filePath);
-      return Page.create(pagePath, markdown, owner, {}) as IPage;
+      return this.crowi.pageService.create(pagePath, markdown, owner, {}) as IPage;
     }
     catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);

+ 322 - 19
packages/app/src/server/service/page.ts

@@ -20,7 +20,7 @@ import { IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import {
-  CreateMethod, PageCreateOptions, PageModel, PageDocument,
+  CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision,
 } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
@@ -525,7 +525,7 @@ class PageService {
       newParent = await this.getParentAndforceCreateEmptyTree(page, newPagePath);
     }
     else {
-      newParent = await Page.getParentAndFillAncestors(newPagePath, user);
+      newParent = await this.getParentAndFillAncestors(newPagePath, user);
     }
 
     // 3. Put back target page to tree (also update the other attrs)
@@ -979,12 +979,12 @@ class PageService {
     };
     let duplicatedTarget;
     if (page.isEmpty) {
-      const parent = await Page.getParentAndFillAncestors(newPagePath, user);
+      const parent = await this.getParentAndFillAncestors(newPagePath, user);
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
     }
     else {
       await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
-      duplicatedTarget = await (Page.create as CreateMethod)(
+      duplicatedTarget = await (this.create as CreateMethod)(
         newPagePath, page.revision.body, user, options,
       );
     }
@@ -1067,7 +1067,6 @@ class PageService {
   }
 
   async duplicateV4(page, newPagePath, user, isRecursively) {
-    const Page = this.crowi.model('Page');
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     // populate
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
@@ -1080,7 +1079,7 @@ class PageService {
 
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
-    const createdPage = await Page.create(
+    const createdPage = await this.crowi.pageService.create(
       newPagePath, page.revision.body, user, options,
     );
     this.pageEvent.emit('duplicate', page, user);
@@ -1741,6 +1740,11 @@ class PageService {
     return;
   }
 
+  // TODO: implement this method
+  async deleteCompletelySystematically() {
+    return;
+  }
+
   async deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike): Promise<void> {
     await this.deleteCompletelyDescendantsWithStream(page, user, options, false);
 
@@ -1910,7 +1914,7 @@ class PageService {
     }
 
     // 2. Revert target
-    const parent = await Page.getParentAndFillAncestors(newPath, user);
+    const parent = await this.getParentAndFillAncestors(newPath, user);
     const updatedPage = await Page.findByIdAndUpdate(page._id, {
       $set: {
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null, parent: parent._id, descendantCount: 0,
@@ -2260,8 +2264,13 @@ class PageService {
     if (pages == null || !Array.isArray(pages)) {
       throw Error('Something went wrong while converting pages.');
     }
+
+
     if (pages.length === 0) {
-      throw new V5ConversionError(`Could not find the page "${path}" to convert.`, V5ConversionErrCode.PAGE_NOT_FOUND);
+      const isForbidden = await Page.count({ path, isEmpty: false }) > 0;
+      if (isForbidden) {
+        throw new V5ConversionError('It is not allowed to convert this page.', V5ConversionErrCode.FORBIDDEN);
+      }
     }
     if (pages.length > 1) {
       throw new V5ConversionError(
@@ -2270,10 +2279,31 @@ class PageService {
       );
     }
 
-    const page = pages[0];
-    const {
-      grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
-    } = page;
+    let page;
+    let systematicallyCreatedPage;
+
+    const shouldCreateNewPage = pages[0] == null;
+    if (shouldCreateNewPage) {
+      const notEmptyParent = await Page.findNotEmptyParentByPathRecursively(path);
+
+      systematicallyCreatedPage = await Page.createSystematically(
+        path,
+        'This page was created by GROWI.',
+        {
+          grant: notEmptyParent.grant,
+          grantedUserIds: notEmptyParent.grantedUsers,
+          grantUserGroupId: notEmptyParent.grantedGroup,
+        },
+      );
+      page = systematicallyCreatedPage;
+    }
+    else {
+      page = page[0];
+    }
+
+    const grant = page.grant;
+    const grantedUserIds = page.grantedUsers;
+    const grantedGroupId = page.grantedGroup;
 
     /*
      * UserGroup & Owner validation
@@ -2285,6 +2315,7 @@ class PageService {
       isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
     }
     catch (err) {
+      // TODO: delete systematicallyCreatedPage
       logger.error(`Failed to validate grant of page at "${path}"`, err);
       throw err;
     }
@@ -2307,12 +2338,20 @@ class PageService {
       });
     }
     catch (err) {
+      // TODO: delete systematicallyCreatedPage
       logger.error('Failed to create PageOperation document.', err);
       throw err;
     }
 
     // no await
-    this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+    (async() => {
+      // Remove the created page if no page has converted
+      const count = await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+      if (count === 0) {
+        // TODO: delete systematicallyCreatedPage
+        // await this.deleteCompletelySystematically(systematicallyCreatedPage, user, {}, false);
+      }
+    })();
   }
 
   async normalizeParentByPageIdsRecursively(pageIds: ObjectIdLike[], user): Promise<void> {
@@ -2412,7 +2451,7 @@ class PageService {
       normalizedPage = await Page.findById(page._id);
     }
     else {
-      const parent = await Page.getParentAndFillAncestors(page.path, user);
+      const parent = await this.getParentAndFillAncestors(page.path, user);
       normalizedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
     }
 
@@ -2509,7 +2548,7 @@ class PageService {
     }
   }
 
-  async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
+  async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<number> {
     // Save prevDescendantCount for sub-operation
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
@@ -2519,8 +2558,9 @@ class PageService {
     const exPage = await builder.query.exec();
     const options = { prevDescendantCount: exPage?.descendantCount ?? 0 };
 
+    let count: number;
     try {
-      await this.normalizeParentRecursively([page.path], user);
+      count = await this.normalizeParentRecursively([page.path], user);
     }
     catch (err) {
       logger.error('V5 initial miration failed.', err);
@@ -2536,6 +2576,8 @@ class PageService {
     }
 
     await this.normalizeParentRecursivelySubOperation(page, user, pageOp._id, options);
+
+    return count;
   }
 
   async normalizeParentRecursivelySubOperation(page, user, pageOpId: ObjectIdLike, options: {prevDescendantCount: number}): Promise<void> {
@@ -2674,7 +2716,7 @@ class PageService {
    * @param user To be used to filter pages to update. If null, only public pages will be updated.
    * @returns Promise<void>
    */
-  async normalizeParentRecursively(paths: string[], user: any | null): Promise<void> {
+  async normalizeParentRecursively(paths: string[], user: any | null): Promise<number> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
@@ -2740,7 +2782,7 @@ class PageService {
 
   private async _normalizeParentRecursively(
       pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
-  ): Promise<void> {
+  ): Promise<number> {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
 
@@ -2779,6 +2821,8 @@ class PageService {
     let nextCount = count;
     let nextSkiped = skiped;
 
+    const createEmptyPagesByPaths = this.createEmptyPagesByPaths.bind(this);
+
     const migratePagesStream = new Writable({
       objectMode: true,
       async write(pages, encoding, callback) {
@@ -2817,7 +2861,7 @@ class PageService {
           { path: { $nin: publicPathsToNormalize }, status: Page.STATUS_PUBLISHED },
         ];
         const filterForApplicableAncestors = { $or: orFilters };
-        await Page.createEmptyPagesByPaths(parentPaths, user, false, filterForApplicableAncestors);
+        await createEmptyPagesByPaths(parentPaths, user, false, true, filterForApplicableAncestors);
 
         // 3. Find parents
         const addGrantCondition = (builder) => {
@@ -2910,6 +2954,8 @@ class PageService {
 
     // End
     socket.emit(SocketEventName.PMEnded, { isSucceeded: true });
+
+    return nextCount;
   }
 
   private async _v5NormalizeIndex() {
@@ -3008,6 +3054,263 @@ class PageService {
     socket.emit(SocketEventName.UpdateDescCount, data);
   }
 
+  /**
+   * Find parent or create parent if not exists.
+   * It also updates parent of ancestors
+   * @param path string
+   * @returns Promise<PageDocument>
+   */
+  async getParentAndFillAncestors(path: string, user, options?: { isSystematically?: boolean }): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    const parentPath = pathlib.dirname(path);
+
+    const builder1 = new PageQueryBuilder(Page.find({ path: parentPath }), true);
+    const pagesCanBeParent = await builder1
+      .addConditionAsMigrated()
+      .query
+      .exec();
+
+    if (pagesCanBeParent.length >= 1) {
+      return pagesCanBeParent[0]; // the earliest page will be the result
+    }
+
+    /*
+     * Fill parents if parent is null
+     */
+    const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
+
+    // just create ancestors with empty pages
+    // TODO: separate process
+    await this.createEmptyPagesByPaths(ancestorPaths, user, true);
+
+    // find ancestors
+    const builder2 = new PageQueryBuilder(Page.find(), true);
+
+    // avoid including not normalized pages
+    builder2.addConditionToFilterByApplicableAncestors(ancestorPaths);
+
+    const ancestors = await builder2
+      .addConditionToListByPathsArray(ancestorPaths)
+      .addConditionToSortPagesByDescPath()
+      .query
+      .exec();
+
+    const ancestorsMap = new Map(); // Map<path, page>
+    ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page)); // the earlier element should be the true ancestor
+
+    // bulkWrite to update ancestors
+    const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
+    const operations = nonRootAncestors.map((page) => {
+      const parentPath = pathlib.dirname(page.path);
+      return {
+        updateOne: {
+          filter: {
+            _id: page._id,
+          },
+          update: {
+            parent: ancestorsMap.get(parentPath)._id,
+          },
+        },
+      };
+    });
+    await Page.bulkWrite(operations);
+
+    const parentId = ancestorsMap.get(parentPath)._id; // get parent page id to fetch updated parent parent
+    const createdParent = await Page.findOne({ _id: parentId });
+    if (createdParent == null) {
+      throw Error('updated parent not Found');
+    }
+    return createdParent;
+  }
+
+  // TODO: separate processes for systematical and manual operations
+  /**
+   * Create empty pages if the page in paths didn't exist
+   * @param onlyMigratedAsExistingPages Determine whether to include non-migrated pages as existing pages. If a page exists,
+   * an empty page will not be created at that page's path.
+   */
+  async createEmptyPagesByPaths(
+      paths: string[],
+      user: any | null,
+      onlyMigratedAsExistingPages = true,
+      andFilter?,
+  ): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const aggregationPipeline: any[] = [];
+    // 1. Filter by paths
+    aggregationPipeline.push({ $match: { path: { $in: paths } } });
+    // 2. Normalized condition
+    if (onlyMigratedAsExistingPages) {
+      aggregationPipeline.push({
+        $match: {
+          $or: [
+            { grant: Page.GRANT_PUBLIC },
+            { parent: { $ne: null } },
+            { path: '/' },
+          ],
+        },
+      });
+    }
+    // 3. Add custom pipeline
+    if (andFilter != null) {
+      aggregationPipeline.push({ $match: andFilter });
+    }
+    // 4. Add grant conditions
+    // TODO: need to separate here
+    let userGroups = null;
+    if (user != null) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+    const grantCondition = Page.generateGrantCondition(user, userGroups);
+    aggregationPipeline.push({ $match: grantCondition });
+
+    // Run aggregation
+    const existingPages = await Page.aggregate(aggregationPipeline);
+
+
+    const existingPagePaths = existingPages.map(page => page.path);
+    // paths to create empty pages
+    const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
+
+    // insertMany empty pages
+    try {
+      await Page.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
+    }
+    catch (err) {
+      logger.error('Failed to insert empty pages.', err);
+      throw err;
+    }
+  }
+
+
+  async create(path: string, body: string, user, options: PageCreateOptions = {}) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+
+    const { isSystematically } = options;
+
+    if (user == null && !isSystematically) {
+      throw Error('Cannot call create() without a parameter "user" when shouldSkipUserValidation is false.');
+    }
+
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    // v4 compatible process
+    if (!isV5Compatible) {
+      return Page.createV4(path, body, user, options);
+    }
+
+    const canOperate = await this.crowi.pageOperationService.canOperate(false, null, path);
+    if (!canOperate) {
+      throw Error(`Cannot operate create to path "${path}" right now.`);
+    }
+
+    const {
+      format = 'markdown', grantUserGroupId, grantedUserIds,
+    } = options;
+    let grant = options.grant;
+
+    // sanitize path
+    path = this.crowi.xss.process(path); // eslint-disable-line no-param-reassign
+    // throw if exists
+    const isExist = (await Page.count({ path, isEmpty: false })) > 0; // not validate empty page
+    if (isExist) {
+      throw Error('Cannot create new page to existed path');
+    }
+    // force public
+    if (isTopPage(path)) {
+      grant = Page.GRANT_PUBLIC;
+    }
+
+    // find an existing empty page
+    const emptyPage = await Page.findOne({ path, isEmpty: true });
+
+    /*
+     * UserGroup & Owner validation
+     */
+    if (!isSystematically && grant !== Page.GRANT_RESTRICTED) {
+      let isGrantNormalized = false;
+      try {
+        // It must check descendants as well if emptyTarget is not null
+        const shouldCheckDescendants = emptyPage != null;
+        const newGrantedUserIds = grant === Page.GRANT_OWNER ? [user._id] : undefined;
+
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, newGrantedUserIds, grantUserGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('The selected grant or grantedGroup is not assignable to this page.');
+      }
+    }
+
+    /*
+     * update empty page if exists, if not, create a new page
+     */
+    let page;
+    if (emptyPage != null && grant !== Page.GRANT_RESTRICTED) {
+      page = emptyPage;
+      const descendantCount = await Page.recountDescendantCount(page._id);
+
+      page.descendantCount = descendantCount;
+      page.isEmpty = false;
+    }
+    else {
+      page = new Page();
+    }
+
+    page.path = path;
+    page.creator = user;
+    page.lastUpdateUser = user;
+    page.status = Page.STATUS_PUBLISHED;
+
+    // set parent to null when GRANT_RESTRICTED
+    const isGrantRestricted = grant === Page.GRANT_RESTRICTED;
+    if (isTopPage(path) || isGrantRestricted) {
+      page.parent = null;
+    }
+    else {
+      const options = { isSystematically };
+      const parent = await this.getParentAndFillAncestors(path, user, options);
+      page.parent = parent._id;
+    }
+
+    const userForApplyScope = grantedUserIds?.[0] != null ? { _id: grantedUserIds[0] } : user;
+    page.applyScope(userForApplyScope, grant, grantUserGroupId);
+
+    let savedPage = await page.save();
+
+    /*
+     * After save
+     */
+    // Delete PageRedirect if exists
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+    try {
+      await PageRedirect.deleteOne({ fromPath: path });
+      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
+    }
+    catch (err) {
+      // no throw
+      logger.error('Failed to delete PageRedirect');
+    }
+
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    savedPage = await pushRevision(savedPage, newRevision, user);
+    await savedPage.populateDataToShowRevision();
+
+    this.pageEvent.emit('create', savedPage, user);
+
+    // update descendantCount asynchronously
+    await this.crowi.pageService.updateDescendantCountOfAncestors(savedPage._id, 1, false);
+
+    return savedPage;
+  }
+
 }
 
 export default PageService;

+ 1 - 1
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -21,7 +21,7 @@ class CreatePageService {
 
     // generate a dummy id because Operation to create a page needs ObjectId
     const dummyObjectIdOfUser = userId != null ? userId : new mongoose.Types.ObjectId();
-    const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
+    const page = await this.crowi.pageService.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
 
     // Send a message when page creation is complete
     const growiUri = this.crowi.appService.getSiteUrl();

+ 1 - 1
packages/app/src/server/util/createGrowiPagesFromImports.js

@@ -27,7 +27,7 @@ module.exports = (crowi) => {
 
       if (isCreatableName && !isPageNameTaken) {
         try {
-          const promise = Page.create(path, body, user, { grant: Page.GRANT_PUBLIC, grantUserGroupId: null });
+          const promise = crowi.pageService.create(path, body, user, { grant: Page.GRANT_PUBLIC, grantUserGroupId: null });
           promises.push(promise);
         }
         catch (err) {

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

@@ -511,13 +511,13 @@ describe('Page', () => {
   describe('create', () => {
 
     test('Should create single page', async() => {
-      const page = await Page.create('/v5_create1', 'create1', dummyUser1, {});
+      const page = await crowi.pageService.create('/v5_create1', 'create1', dummyUser1, {});
       expect(page).toBeTruthy();
       expect(page.parent).toStrictEqual(rootPage._id);
     });
 
     test('Should create empty-child and non-empty grandchild', async() => {
-      const grandchildPage = await Page.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
+      const grandchildPage = await crowi.pageService.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
       const childPage = await Page.findOne({ path: '/v5_empty_create2' });
 
       expect(childPage.isEmpty).toBe(true);
@@ -531,7 +531,7 @@ describe('Page', () => {
       const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
       expect(beforeCreatePage.isEmpty).toBe(true);
 
-      const childPage = await Page.create('/v5_empty_create_4', 'body', dummyUser1, {});
+      const childPage = await crowi.pageService.create('/v5_empty_create_4', 'body', dummyUser1, {});
       const grandchildPage = await Page.findOne({ parent: childPage._id });
 
       expect(childPage).toBeTruthy();
@@ -557,7 +557,7 @@ describe('Page', () => {
         expect(page3).toBeNull();
 
         // use existing path
-        await Page.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await crowi.pageService.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
@@ -582,7 +582,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page2).toBeNull();
 
-        await Page.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await crowi.pageService.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
@@ -814,7 +814,7 @@ describe('Page', () => {
   describe('getParentAndFillAncestors', () => {
     test('return parent if exist', async() => {
       const page1 = await Page.findOne({ path: '/PAF1' });
-      const parent = await Page.getParentAndFillAncestors(page1.path, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestors(page1.path, dummyUser1);
       expect(parent).toBeTruthy();
       expect(page1.parent).toStrictEqual(parent._id);
     });
@@ -829,7 +829,7 @@ describe('Page', () => {
       expect(_page2).toBeNull();
       expect(_page3).toBeNull();
 
-      const parent = await Page.getParentAndFillAncestors(path3, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestors(path3, dummyUser1);
       const page1 = await Page.findOne({ path: path1 });
       const page2 = await Page.findOne({ path: path2 });
       const page3 = await Page.findOne({ path: path3 });
@@ -854,7 +854,7 @@ describe('Page', () => {
       expect(_page1).toBeTruthy();
       expect(_page2).toBeTruthy();
 
-      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestors(_page2.path, dummyUser1);
       const page1 = await Page.findOne({ path: path1, isEmpty: true }); // parent
       const page2 = await Page.findOne({ path: path2, isEmpty: false });
 
@@ -877,7 +877,7 @@ describe('Page', () => {
       expect(_page3).toBeTruthy();
       expect(_page3.parent).toBeNull();
 
-      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestors(_page2.path, dummyUser1);
       const page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
       const page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
       const page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
@@ -920,7 +920,7 @@ describe('Page', () => {
       expect(_emptyA).toBeNull();
       expect(_emptyAB).toBeNull();
 
-      const parent = await Page.getParentAndFillAncestors('/get_parent_A/get_parent_B/get_parent_C', dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestors('/get_parent_A/get_parent_B/get_parent_C', dummyUser1);
 
       const pageA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: false });
@@ -966,7 +966,7 @@ describe('Page', () => {
       expect(_emptyC).toBeNull();
       expect(_emptyCD).toBeNull();
 
-      const parent = await Page.getParentAndFillAncestors('/get_parent_C/get_parent_D/get_parent_E', dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestors('/get_parent_C/get_parent_D/get_parent_E', dummyUser1);
 
       const pageC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: false });