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

Merge pull request #5119 from weseek/feat/page-create-update-v5

feat: Page create update v5
Haku Mizuki 4 лет назад
Родитель
Сommit
4243dd0861

+ 4 - 0
packages/app/src/server/crowi/index.js

@@ -20,6 +20,7 @@ import AppService from '../service/app';
 import AclService from '../service/acl';
 import SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
+import PageGrantService from '../service/page-grant';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 
@@ -682,6 +683,9 @@ Crowi.prototype.setupPageService = async function() {
   if (this.pageService == null) {
     this.pageService = new PageEventService(this);
   }
+  if (this.pageGrantService == null) {
+    this.pageGrantService = new PageGrantService(this);
+  }
 };
 
 Crowi.prototype.setupInAppNotificationService = async function() {

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

@@ -156,6 +156,21 @@ export class PageQueryBuilder {
 
   }
 
+  addConditionToListOnlyAncestors(path) {
+    const pathNormalized = pathUtils.normalizePath(path);
+    const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
+
+    this.query = this.query
+      .and({
+        path: {
+          $in: ancestorsPaths,
+        },
+      });
+
+    return this;
+
+  }
+
   /**
    * generate the query to find pages that start with `path`
    *
@@ -970,14 +985,17 @@ export const getPageSchema = (crowi) => {
     }
   }
 
-  pageSchema.statics.create = async function(path, body, user, options = {}) {
+  pageSchema.statics.createV4 = async function(path, body, user, options = {}) {
+    /*
+     * v4 compatible process
+     */
     validateCrowi();
 
     const Page = this;
     const Revision = crowi.model('Revision');
-    const {
-      format = 'markdown', redirectTo, grantUserGroupId, parentId,
-    } = options;
+    const format = options.format || 'markdown';
+    const redirectTo = options.redirectTo || null;
+    const grantUserGroupId = options.grantUserGroupId || null;
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -988,37 +1006,18 @@ export const getPageSchema = (crowi) => {
       grant = GRANT_PUBLIC;
     }
 
-    const isExist = await this.count({ path, isEmpty: false }); // not validate empty page
+    const isExist = await this.count({ path });
+
     if (isExist) {
       throw new Error('Cannot create new page to existed path');
     }
 
-    /*
-     * update empty page if exists, if not, create a new page
-     */
-    let page;
-    const emptyPage = await Page.findOne({ path, isEmpty: true });
-    if (emptyPage != null) {
-      page = emptyPage;
-      page.isEmpty = false;
-    }
-    else {
-      page = new Page();
-    }
-
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-
-    let parent = parentId;
-    if (isV5Compatible && parent == null && !isTopPage(path)) {
-      parent = await Page.getParentIdAndFillAncestors(path);
-    }
-
+    const page = new Page();
     page.path = path;
     page.creator = user;
     page.lastUpdateUser = user;
     page.redirectTo = redirectTo;
     page.status = STATUS_PUBLISHED;
-    page.parent = parent;
 
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
@@ -1034,7 +1033,7 @@ export const getPageSchema = (crowi) => {
     return savedPage;
   };
 
-  pageSchema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
+  pageSchema.statics.updatePageV4 = async function(pageData, body, previousBody, user, options = {}) {
     validateCrowi();
 
     const Revision = crowi.model('Revision');

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

@@ -23,7 +23,7 @@ const logger = loggerFactory('growi:models:page');
  */
 const GRANT_PUBLIC = 1;
 const GRANT_RESTRICTED = 2;
-const GRANT_SPECIFIED = 3;
+const GRANT_SPECIFIED = 3; // DEPRECATED
 const GRANT_OWNER = 4;
 const GRANT_USER_GROUP = 5;
 const PAGE_GRANT_ERROR = 1;
@@ -39,7 +39,7 @@ type TargetAndAncestorsResult = {
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[], publicOnly?: boolean): Promise<void>
-  getParentIdAndFillAncestors(path: string): Promise<string | null>
+  getParentIdAndFillAncestors(path: string, parent: (PageDocument & { _id: any }) | null): Promise<string | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
@@ -57,6 +57,7 @@ export interface PageModel extends Model<PageDocument> {
   STATUS_DELETED
 }
 
+type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
 const schema = new Schema<PageDocument, PageModel>({
@@ -145,10 +146,9 @@ schema.statics.createEmptyPagesByPaths = async function(paths: string[], publicO
  *   - second  update ancestor pages' parent
  *   - finally return the target's parent page id
  */
-schema.statics.getParentIdAndFillAncestors = async function(path: string): Promise<Schema.Types.ObjectId> {
+schema.statics.getParentIdAndFillAncestors = async function(path: string, parent: PageDocument | null): Promise<Schema.Types.ObjectId> {
   const parentPath = nodePath.dirname(path);
 
-  const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
   if (parent != null) {
     return parent._id;
   }
@@ -171,7 +171,7 @@ schema.statics.getParentIdAndFillAncestors = async function(path: string): Promi
     .exec();
 
   const ancestorsMap = new Map(); // Map<path, _id>
-  ancestors.forEach(page => ancestorsMap.set(page.path, page._id));
+  ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page._id)); // the earlier element should be the true ancestor
 
   // bulkWrite to update ancestors
   const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
@@ -328,6 +328,19 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
   return pathToChildren;
 };
 
+/*
+ * Utils from obsolete-page.js
+ */
+async function pushRevision(pageData, newRevision, user) {
+  await newRevision.save();
+
+  pageData.revision = newRevision;
+  pageData.lastUpdateUser = user;
+  pageData.updatedAt = Date.now();
+
+  return pageData.save();
+}
+
 /**
  * return aggregate condition to get following pages
  * - page that has the same path as the provided path
@@ -424,6 +437,174 @@ schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id:mo
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */
 export default (crowi: Crowi): any => {
+  let pageEvent;
+  if (crowi != null) {
+    pageEvent = crowi.event('page');
+  }
+
+  schema.statics.create = async function(path, body, user, options = {}) {
+    if (crowi.pageGrantService == null || crowi.configManager == 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 Page = this;
+    const Revision = crowi.model('Revision');
+    const {
+      format = 'markdown', redirectTo, 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;
+    }
+
+    // find an existing empty page
+    const emptyPage = await Page.findOne({ path, isEmpty: true });
+
+    /*
+     * 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(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) {
+      page = emptyPage;
+      page.isEmpty = false;
+    }
+    else {
+      page = new Page();
+    }
+
+    let parentId: string | null = null;
+    const parentPath = nodePath.dirname(path);
+    const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
+    if (!isTopPage(path)) {
+      parentId = await Page.getParentIdAndFillAncestors(path, parent);
+    }
+
+    page.path = path;
+    page.creator = user;
+    page.lastUpdateUser = user;
+    page.redirectTo = redirectTo;
+    page.status = STATUS_PUBLISHED;
+
+    // set parent to null when GRANT_RESTRICTED
+    if (grant === GRANT_RESTRICTED) {
+      page.parent = null;
+    }
+    else {
+      page.parent = parentId;
+    }
+
+    page.applyScope(user, grant, grantUserGroupId);
+
+    let savedPage = await page.save();
+
+    /*
+     * After save
+     */
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    const revision = await pushRevision(savedPage, newRevision, user);
+    savedPage = await this.findByPath(revision.path);
+    await savedPage.populateDataToShowRevision();
+
+    pageEvent.emit('create', savedPage, user);
+
+    return savedPage;
+  };
+
+  schema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
+    if (crowi.configManager == null || crowi.pageGrantService == null) {
+      throw Error('Crowi is not set up');
+    }
+
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    if (!isV5Compatible) {
+      // v4 compatible process
+      return this.updatePageV4(pageData, body, previousBody, user, options);
+    }
+
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const grant = options.grant || pageData.grant; // use the previous data if absence
+    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+    const grantedUserIds = pageData.grantedUserIds || [user._id];
+
+    const newPageData = pageData;
+
+    if (grant === GRANT_RESTRICTED) {
+      newPageData.parent = null;
+    }
+    else {
+      /*
+       * UserGroup & Owner validation
+       */
+      let isGrantNormalized = false;
+      try {
+        const shouldCheckDescendants = true;
+
+        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(pageData.path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${pageData.path}" of grant ${grant}:`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('The selected grant or grantedGroup is not assignable to this page.');
+      }
+    }
+
+    newPageData.applyScope(user, grant, grantUserGroupId);
+
+    // update existing page
+    let savedPage = await newPageData.save();
+    const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
+    const revision = await pushRevision(savedPage, newRevision, user);
+    savedPage = await this.findByPath(revision.path);
+    await savedPage.populateDataToShowRevision();
+
+    if (isSyncRevisionToHackmd) {
+      savedPage = await this.syncRevisionToHackmd(savedPage);
+    }
+
+    pageEvent.emit('update', savedPage, user);
+
+    return savedPage;
+  };
+
   // add old page schema methods
   const pageSchema = getPageSchema(crowi);
   schema.methods = { ...pageSchema.methods, ...schema.methods };

+ 22 - 0
packages/app/src/server/models/user-group.ts

@@ -84,6 +84,13 @@ schema.statics.createGroup = async function(name, description, parentId) {
   return this.create({ name, description, parent });
 };
 
+/**
+ * Find all ancestor groups starting from the UserGroup of the initial "group".
+ * Set "ancestors" as "[]" if the initial group is unnecessary as result.
+ * @param groups UserGroupDocument
+ * @param ancestors UserGroupDocument[]
+ * @returns UserGroupDocument[]
+ */
 schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancestors = [group]) {
   if (group == null) {
     return ancestors;
@@ -99,6 +106,13 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
   return this.findGroupsWithAncestorsRecursively(parent, ancestors);
 };
 
+/**
+ * Find all descendant groups starting from the UserGroups in the initial groups in "groups".
+ * Set "descendants" as "[]" if the initial groups are unnecessary as result.
+ * @param groups UserGroupDocument[] including at least one UserGroup
+ * @param descendants UserGroupDocument[]
+ * @returns UserGroupDocument[]
+ */
 schema.statics.findGroupsWithDescendantsRecursively = async function(groups, descendants = groups) {
   const nextGroups = await this.find({ parent: { $in: groups.map(g => g._id) } });
 
@@ -109,4 +123,12 @@ schema.statics.findGroupsWithDescendantsRecursively = async function(groups, des
   return this.findGroupsWithDescendantsRecursively(nextGroups, descendants.concat(nextGroups));
 };
 
+schema.statics.findGroupsWithDescendantsById = async function(groupId) {
+  const root = await this.findOne({ _id: groupId });
+  if (root == null) {
+    throw Error('The root user group does not exist');
+  }
+  return this.findGroupsWithDescendantsRecursively([root]);
+};
+
 export default getOrCreateModel<UserGroupDocument, UserGroupModel>('UserGroup', schema);

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

@@ -1,4 +1,5 @@
 import loggerFactory from '~/utils/logger';
+import UserGroup from '~/server/models/user-group';
 
 const logger = loggerFactory('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
@@ -7,7 +8,6 @@ const debug = require('debug')('growi:routes:admin');
 module.exports = function(crowi, app) {
 
   const models = crowi.models;
-  const UserGroup = models.UserGroup;
   const UserGroupRelation = models.UserGroupRelation;
   const GlobalNotificationSetting = models.GlobalNotificationSetting;
 

+ 1 - 0
packages/app/src/server/routes/apiv3/pages.js

@@ -284,6 +284,7 @@ module.exports = (crowi) => {
       });
     }
     catch (err) {
+      logger.error('Error occurred while creating a page.', err);
       return res.apiv3Err(err);
     }
 

+ 1 - 1
packages/app/src/server/routes/apiv3/user-group.js

@@ -1,5 +1,6 @@
 import loggerFactory from '~/utils/logger';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import UserGroup from '~/server/models/user-group';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 
@@ -35,7 +36,6 @@ module.exports = (crowi) => {
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const {
-    UserGroup,
     UserGroupRelation,
     User,
     Page,

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

@@ -0,0 +1,333 @@
+import mongoose from 'mongoose';
+import { pagePathUtils, pathUtils } from '@growi/core';
+import escapeStringRegexp from 'escape-string-regexp';
+
+import UserGroup from '~/server/models/user-group';
+import { PageModel } from '~/server/models/page';
+import { PageQueryBuilder } from '../models/obsolete-page';
+import { isIncludesObjectId, removeDuplicates, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+
+const { addTrailingSlash } = pathUtils;
+const { isTopPage } = pagePathUtils;
+
+type ObjectId = mongoose.Types.ObjectId;
+
+type ComparableTarget = {
+  grant: number,
+  grantedUserIds?: ObjectId[],
+  grantedGroupId: ObjectId,
+  applicableUserIds?: ObjectId[],
+  applicableGroupIds?: ObjectId[],
+};
+
+type ComparableAncestor = {
+  grant: number,
+  grantedUserIds: ObjectId[],
+  applicableUserIds?: ObjectId[],
+  applicableGroupIds?: ObjectId[],
+};
+
+type ComparableDescendants = {
+  isPublicExist: boolean,
+  grantedUserIds: ObjectId[],
+  grantedGroupIds: ObjectId[],
+};
+
+class PageGrantService {
+
+  crowi!: any;
+
+  constructor(crowi: any) {
+    this.crowi = crowi;
+  }
+
+  private validateComparableTarget(comparable: ComparableTarget) {
+    const Page = mongoose.model('Page') as PageModel;
+
+    const { grant, grantedUserIds, grantedGroupId } = comparable;
+
+    if (grant === Page.GRANT_OWNER && (grantedUserIds == null || grantedUserIds.length !== 1)) {
+      throw Error('grantedUserIds must not be null and must have 1 length');
+    }
+    if (grant === Page.GRANT_USER_GROUP && grantedGroupId == null) {
+      throw Error('grantedGroupId is not specified');
+    }
+  }
+
+  /**
+   * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
+   * @returns boolean
+   */
+  private processValidation(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
+    this.validateComparableTarget(target);
+
+    const Page = mongoose.model('Page') as PageModel;
+
+    /*
+     * ancestor side
+     */
+    // GRANT_PUBLIC
+    if (ancestor.grant === Page.GRANT_PUBLIC) { // any page can exist under public page
+      // do nothing
+    }
+    // GRANT_OWNER
+    else if (ancestor.grant === Page.GRANT_OWNER) {
+      if (target.grantedUserIds?.length !== 1) {
+        throw Error('grantedUserIds must have one user');
+      }
+
+      if (target.grant !== Page.GRANT_OWNER) { // only GRANT_OWNER page can exist under GRANT_OWNER page
+        return false;
+      }
+
+      if (!ancestor.grantedUserIds[0].equals(target.grantedUserIds[0])) { // the grantedUser must be the same as parent's under the GRANT_OWNER page
+        return false;
+      }
+    }
+    // GRANT_USER_GROUP
+    else if (ancestor.grant === Page.GRANT_USER_GROUP) {
+      if (ancestor.applicableGroupIds == null || ancestor.applicableUserIds == null) {
+        throw Error('applicableGroupIds and applicableUserIds are not specified');
+      }
+
+      if (target.grant === Page.GRANT_PUBLIC) { // public page must not exist under GRANT_USER_GROUP page
+        return false;
+      }
+
+      if (target.grant === Page.GRANT_OWNER) {
+        if (target.grantedUserIds?.length !== 1) {
+          throw Error('grantedUserIds must have one user');
+        }
+
+        if (!isIncludesObjectId(ancestor.applicableUserIds, target.grantedUserIds[0])) { // GRANT_OWNER pages under GRAND_USER_GROUP page must be owned by the member of the grantedGroup of the GRAND_USER_GROUP page
+          return false;
+        }
+      }
+
+      if (target.grant === Page.GRANT_USER_GROUP) {
+        if (!isIncludesObjectId(ancestor.applicableGroupIds, target.grantedGroupId)) { // only child groups or the same group can exist under GRANT_USER_GROUP page
+          return false;
+        }
+      }
+    }
+
+    if (descendants == null) {
+      return true;
+    }
+    /*
+     * descendant side
+     */
+
+    // GRANT_PUBLIC
+    if (target.grant === Page.GRANT_PUBLIC) { // any page can exist under public page
+      // do nothing
+    }
+    // GRANT_OWNER
+    else if (target.grant === Page.GRANT_OWNER) {
+      if (target.grantedUserIds?.length !== 1) {
+        throw Error('grantedUserIds must have one user');
+      }
+
+      if (descendants.isPublicExist) { // public page must not exist under GRANT_OWNER page
+        return false;
+      }
+
+      if (descendants.grantedGroupIds.length !== 0 || descendants.grantedUserIds.length > 1) { // groups or more than 2 grantedUsers must not be in descendants
+        return false;
+      }
+
+      if (descendants.grantedUserIds.length === 1 && !descendants.grantedUserIds[0].equals(target.grantedUserIds[0])) { // if Only me page exists, then all of them must be owned by the same user as the target page
+        return false;
+      }
+    }
+    // GRANT_USER_GROUP
+    else if (target.grant === Page.GRANT_USER_GROUP) {
+      if (target.applicableGroupIds == null || target.applicableUserIds == null) {
+        throw Error('applicableGroupIds and applicableUserIds must not be null');
+      }
+
+      if (descendants.isPublicExist) { // public page must not exist under GRANT_USER_GROUP page
+        return false;
+      }
+
+      const shouldNotExistGroupIds = excludeTestIdsFromTargetIds(descendants.grantedGroupIds, target.applicableGroupIds);
+      const shouldNotExistUserIds = excludeTestIdsFromTargetIds(descendants.grantedUserIds, target.applicableUserIds);
+      if (shouldNotExistGroupIds.length !== 0 || shouldNotExistUserIds.length !== 0) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  /**
+   * Prepare ComparableTarget
+   * @returns Promise<ComparableAncestor>
+   */
+  private async generateComparableTarget(
+      grant, grantedUserIds: ObjectId[] | undefined, grantedGroupId: ObjectId, includeApplicable: boolean,
+  ): Promise<ComparableTarget> {
+    if (includeApplicable) {
+      const Page = mongoose.model('Page') as PageModel;
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+
+      let applicableUserIds: ObjectId[] | undefined;
+      let applicableGroupIds: ObjectId[] | undefined;
+
+      if (grant === Page.GRANT_USER_GROUP) {
+        const targetUserGroup = await UserGroup.findOne({ _id: grantedGroupId });
+        if (targetUserGroup == null) {
+          throw Error('Target user group does not exist');
+        }
+
+        const relatedUsers = await UserGroupRelation.find({ relatedGroup: targetUserGroup._id });
+        applicableUserIds = relatedUsers.map(u => u.relatedUser);
+
+        const applicableGroups = grantedGroupId != null ? await UserGroup.findGroupsWithDescendantsById(grantedGroupId) : null;
+        applicableGroupIds = applicableGroups?.map(g => g._id) || null;
+      }
+
+      return {
+        grant,
+        grantedUserIds,
+        grantedGroupId,
+        applicableUserIds,
+        applicableGroupIds,
+      };
+    }
+
+    return {
+      grant,
+      grantedUserIds,
+      grantedGroupId,
+    };
+  }
+
+  /**
+   * Prepare ComparableAncestor
+   * @param targetPath string of the target path
+   * @returns Promise<ComparableAncestor>
+   */
+  private async generateComparableAncestor(targetPath: string): Promise<ComparableAncestor> {
+    const Page = mongoose.model('Page') as PageModel;
+    const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+
+    let applicableUserIds: ObjectId[] | undefined;
+    let applicableGroupIds: ObjectId[] | undefined;
+
+    /*
+     * make granted users list of ancestor's
+     */
+    const builderForAncestors = new PageQueryBuilder(Page.find(), false);
+    const ancestors = await builderForAncestors
+      .addConditionToListOnlyAncestors(targetPath)
+      .addConditionToSortPagesByDescPath()
+      .query
+      .exec();
+    const testAncestor = ancestors[0];
+    if (testAncestor == null) {
+      throw Error('testAncestor must exist');
+    }
+
+    if (testAncestor.grant === Page.GRANT_USER_GROUP) {
+      // make a set of all users
+      const grantedRelations = await UserGroupRelation.find({ relatedGroup: testAncestor.grantedGroup }, { _id: 0, relatedUser: 1 });
+      const grantedGroups = await UserGroup.findGroupsWithDescendantsById(testAncestor.grantedGroup);
+      applicableGroupIds = grantedGroups.map(g => g._id);
+      applicableUserIds = Array.from(new Set(grantedRelations.map(r => r.relatedUser))) as ObjectId[];
+    }
+
+    return {
+      grant: testAncestor.grant,
+      grantedUserIds: testAncestor.grantedUsers,
+      applicableUserIds,
+      applicableGroupIds,
+    };
+  }
+
+  /**
+   * Prepare ComparableDescendants
+   * @param targetPath string of the target path
+   * @returns ComparableDescendants
+   */
+  private async generateComparableDescendants(targetPath: string): Promise<ComparableDescendants> {
+    const Page = mongoose.model('Page') as PageModel;
+
+    /*
+     * make granted users list of descendant's
+     */
+    const pathWithTrailingSlash = addTrailingSlash(targetPath);
+    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+
+    const result = await Page.aggregate([
+      { // match to descendants excluding empty pages
+        $match: {
+          path: new RegExp(`^${startsPattern}`),
+          isEmpty: { $ne: true },
+        },
+      },
+      {
+        $project: {
+          _id: 0,
+          grant: 1,
+          grantedUsers: 1,
+          grantedGroup: 1,
+        },
+      },
+      { // remove duplicates from pipeline
+        $group: {
+          _id: '$grant',
+          grantedGroupSet: { $addToSet: '$grantedGroup' },
+          grantedUsersSet: { $addToSet: '$grantedUsers' },
+        },
+      },
+      { // flatten granted user set
+        $unwind: {
+          path: '$grantedUsersSet',
+        },
+      },
+    ]);
+
+    // GRANT_PUBLIC group
+    const isPublicExist = result.some(r => r._id === Page.GRANT_PUBLIC);
+    // GRANT_OWNER group
+    const grantOwnerResult = result.filter(r => r._id === Page.GRANT_OWNER)[0]; // users of GRANT_OWNER
+    const grantedUserIds: ObjectId[] = grantOwnerResult?.grantedUsersSet ?? [];
+    // GRANT_USER_GROUP group
+    const grantUserGroupResult = result.filter(r => r._id === Page.GRANT_USER_GROUP)[0]; // users of GRANT_OWNER
+    const grantedGroupIds = grantUserGroupResult?.grantedGroupSet ?? [];
+
+    return {
+      isPublicExist,
+      grantedUserIds,
+      grantedGroupIds,
+    };
+  }
+
+  /**
+   * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
+   * @returns Promise<boolean>
+   */
+  async isGrantNormalized(
+      targetPath: string, grant, grantedUserIds: ObjectId[] | undefined, grantedGroupId: ObjectId, shouldCheckDescendants = false,
+  ): Promise<boolean> {
+    if (isTopPage(targetPath)) {
+      return true;
+    }
+
+    const comparableAncestor = await this.generateComparableAncestor(targetPath);
+
+    if (!shouldCheckDescendants) { // checking the parent is enough
+      const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, false);
+      return this.processValidation(comparableTarget, comparableAncestor);
+    }
+
+    const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, true);
+    const comparableDescendants = await this.generateComparableDescendants(targetPath);
+
+    return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
+  }
+
+}
+
+export default PageGrantService;

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

@@ -369,7 +369,7 @@ class PageService {
     const options = { page };
     options.grant = page.grant;
     options.grantUserGroupId = page.grantedGroup;
-    options.grantedUsers = page.grantedUsers;
+    options.grantedUserIds = page.grantedUsers;
 
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 

+ 3 - 3
packages/app/src/server/service/user-group.ts

@@ -1,8 +1,8 @@
 import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
-import UserGroup from '~/server/models/user-group';
-import { compareObjectId, isIncludesObjectId } from '~/server/util/compare-objectId';
+import UserGroup, { UserGroupDocument } from '~/server/models/user-group';
+import { isIncludesObjectId } from '~/server/util/compare-objectId';
 
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 
@@ -61,7 +61,7 @@ class UserGroupService {
 
     // throw if parent was in its descendants
     const descendantsWithTarget = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
-    const descendants = descendantsWithTarget.filter(d => compareObjectId(d._id, userGroup._id));
+    const descendants = descendantsWithTarget.filter(d => d._id.equals(userGroup._id));
     if (isIncludesObjectId(descendants, parent._id)) {
       throw Error('It is not allowed to choose parent from descendant groups.');
     }

+ 18 - 8
packages/app/src/server/util/compare-objectId.ts

@@ -3,11 +3,7 @@ import mongoose from 'mongoose';
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Types.ObjectId;
 
-export const compareObjectId = (id1: IObjectId, id2: IObjectId): boolean => {
-  return id1.toString() === id2.toString();
-};
-
-export const isIncludesObjectId = (arr: IObjectId[], id: IObjectId): boolean => {
+export const isIncludesObjectId = (arr: (IObjectId | string)[], id: IObjectId | string): boolean => {
   const _arr = arr.map(i => i.toString());
   const _id = id.toString();
 
@@ -20,14 +16,28 @@ export const isIncludesObjectId = (arr: IObjectId[], id: IObjectId): boolean =>
  * @param testIds Array of mongoose.Types.ObjectId
  * @returns Array of mongoose.Types.ObjectId
  */
-export const excludeTestIdsFromTargetIds = (targetIds: IObjectId[], testIds: IObjectId[]): IObjectId[] => {
+export const excludeTestIdsFromTargetIds = <T extends { toString: any } = IObjectId>(
+  targetIds: T[], testIds: (IObjectId | string)[],
+): T[] => {
   // cast to string
   const arr1 = targetIds.map(e => e.toString());
   const arr2 = testIds.map(e => e.toString());
 
   // filter
-  const excluded = arr2.filter(e => !arr1.includes(e));
+  const excluded = arr1.filter(e => !arr2.includes(e));
+  // cast to ObjectId
+  const shouldReturnString = (arr: any[]): arr is string[] => {
+    return typeof arr[0] === 'string';
+  };
+
+  return shouldReturnString(targetIds) ? excluded : excluded.map(e => new ObjectId(e));
+};
+
+export const removeDuplicates = (objectIds: (IObjectId | string)[]): IObjectId[] => {
+  // cast to string
+  const strs = objectIds.map(id => id.toString());
+  const uniqueArr = Array.from(new Set(strs));
 
   // cast to ObjectId
-  return excluded.map(e => new ObjectId(e));
+  return uniqueArr.map(str => new ObjectId(str));
 };

+ 369 - 0
packages/app/src/test/integration/service/page-grant.test.js

@@ -0,0 +1,369 @@
+import mongoose from 'mongoose';
+
+import { getInstance } from '../setup-crowi';
+import UserGroup from '~/server/models/user-group';
+
+/*
+ * There are 3 grant types to test.
+ * GRANT_PUBLIC, GRANT_OWNER, GRANT_USER_GROUP
+ */
+describe('PageGrantService', () => {
+  /*
+   * models
+   */
+  let User;
+  let Page;
+  let UserGroupRelation;
+
+  /*
+   * global instances
+   */
+  let crowi;
+  let pageGrantService;
+  let xssSpy;
+
+  let user1;
+  let user2;
+
+  let groupParent;
+  let groupChild;
+
+  let rootPage;
+
+  let emptyPage1;
+  let emptyPage2;
+  let emptyPage3;
+  const emptyPagePath1 = '/E1';
+  const emptyPagePath2 = '/E2';
+  const emptyPagePath3 = '/E3';
+
+  let pageRootPublic;
+  let pageRootGroupParent;
+  const pageRootPublicPath = '/Public';
+  const pageRootGroupParentPath = '/GroupParent';
+
+  let pageE1Public;
+  let pageE2User1;
+  let pageE3GroupParent;
+  let pageE3GroupChild;
+  let pageE3User1;
+  const pageE1PublicPath = '/E1/Public';
+  const pageE2User1Path = '/E2/User1';
+  const pageE3GroupParentPath = '/E3/GroupParent';
+  const pageE3GroupChildPath = '/E3/GroupChild';
+  const pageE3User1Path = '/E3/User1';
+
+  /*
+   * prepare before all tests
+   */
+  beforeAll(async() => {
+    crowi = await getInstance();
+
+    pageGrantService = crowi.pageGrantService;
+
+    User = mongoose.model('User');
+    Page = mongoose.model('Page');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+
+    // Users
+    await User.insertMany([
+      { name: 'User1', username: 'User1', email: 'user1@example.com' },
+      { name: 'User2', username: 'User2', email: 'user2@example.com' },
+    ]);
+
+    user1 = await User.findOne({ username: 'User1' });
+    user2 = await User.findOne({ username: 'User2' });
+
+    // Parent user groups
+    await UserGroup.insertMany([
+      {
+        name: 'GroupParent',
+        parent: null,
+      },
+    ]);
+    groupParent = await UserGroup.findOne({ name: 'GroupParent' });
+
+    // Child user groups
+    await UserGroup.insertMany([
+      {
+        name: 'GroupChild',
+        parent: groupParent._id,
+      },
+    ]);
+    groupChild = await UserGroup.findOne({ name: 'GroupChild' });
+
+    // UserGroupRelations
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: groupParent._id,
+        relatedUser: user1._id,
+      },
+      {
+        relatedGroup: groupParent._id,
+        relatedUser: user2._id,
+      },
+      {
+        relatedGroup: groupChild._id,
+        relatedUser: user1._id,
+      },
+    ]);
+
+    // Root page (Depth: 0)
+    await Page.insertMany([
+      {
+        path: '/',
+        grant: Page.GRANT_PUBLIC,
+      },
+    ]);
+    rootPage = await Page.findOne({ path: '/' });
+
+    // Empty pages (Depth: 1)
+    await Page.insertMany([
+      {
+        path: emptyPagePath1,
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        parent: rootPage._id,
+      },
+      {
+        path: emptyPagePath2,
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        parent: rootPage._id,
+      },
+      {
+        path: emptyPagePath3,
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        parent: rootPage._id,
+      },
+      {
+        path: pageRootPublicPath,
+        grant: Page.GRANT_PUBLIC,
+        creator: user1,
+        lastUpdateUser: user1,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: pageRootGroupParentPath,
+        grant: Page.GRANT_USER_GROUP,
+        creator: user1,
+        lastUpdateUser: user1,
+        grantedUsers: null,
+        grantedGroup: groupParent._id,
+        parent: rootPage._id,
+      },
+    ]);
+
+    emptyPage1 = await Page.findOne({ path: emptyPagePath1 });
+    emptyPage2 = await Page.findOne({ path: emptyPagePath2 });
+    emptyPage3 = await Page.findOne({ path: emptyPagePath3 });
+
+    // Leaf pages (Depth: 2)
+    await Page.insertMany([
+      {
+        path: pageE1PublicPath,
+        grant: Page.GRANT_PUBLIC,
+        creator: user1,
+        lastUpdateUser: user1,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: emptyPage1._id,
+      },
+      {
+        path: pageE2User1Path,
+        grant: Page.GRANT_OWNER,
+        creator: user1,
+        lastUpdateUser: user1,
+        grantedUsers: [user1._id],
+        grantedGroup: null,
+        parent: emptyPage2._id,
+      },
+      {
+        path: pageE3GroupParentPath,
+        grant: Page.GRANT_USER_GROUP,
+        creator: user1,
+        lastUpdateUser: user1,
+        grantedUsers: null,
+        grantedGroup: groupParent._id,
+        parent: emptyPage3._id,
+      },
+      {
+        path: pageE3GroupChildPath,
+        grant: Page.GRANT_USER_GROUP,
+        creator: user1,
+        lastUpdateUser: user1,
+        grantedUsers: null,
+        grantedGroup: groupChild._id,
+        parent: emptyPage3._id,
+      },
+      {
+        path: pageE3User1Path,
+        grant: Page.GRANT_OWNER,
+        creator: user1,
+        lastUpdateUser: user1,
+        grantedUsers: [user1._id],
+        grantedGroup: null,
+        parent: emptyPage3._id,
+      },
+    ]);
+    pageE1Public = await Page.findOne({ path: pageE1PublicPath });
+    pageE2User1 = await Page.findOne({ path: pageE2User1Path });
+    pageE3GroupParent = await Page.findOne({ path: pageE3GroupParentPath });
+    pageE3GroupChild = await Page.findOne({ path: pageE3GroupChildPath });
+    pageE3User1 = await Page.findOne({ path: pageE3User1Path });
+
+    xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
+  });
+
+  describe('Test isGrantNormalized method with shouldCheckDescendants false', () => {
+    test('Should return true when Ancestor: root, Target: public', async() => {
+      const targetPath = '/NEW';
+      const grant = Page.GRANT_PUBLIC;
+      const grantedUserIds = null;
+      const grantedGroupId = null;
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(true);
+    });
+
+    test('Should return true when Ancestor: root, Target: GroupParent', async() => {
+      const targetPath = '/NEW_GroupParent';
+      const grant = Page.GRANT_USER_GROUP;
+      const grantedUserIds = null;
+      const grantedGroupId = groupParent._id;
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(true);
+    });
+
+    test('Should return true when Ancestor: under-root public, Target: public', async() => {
+      const targetPath = `${pageRootPublicPath}/NEW`;
+      const grant = Page.GRANT_PUBLIC;
+      const grantedUserIds = null;
+      const grantedGroupId = null;
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(true);
+    });
+
+    test('Should return true when Ancestor: under-root GroupParent, Target: GroupParent', async() => {
+      const targetPath = `${pageRootGroupParentPath}/NEW`;
+      const grant = Page.GRANT_USER_GROUP;
+      const grantedUserIds = null;
+      const grantedGroupId = groupParent._id;
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(true);
+    });
+
+    test('Should return true when Ancestor: public, Target: public', async() => {
+      const targetPath = `${pageE1PublicPath}/NEW`;
+      const grant = Page.GRANT_PUBLIC;
+      const grantedUserIds = null;
+      const grantedGroupId = null;
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(true);
+    });
+
+    test('Should return true when Ancestor: owned by User1, Target: owned by User1', async() => {
+      const targetPath = `${pageE2User1Path}/NEW`;
+      const grant = Page.GRANT_OWNER;
+      const grantedUserIds = [user1._id];
+      const grantedGroupId = null;
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(true);
+    });
+
+    test('Should return false when Ancestor: owned by GroupParent, Target: public', async() => {
+      const targetPath = `${pageE3GroupParentPath}/NEW`;
+      const grant = Page.GRANT_PUBLIC;
+      const grantedUserIds = null;
+      const grantedGroupId = null;
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(false);
+    });
+
+    test('Should return false when Ancestor: owned by GroupChild, Target: GroupParent', async() => {
+      const targetPath = `${pageE3GroupChildPath}/NEW`;
+      const grant = Page.GRANT_USER_GROUP;
+      const grantedUserIds = null;
+      const grantedGroupId = groupParent._id;
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(false);
+    });
+  });
+
+  describe('Test isGrantNormalized method with shouldCheckDescendants true', () => {
+    test('Should return true when Target: public, Descendant: public', async() => {
+      const targetPath = emptyPagePath1;
+      const grant = Page.GRANT_PUBLIC;
+      const grantedUserIds = null;
+      const grantedGroupId = null;
+      const shouldCheckDescendants = true;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(true);
+    });
+
+    test('Should return true when Target: owned by User1, Descendant: User1 only', async() => {
+      const targetPath = emptyPagePath2;
+      const grant = Page.GRANT_OWNER;
+      const grantedUserIds = [user1._id];
+      const grantedGroupId = null;
+      const shouldCheckDescendants = true;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(true);
+    });
+
+    test('Should return true when Target: owned by GroupParent, Descendant: GroupParent, GroupChild and User1', async() => {
+      const targetPath = emptyPagePath3;
+      const grant = Page.GRANT_USER_GROUP;
+      const grantedUserIds = null;
+      const grantedGroupId = groupParent._id;
+      const shouldCheckDescendants = true;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(true);
+    });
+
+    test('Should return false when Target: owned by UserA, Descendant: public', async() => {
+      const targetPath = emptyPagePath1;
+      const grant = Page.GRANT_OWNER;
+      const grantedUserIds = [user1._id];
+      const grantedGroupId = null;
+      const shouldCheckDescendants = true;
+
+      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+
+      expect(result).toBe(false);
+    });
+  });
+
+});