Forráskód Böngészése

Merge pull request #8381 from weseek/feat/136139-137890-page-duplicate-for-multiple-group-grant

Feat/136139 137890 page duplicate for multiple group grant
Futa Arai 2 éve
szülő
commit
2e9b4aad6e

+ 4 - 2
apps/app/public/static/locales/en_US/translation.json

@@ -390,10 +390,12 @@
       "Current page name": "Current page name",
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
       "Duplicate without exist path": "Duplicate without exist path",
-      "Same page already exists": "Same page already exists"
+      "Same page already exists": "Same page already exists",
+      "Only duplicate user related resources": "Only duplicate user related resources"
     },
     },
     "help": {
     "help": {
-      "recursive": "Duplicate children of under this path recursively"
+      "recursive": "Duplicate children of under this path recursively",
+      "only_user_related_resources": "This will only duplicate pages that the user has permission to view. If the page permission is set to \"Only specific groups\", only user related groups will be set to the page duplicate."
     }
     }
   },
   },
   "duplicated_pages": "{{fromPath}} has been duplicated",
   "duplicated_pages": "{{fromPath}} has been duplicated",

+ 4 - 2
apps/app/public/static/locales/ja_JP/translation.json

@@ -423,10 +423,12 @@
       "Current page name": "現在のページ名",
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に複製",
       "Recursively": "再帰的に複製",
       "Duplicate without exist path": "存在するパス以外を複製する",
       "Duplicate without exist path": "存在するパス以外を複製する",
-      "Same page already exists": "同じページがすでに存在します"
+      "Same page already exists": "同じページがすでに存在します",
+      "Only duplicate user related resources": "ユーザに関連のあるリソースのみを複製する"
     },
     },
     "help": {
     "help": {
-      "recursive": "配下のページも複製します"
+      "recursive": "配下のページも複製します",
+      "only_user_related_resources": "ユーザが閲覧可能なページのみを複製します。また、閲覧権限が「特定グループのみ」で設定されている場合、複製後のページにはユーザが所属するグループのみを閲覧可能なグループとして設定します。"
     }
     }
   },
   },
   "duplicated_pages": "{{fromPath}} を複製しました",
   "duplicated_pages": "{{fromPath}} を複製しました",

+ 4 - 2
apps/app/public/static/locales/zh_CN/translation.json

@@ -380,10 +380,12 @@
 			"Current page name": "Current page name",
 			"Current page name": "Current page name",
       "Recursively": "Recursively",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
       "Duplicate without exist path": "Duplicate without exist path",
-      "Same page already exists": "Same page already exists"
+      "Same page already exists": "Same page already exists",
+      "Only duplicate user related resources": "Only duplicate user related resources"
     },
     },
     "help": {
     "help": {
-      "recursive": "Duplicate children of under this path recursively"
+      "recursive": "Duplicate children of under this path recursively",
+      "only_user_related_resources": "This will only duplicate pages that the user has permission to view. If the page permission is set to \"Only specific groups\", only user related groups will be set to the page duplicate."
     }
     }
   },
   },
   "duplicated_pages": "{{fromPath}} 已重复",
   "duplicated_pages": "{{fromPath}} 已重复",

+ 27 - 9
apps/app/src/components/PageDuplicateModal.tsx

@@ -37,6 +37,7 @@ const PageDuplicateModal = (): JSX.Element => {
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
+  const [onlyDuplicateUserRelatedResources, setOnlyDuplicateUserRelatedResources] = useState(false);
 
 
   const updateSubordinatedList = useCallback(async() => {
   const updateSubordinatedList = useCallback(async() => {
     if (page == null) {
     if (page == null) {
@@ -114,7 +115,9 @@ const PageDuplicateModal = (): JSX.Element => {
 
 
     const { pageId, path } = page;
     const { pageId, path } = page;
     try {
     try {
-      const { data } = await apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
+      const { data } = await apiv3Post('/pages/duplicate', {
+        pageId, pageNameInput, isRecursively: isDuplicateRecursively, onlyDuplicateUserRelatedResources,
+      });
       const onDuplicated = duplicateModalData?.opts?.onDuplicated;
       const onDuplicated = duplicateModalData?.opts?.onDuplicated;
       const fromPath = path;
       const fromPath = path;
       const toPath = data.page.path;
       const toPath = data.page.path;
@@ -127,7 +130,7 @@ const PageDuplicateModal = (): JSX.Element => {
     catch (err) {
     catch (err) {
       setErrs(err);
       setErrs(err);
     }
     }
-  }, [closeDuplicateModal, duplicateModalData?.opts?.onDuplicated, isDuplicateRecursively, page, pageNameInput]);
+  }, [closeDuplicateModal, duplicateModalData?.opts?.onDuplicated, isDuplicateRecursively, page, pageNameInput, onlyDuplicateUserRelatedResources]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (isOpened) {
     if (isOpened) {
@@ -193,7 +196,7 @@ const PageDuplicateModal = (): JSX.Element => {
           <p className="text-danger">Error: Target path is duplicated.</p>
           <p className="text-danger">Error: Target path is duplicated.</p>
         ) }
         ) }
 
 
-        <div className="form-check form-check-warning mb-3">
+        <div className="form-check form-check-warning">
           <input
           <input
             className="form-check-input"
             className="form-check-input"
             name="recursively"
             name="recursively"
@@ -204,7 +207,7 @@ const PageDuplicateModal = (): JSX.Element => {
           />
           />
           <label className="form-label form-check-label" htmlFor="cbDuplicateRecursively">
           <label className="form-label form-check-label" htmlFor="cbDuplicateRecursively">
             { t('modal_duplicate.label.Recursively') }
             { t('modal_duplicate.label.Recursively') }
-            <p className="form-text text-muted mt-0">{ t('modal_duplicate.help.recursive') }</p>
+            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
           </label>
           </label>
 
 
           <div>
           <div>
@@ -220,15 +223,30 @@ const PageDuplicateModal = (): JSX.Element => {
                 />
                 />
                 <label className="form-label form-check-label" htmlFor="cbDuplicatewithoutExistRecursively">
                 <label className="form-label form-check-label" htmlFor="cbDuplicatewithoutExistRecursively">
                   { t('modal_duplicate.label.Duplicate without exist path') }
                   { t('modal_duplicate.label.Duplicate without exist path') }
+                  <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
                 </label>
                 </label>
               </div>
               </div>
             )}
             )}
           </div>
           </div>
-          <div>
-            {isDuplicateRecursively && existingPaths.length !== 0 && (
-              <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
-            ) }
-          </div>
+        </div>
+
+        <div className="form-check form-check-warning mb-3">
+          <input
+            className="form-check-input"
+            id="cbOnlyDuplicateUserRelatedResources"
+            type="checkbox"
+            checked={onlyDuplicateUserRelatedResources}
+            onChange={() => setOnlyDuplicateUserRelatedResources(!onlyDuplicateUserRelatedResources)}
+          />
+          <label className="form-label form-check-label" htmlFor="cbOnlyDuplicateUserRelatedResources">
+            { t('modal_duplicate.label.Only duplicate user related resources') }
+            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.only_user_related_resources') }</p>
+          </label>
+        </div>
+        <div>
+          {isDuplicateRecursively && existingPaths.length !== 0 && (
+            <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
+          ) }
         </div>
         </div>
       </>
       </>
     );
     );

+ 2 - 2
apps/app/src/pages/[[...path]].page.tsx

@@ -407,7 +407,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
 
 
   const Page = crowi.model('Page') as PageModel;
   const Page = crowi.model('Page') as PageModel;
   const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
   const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
-  const { pageService, configManager } = crowi;
+  const { pageService, configManager, pageGrantService } = crowi;
 
 
   let currentPathname = props.currentPathname;
   let currentPathname = props.currentPathname;
 
 
@@ -462,7 +462,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     if (ancestor != null) {
     if (ancestor != null) {
       ancestor.populate('grantedGroups.item');
       ancestor.populate('grantedGroups.item');
-      const userRelatedGrantedGroups = (await pageService.getUserRelatedGrantedGroups(ancestor, user)).map((group) => {
+      const userRelatedGrantedGroups = (await pageGrantService.getUserRelatedGrantedGroups(ancestor, user)).map((group) => {
         if (isPopulated(group.item)) {
         if (isPopulated(group.item)) {
           return {
           return {
             id: group.item._id,
             id: group.item._id,

+ 2 - 2
apps/app/src/server/routes/apiv3/page.js

@@ -478,7 +478,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
       return res.apiv3Err(err, 500);
     }
     }
 
 
-    const userRelatedGrantedGroups = await crowi.pageService.getUserRelatedGrantedGroups(page, req.user);
+    const userRelatedGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(page, req.user);
     const { grantedUserGroups, grantedExternalUserGroups } = divideByType(userRelatedGrantedGroups);
     const { grantedUserGroups, grantedExternalUserGroups } = divideByType(userRelatedGrantedGroups);
     const currentPageUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroups } });
     const currentPageUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroups } });
     const currentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroups } });
     const currentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroups } });
@@ -515,7 +515,7 @@ module.exports = (crowi) => {
       return res.apiv3({ isGrantNormalized, grantData });
       return res.apiv3({ isGrantNormalized, grantData });
     }
     }
 
 
-    const userRelatedParentGrantedGroups = await crowi.pageService.getUserRelatedGrantedGroups(parentPage, req.user);
+    const userRelatedParentGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(parentPage, req.user);
     const {
     const {
       grantedUserGroups: parentGrantedUserGroupIds,
       grantedUserGroups: parentGrantedUserGroupIds,
       grantedExternalUserGroups: parentGrantedExternalUserGroupIds,
       grantedExternalUserGroups: parentGrantedExternalUserGroupIds,

+ 2 - 2
apps/app/src/server/routes/apiv3/pages.js

@@ -797,7 +797,7 @@ module.exports = (crowi) => {
    */
    */
   router.post('/duplicate', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
   router.post('/duplicate', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
     async(req, res) => {
     async(req, res) => {
-      const { pageId, isRecursively } = req.body;
+      const { pageId, isRecursively, onlyDuplicateUserRelatedResources } = req.body;
 
 
       const newPagePath = normalizePath(req.body.pageNameInput);
       const newPagePath = normalizePath(req.body.pageNameInput);
 
 
@@ -833,7 +833,7 @@ module.exports = (crowi) => {
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
       }
       }
 
 
-      const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
+      const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively, onlyDuplicateUserRelatedResources);
       const result = { page: serializePageSecurely(newParentPage) };
       const result = { page: serializePageSecurely(newParentPage) };
 
 
       // copy the page since it's used and updated in crowi.pageService.duplicate
       // copy the page since it's used and updated in crowi.pageService.duplicate

+ 21 - 1
apps/app/src/server/service/page-grant.ts

@@ -93,9 +93,11 @@ export interface IPageGrantService {
     operator, updateGrant?: PageGrant, grantGroupIds?: IGrantedGroup[],
     operator, updateGrant?: PageGrant, grantGroupIds?: IGrantedGroup[],
   ) => Promise<UpdateGrantInfo>,
   ) => Promise<UpdateGrantInfo>,
   canOverwriteDescendants: (targetPath: string, operator: { _id: ObjectIdLike }, updateGrantInfo: UpdateGrantInfo) => Promise<boolean>,
   canOverwriteDescendants: (targetPath: string, operator: { _id: ObjectIdLike }, updateGrantInfo: UpdateGrantInfo) => Promise<boolean>,
-  validateGrantChange: (user, previousGrantedGroupIds: IGrantedGroup[], grant?: PageGrant, grantedGroupIds?: IGrantedGroup[]) => Promise<boolean>,
+  validateGrantChange: (user, previousGrantedGroupIds: IGrantedGroup[], grant?: PageGrant, grantedGroupIds?: IGrantedGroup[]) => Promise<boolean>
   getUserRelatedGroups: (user) => Promise<PopulatedGrantedGroup[]>,
   getUserRelatedGroups: (user) => Promise<PopulatedGrantedGroup[]>,
   filterGrantedGroupsByIds: (page: PageDocument, groupIds: string[]) => IGrantedGroup[],
   filterGrantedGroupsByIds: (page: PageDocument, groupIds: string[]) => IGrantedGroup[],
+  getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
+  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroupIds: string[]) => boolean
 }
 }
 
 
 class PageGrantService implements IPageGrantService {
 class PageGrantService implements IPageGrantService {
@@ -672,6 +674,24 @@ class PageGrantService implements IPageGrantService {
     }) || [];
     }) || [];
   }
   }
 
 
+  /*
+   * get all groups of Page that user is related to
+   */
+  async getUserRelatedGrantedGroups(page: PageDocument, user): Promise<IGrantedGroup[]> {
+    const userRelatedGroupIds: string[] = (await this.getUserRelatedGroups(user)).map(ug => ug.item._id.toString());
+    return this.filterGrantedGroupsByIds(page, userRelatedGroupIds);
+  }
+
+  /**
+   * Check if user is granted access to page
+   */
+  isUserGrantedPageAccess(page: PageDocument, user, userRelatedGroupIds: string[]): boolean {
+    if (page.grant === PageGrant.GRANT_PUBLIC) return true;
+    if (page.grant === PageGrant.GRANT_OWNER) return page.grantedUsers?.includes(user._id.toString()) ?? false;
+    if (page.grant === PageGrant.GRANT_USER_GROUP) return this.filterGrantedGroupsByIds(page, userRelatedGroupIds).length > 0;
+    return false;
+  }
+
   /**
   /**
    * see: https://dev.growi.org/635a314eac6bcd85cbf359fc
    * see: https://dev.growi.org/635a314eac6bcd85cbf359fc
    * @param {string} targetPath
    * @param {string} targetPath

+ 44 - 42
apps/app/src/server/service/page/index.ts

@@ -4,7 +4,7 @@ import { Readable, Writable } from 'stream';
 
 
 import type {
 import type {
   Ref, HasObjectId, IUserHasId, IUser,
   Ref, HasObjectId, IUserHasId, IUser,
-  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
+  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup, IRevisionHasId,
 } from '@growi/core';
 } from '@growi/core';
 import {
 import {
   PageGrant, PageStatus, getIdForRef, isPopulated,
   PageGrant, PageStatus, getIdForRef, isPopulated,
@@ -1056,7 +1056,7 @@ class PageService implements IPageService {
   /*
   /*
    * Duplicate
    * Duplicate
    */
    */
-  async duplicate(page, newPagePath, user, isRecursively) {
+  async duplicate(page: PageDocument, newPagePath: string, user, isRecursively: boolean, onlyDuplicateUserRelatedResources: boolean) {
     /*
     /*
      * Common Operation
      * Common Operation
      */
      */
@@ -1077,7 +1077,7 @@ class PageService implements IPageService {
     // 1. Separate v4 & v5 process
     // 1. Separate v4 & v5 process
     const isShouldUseV4Process = shouldUseV4Process(page);
     const isShouldUseV4Process = shouldUseV4Process(page);
     if (isShouldUseV4Process) {
     if (isShouldUseV4Process) {
-      return this.duplicateV4(page, newPagePath, user, isRecursively);
+      return this.duplicateV4(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources);
     }
     }
 
 
     const canOperate = await this.crowi.pageOperationService.canOperate(isRecursively, page.path, newPagePath);
     const canOperate = await this.crowi.pageOperationService.canOperate(isRecursively, page.path, newPagePath);
@@ -1087,9 +1087,10 @@ class PageService implements IPageService {
 
 
     // 2. UserGroup & Owner validation
     // 2. UserGroup & Owner validation
     // use the parent's grant when target page is an empty page
     // use the parent's grant when target page is an empty page
-    let grant;
+    let grant: PageGrant;
     let grantedUserIds;
     let grantedUserIds;
-    let grantedGroupIds;
+    let grantedGroupIds: IGrantedGroup[];
+
     if (page.isEmpty) {
     if (page.isEmpty) {
       const parent = await Page.findOne({ _id: page.parent });
       const parent = await Page.findOne({ _id: page.parent });
       if (parent == null) {
       if (parent == null) {
@@ -1097,12 +1098,12 @@ class PageService implements IPageService {
       }
       }
       grant = parent.grant;
       grant = parent.grant;
       grantedUserIds = parent.grantedUsers;
       grantedUserIds = parent.grantedUsers;
-      grantedGroupIds = parent.grantedGroups;
+      grantedGroupIds = onlyDuplicateUserRelatedResources ? (await this.pageGrantService.getUserRelatedGrantedGroups(parent, user)) : parent.grantedGroups;
     }
     }
     else {
     else {
       grant = page.grant;
       grant = page.grant;
       grantedUserIds = page.grantedUsers;
       grantedUserIds = page.grantedUsers;
-      grantedGroupIds = page.grantedGroups;
+      grantedGroupIds = onlyDuplicateUserRelatedResources ? (await this.pageGrantService.getUserRelatedGrantedGroups(page, user)) : page.grantedGroups;
     }
     }
 
 
     if (grant !== Page.GRANT_RESTRICTED) {
     if (grant !== Page.GRANT_RESTRICTED) {
@@ -1124,8 +1125,8 @@ class PageService implements IPageService {
 
 
     // 3. Duplicate target
     // 3. Duplicate target
     const options: PageCreateOptions = {
     const options: PageCreateOptions = {
-      grant: page.grant,
-      grantUserGroupIds: page.grantedGroups,
+      grant,
+      grantUserGroupIds: grantedGroupIds,
     };
     };
     let duplicatedTarget;
     let duplicatedTarget;
     if (page.isEmpty) {
     if (page.isEmpty) {
@@ -1133,9 +1134,9 @@ class PageService implements IPageService {
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
     }
     }
     else {
     else {
-      await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
+      const populatedPage = await page.populate<{revision: IRevisionHasId | null}>({ path: 'revision', model: 'Revision', select: 'body' });
       duplicatedTarget = await (this.create as CreateMethod)(
       duplicatedTarget = await (this.create as CreateMethod)(
-        newPagePath, page.revision.body, user, options,
+        newPagePath, populatedPage?.revision?.body ?? '', user, options,
       );
       );
     }
     }
     this.pageEvent.emit('duplicate', page, user);
     this.pageEvent.emit('duplicate', page, user);
@@ -1171,7 +1172,7 @@ class PageService implements IPageService {
 
 
       (async() => {
       (async() => {
         try {
         try {
-          await this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id);
+          await this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id, onlyDuplicateUserRelatedResources);
         }
         }
         catch (err) {
         catch (err) {
           logger.error('Error occurred while running duplicateRecursivelyMainOperation.', err);
           logger.error('Error occurred while running duplicateRecursivelyMainOperation.', err);
@@ -1189,8 +1190,14 @@ class PageService implements IPageService {
     return result;
     return result;
   }
   }
 
 
-  async duplicateRecursivelyMainOperation(page, newPagePath: string, user, pageOpId: ObjectIdLike): Promise<void> {
-    const nDuplicatedPages = await this.duplicateDescendantsWithStream(page, newPagePath, user, false);
+  async duplicateRecursivelyMainOperation(
+      page: PageDocument,
+      newPagePath: string,
+      user,
+      pageOpId: ObjectIdLike,
+      onlyDuplicateUserRelatedResources: boolean,
+  ): Promise<void> {
+    const nDuplicatedPages = await this.duplicateDescendantsWithStream(page, newPagePath, user, onlyDuplicateUserRelatedResources, false);
 
 
     // normalize parent of descendant pages
     // normalize parent of descendant pages
     const shouldNormalize = this.shouldNormalizeParent(page);
     const shouldNormalize = this.shouldNormalizeParent(page);
@@ -1229,7 +1236,7 @@ class PageService implements IPageService {
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
   }
   }
 
 
-  async duplicateV4(page, newPagePath, user, isRecursively) {
+  async duplicateV4(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources: boolean) {
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     // populate
     // populate
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
@@ -1248,7 +1255,7 @@ class PageService implements IPageService {
     this.pageEvent.emit('duplicate', page, user);
     this.pageEvent.emit('duplicate', page, user);
 
 
     if (isRecursively) {
     if (isRecursively) {
-      this.duplicateDescendantsWithStream(page, newPagePath, user);
+      this.duplicateDescendantsWithStream(page, newPagePath, user, onlyDuplicateUserRelatedResources);
     }
     }
 
 
     // take over tags
     // take over tags
@@ -1302,7 +1309,10 @@ class PageService implements IPageService {
     return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
     return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
   }
   }
 
 
-  private async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix, shouldUseV4Process = true) {
+  private async duplicateDescendants(
+      pages, user, oldPagePathPrefix, newPagePathPrefix,
+      onlyDuplicateUserRelatedResources: boolean, shouldUseV4Process = true,
+  ) {
     if (shouldUseV4Process) {
     if (shouldUseV4Process) {
       return this.duplicateDescendantsV4(pages, user, oldPagePathPrefix, newPagePathPrefix);
       return this.duplicateDescendantsV4(pages, user, oldPagePathPrefix, newPagePathPrefix);
     }
     }
@@ -1324,6 +1334,8 @@ class PageService implements IPageService {
     const newPages: any[] = [];
     const newPages: any[] = [];
     const newRevisions: any[] = [];
     const newRevisions: any[] = [];
 
 
+    const userRelatedGroupIds = (await this.pageGrantService.getUserRelatedGroups(user)).map(ug => ug.item._id.toString());
+
     // no need to save parent here
     // no need to save parent here
     pages.forEach((page) => {
     pages.forEach((page) => {
       const newPageId = new mongoose.Types.ObjectId();
       const newPageId = new mongoose.Types.ObjectId();
@@ -1331,14 +1343,20 @@ class PageService implements IPageService {
       const revisionId = new mongoose.Types.ObjectId();
       const revisionId = new mongoose.Types.ObjectId();
       pageIdMapping[page._id] = newPageId;
       pageIdMapping[page._id] = newPageId;
 
 
+      const isDuplicateTarget = !page.isEmpty
+      && (!onlyDuplicateUserRelatedResources || this.pageGrantService.isUserGrantedPageAccess(page, user, userRelatedGroupIds));
+
       let newPage;
       let newPage;
-      if (!page.isEmpty) {
+      if (isDuplicateTarget) {
+        const grantedGroups = onlyDuplicateUserRelatedResources
+          ? this.pageGrantService.filterGrantedGroupsByIds(page, userRelatedGroupIds)
+          : page.grantedGroups;
         newPage = {
         newPage = {
           _id: newPageId,
           _id: newPageId,
           path: newPagePath,
           path: newPagePath,
           creator: user._id,
           creator: user._id,
           grant: page.grant,
           grant: page.grant,
-          grantedGroups: page.grantedGroups,
+          grantedGroups,
           grantedUsers: page.grantedUsers,
           grantedUsers: page.grantedUsers,
           lastUpdateUser: user._id,
           lastUpdateUser: user._id,
           revision: revisionId,
           revision: revisionId,
@@ -1401,9 +1419,9 @@ class PageService implements IPageService {
     await this.duplicateTags(pageIdMapping);
     await this.duplicateTags(pageIdMapping);
   }
   }
 
 
-  private async duplicateDescendantsWithStream(page, newPagePath, user, shouldUseV4Process = true) {
+  private async duplicateDescendantsWithStream(page, newPagePath, user, onlyDuplicateUserRelatedResources: boolean, shouldUseV4Process = true) {
     if (shouldUseV4Process) {
     if (shouldUseV4Process) {
-      return this.duplicateDescendantsWithStreamV4(page, newPagePath, user);
+      return this.duplicateDescendantsWithStreamV4(page, newPagePath, user, onlyDuplicateUserRelatedResources);
     }
     }
 
 
     const iterableFactory = new PageCursorsForDescendantsFactory(user, page, true);
     const iterableFactory = new PageCursorsForDescendantsFactory(user, page, true);
@@ -1422,7 +1440,7 @@ class PageService implements IPageService {
         try {
         try {
           count += batch.length;
           count += batch.length;
           nNonEmptyDuplicatedPages += batch.filter(page => !page.isEmpty).length;
           nNonEmptyDuplicatedPages += batch.filter(page => !page.isEmpty).length;
-          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, shouldUseV4Process);
+          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, onlyDuplicateUserRelatedResources, shouldUseV4Process);
           logger.debug(`Adding pages progressing: (count=${count})`);
           logger.debug(`Adding pages progressing: (count=${count})`);
         }
         }
         catch (err) {
         catch (err) {
@@ -1449,7 +1467,7 @@ class PageService implements IPageService {
     return nNonEmptyDuplicatedPages;
     return nNonEmptyDuplicatedPages;
   }
   }
 
 
-  private async duplicateDescendantsWithStreamV4(page, newPagePath, user) {
+  private async duplicateDescendantsWithStreamV4(page, newPagePath, user, onlyDuplicateUserRelatedResources: boolean) {
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
 
 
     const newPagePathPrefix = newPagePath;
     const newPagePathPrefix = newPagePath;
@@ -1463,7 +1481,7 @@ class PageService implements IPageService {
       async write(batch, encoding, callback) {
       async write(batch, encoding, callback) {
         try {
         try {
           count += batch.length;
           count += batch.length;
-          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix);
+          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, onlyDuplicateUserRelatedResources);
           logger.debug(`Adding pages progressing: (count=${count})`);
           logger.debug(`Adding pages progressing: (count=${count})`);
         }
         }
         catch (err) {
         catch (err) {
@@ -2309,22 +2327,6 @@ class PageService implements IPageService {
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
   }
   }
 
 
-  /*
-   * get all groups of Page that user is related to
-   */
-  async getUserRelatedGrantedGroups(page: PageDocument, user): Promise<IGrantedGroup[]> {
-    const userRelatedGroupIds: string[] = [
-      ...(await UserGroupRelation.findAllGroupsForUser(user)).map(ugr => ugr._id.toString()),
-      ...(await ExternalUserGroupRelation.findAllGroupsForUser(user)).map(eugr => eugr._id.toString()),
-    ];
-    return page.grantedGroups?.filter((group) => {
-      if (isPopulated(group.item)) {
-        return userRelatedGroupIds.includes(group.item._id.toString());
-      }
-      return userRelatedGroupIds.includes(group.item);
-    }) || [];
-  }
-
   private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
   private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
     const PageTagRelation = this.crowi.model('PageTagRelation');
@@ -2375,7 +2377,7 @@ class PageService implements IPageService {
     await batchProcessPromiseAll(childPages, 20, async(childPage: any) => {
     await batchProcessPromiseAll(childPages, 20, async(childPage: any) => {
       let newChildGrantedGroups: IGrantedGroup[] = [];
       let newChildGrantedGroups: IGrantedGroup[] = [];
       if (grant === PageGrant.GRANT_USER_GROUP) {
       if (grant === PageGrant.GRANT_USER_GROUP) {
-        const userRelatedParentGrantedGroups = await this.getUserRelatedGrantedGroups(parentPage, user);
+        const userRelatedParentGrantedGroups = await this.pageGrantService.getUserRelatedGrantedGroups(parentPage, user);
         newChildGrantedGroups = await this.getNewGrantedGroups(userRelatedParentGrantedGroups, childPage, user);
         newChildGrantedGroups = await this.getNewGrantedGroups(userRelatedParentGrantedGroups, childPage, user);
       }
       }
       const canChangeGrant = await this.pageGrantService
       const canChangeGrant = await this.pageGrantService
@@ -4054,7 +4056,7 @@ class PageService implements IPageService {
    */
    */
   async getNewGrantedGroups(userRelatedGrantedGroups: IGrantedGroup[], page: PageDocument, user): Promise<IGrantedGroup[]> {
   async getNewGrantedGroups(userRelatedGrantedGroups: IGrantedGroup[], page: PageDocument, user): Promise<IGrantedGroup[]> {
     const previousGrantedGroups = page.grantedGroups;
     const previousGrantedGroups = page.grantedGroups;
-    const userRelatedPreviousGrantedGroups = (await this.getUserRelatedGrantedGroups(page, user)).map(g => getIdForRef(g.item));
+    const userRelatedPreviousGrantedGroups = (await this.pageGrantService.getUserRelatedGrantedGroups(page, user)).map(g => getIdForRef(g.item));
     const userUnrelatedPreviousGrantedGroups = previousGrantedGroups.filter(g => !userRelatedPreviousGrantedGroups.includes(getIdForRef(g.item)));
     const userUnrelatedPreviousGrantedGroups = previousGrantedGroups.filter(g => !userRelatedPreviousGrantedGroups.includes(getIdForRef(g.item)));
     return [...userUnrelatedPreviousGrantedGroups, ...userRelatedGrantedGroups];
     return [...userUnrelatedPreviousGrantedGroups, ...userRelatedGrantedGroups];
   }
   }

+ 114 - 9
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -57,6 +57,9 @@ describe('PageService page operations with non-public pages', () => {
   const pageIdDuplicate4 = new mongoose.Types.ObjectId();
   const pageIdDuplicate4 = new mongoose.Types.ObjectId();
   const pageIdDuplicate5 = new mongoose.Types.ObjectId();
   const pageIdDuplicate5 = new mongoose.Types.ObjectId();
   const pageIdDuplicate6 = new mongoose.Types.ObjectId();
   const pageIdDuplicate6 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate7 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate8 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate9 = new mongoose.Types.ObjectId();
   // revision id
   // revision id
   const revisionIdDuplicate1 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate1 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate2 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate2 = new mongoose.Types.ObjectId();
@@ -64,6 +67,9 @@ describe('PageService page operations with non-public pages', () => {
   const revisionIdDuplicate4 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate4 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate5 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate5 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate6 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate6 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate7 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate8 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate9 = new mongoose.Types.ObjectId();
 
 
   /**
   /**
    * Revert
    * Revert
@@ -507,7 +513,10 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDuplicate2,
         _id: pageIdDuplicate2,
         path: '/np_duplicate2',
         path: '/np_duplicate2',
         grant: Page.GRANT_USER_GROUP,
         grant: Page.GRANT_USER_GROUP,
-        grantedGroups: [{ item: groupIdA, type: GroupType.userGroup }, { item: externalGroupIdA, type: GroupType.externalUserGroup }],
+        grantedGroups: [
+          { item: groupIdA, type: GroupType.userGroup },
+          { item: externalGroupIdA, type: GroupType.externalUserGroup },
+        ],
         creator: npDummyUser1._id,
         creator: npDummyUser1._id,
         lastUpdateUser: npDummyUser1._id,
         lastUpdateUser: npDummyUser1._id,
         revision: revisionIdDuplicate2,
         revision: revisionIdDuplicate2,
@@ -517,7 +526,10 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDuplicate3,
         _id: pageIdDuplicate3,
         path: '/np_duplicate2/np_duplicate3',
         path: '/np_duplicate2/np_duplicate3',
         grant: Page.GRANT_USER_GROUP,
         grant: Page.GRANT_USER_GROUP,
-        grantedGroups: [{ item: groupIdB, type: GroupType.userGroup }, { item: externalGroupIdB, type: GroupType.externalUserGroup }],
+        grantedGroups: [
+          { item: groupIdB, type: GroupType.userGroup },
+          { item: externalGroupIdB, type: GroupType.externalUserGroup },
+        ],
         creator: npDummyUser2._id,
         creator: npDummyUser2._id,
         lastUpdateUser: npDummyUser2._id,
         lastUpdateUser: npDummyUser2._id,
         revision: revisionIdDuplicate3,
         revision: revisionIdDuplicate3,
@@ -549,6 +561,44 @@ describe('PageService page operations with non-public pages', () => {
         parent: pageIdDuplicate4,
         parent: pageIdDuplicate4,
         revision: revisionIdDuplicate6,
         revision: revisionIdDuplicate6,
       },
       },
+      {
+        _id: pageIdDuplicate7,
+        path: '/np_duplicate7',
+        grant: Page.GRANT_USER_GROUP,
+        creator: npDummyUser1._id,
+        lastUpdateUser: npDummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdDuplicate7,
+        grantedGroups: [
+          { item: groupIdA, type: GroupType.userGroup },
+          { item: externalGroupIdA, type: GroupType.externalUserGroup },
+          { item: groupIdB, type: GroupType.userGroup },
+          { item: externalGroupIdB, type: GroupType.externalUserGroup },
+        ],
+      },
+      {
+        _id: pageIdDuplicate8,
+        path: '/np_duplicate7/np_duplicate8',
+        grant: Page.GRANT_USER_GROUP,
+        creator: npDummyUser3._id,
+        lastUpdateUser: npDummyUser3._id,
+        parent: pageIdDuplicate7,
+        revision: revisionIdDuplicate8,
+        grantedGroups: [
+          { item: groupIdC, type: GroupType.userGroup },
+          { item: externalGroupIdC, type: GroupType.externalUserGroup },
+        ],
+      },
+      {
+        _id: pageIdDuplicate9,
+        path: '/np_duplicate7/np_duplicate9',
+        grant: Page.GRANT_OWNER,
+        creator: npDummyUser2._id,
+        lastUpdateUser: npDummyUser2._id,
+        parent: pageIdDuplicate7,
+        revision: revisionIdDuplicate9,
+        grantedUsers: [npDummyUser2._id],
+      },
     ]);
     ]);
     await Revision.insertMany([
     await Revision.insertMany([
       {
       {
@@ -593,6 +643,27 @@ describe('PageService page operations with non-public pages', () => {
         pageId: pageIdDuplicate6,
         pageId: pageIdDuplicate6,
         author: npDummyUser1._id,
         author: npDummyUser1._id,
       },
       },
+      {
+        _id: revisionIdDuplicate7,
+        body: 'np_duplicate7',
+        format: 'markdown',
+        pageId: pageIdDuplicate7,
+        author: npDummyUser1._id,
+      },
+      {
+        _id: revisionIdDuplicate8,
+        body: 'np_duplicate8',
+        format: 'markdown',
+        pageId: pageIdDuplicate8,
+        author: npDummyUser3._id,
+      },
+      {
+        _id: revisionIdDuplicate9,
+        body: 'np_duplicate9',
+        format: 'markdown',
+        pageId: pageIdDuplicate9,
+        author: npDummyUser2._id,
+      },
     ]);
     ]);
 
 
     /**
     /**
@@ -1083,10 +1154,10 @@ describe('PageService page operations with non-public pages', () => {
   });
   });
   describe('Duplicate', () => {
   describe('Duplicate', () => {
 
 
-    const duplicate = async(page, newPagePath, user, isRecursively) => {
+    const duplicate = async(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources) => {
       // mock return value
       // mock return value
       const mockedDuplicateRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'duplicateRecursivelyMainOperation').mockReturnValue(null);
       const mockedDuplicateRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'duplicateRecursivelyMainOperation').mockReturnValue(null);
-      const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively);
+      const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources);
 
 
       // retrieve the arguments passed when calling method duplicateRecursivelyMainOperation inside duplicate method
       // retrieve the arguments passed when calling method duplicateRecursivelyMainOperation inside duplicate method
       const argsForDuplicateRecursivelyMainOperation = mockedDuplicateRecursivelyMainOperation.mock.calls[0];
       const argsForDuplicateRecursivelyMainOperation = mockedDuplicateRecursivelyMainOperation.mock.calls[0];
@@ -1108,7 +1179,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(_revision).toBeTruthy();
       expect(_revision).toBeTruthy();
 
 
       const newPagePath = '/dup_np_duplicate1';
       const newPagePath = '/dup_np_duplicate1';
-      await duplicate(_page, newPagePath, npDummyUser1, false);
+      await duplicate(_page, newPagePath, npDummyUser1, false, false);
 
 
       const duplicatedPage = await Page.findOne({ path: newPagePath });
       const duplicatedPage = await Page.findOne({ path: newPagePath });
       const duplicatedRevision = await Revision.findOne({ pageId: duplicatedPage._id });
       const duplicatedRevision = await Revision.findOne({ pageId: duplicatedPage._id });
@@ -1126,9 +1197,9 @@ describe('PageService page operations with non-public pages', () => {
       const _path1 = '/np_duplicate2';
       const _path1 = '/np_duplicate2';
       const _path2 = '/np_duplicate2/np_duplicate3';
       const _path2 = '/np_duplicate2/np_duplicate3';
       const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id, grantedGroups: { $elemMatch: { item: groupIdA } } })
       const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id, grantedGroups: { $elemMatch: { item: groupIdA } } })
-        .populate({ path: 'revision', model: 'Revision', grantedPage: groupIdA._id });
+        .populate({ path: 'revision', model: 'Revision' });
       const _page2 = await Page.findOne({ path: _path2, parent: _page1._id, grantedGroups: { $elemMatch: { item: groupIdB } } })
       const _page2 = await Page.findOne({ path: _path2, parent: _page1._id, grantedGroups: { $elemMatch: { item: groupIdB } } })
-        .populate({ path: 'revision', model: 'Revision', grantedPage: groupIdB._id });
+        .populate({ path: 'revision', model: 'Revision' });
       const _revision1 = _page1.revision;
       const _revision1 = _page1.revision;
       const _revision2 = _page2.revision;
       const _revision2 = _page2.revision;
       expect(_page1).toBeTruthy();
       expect(_page1).toBeTruthy();
@@ -1137,7 +1208,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(_revision2).toBeTruthy();
       expect(_revision2).toBeTruthy();
 
 
       const newPagePath = '/dup_np_duplicate2';
       const newPagePath = '/dup_np_duplicate2';
-      await duplicate(_page1, newPagePath, npDummyUser2, true);
+      await duplicate(_page1, newPagePath, npDummyUser2, true, false);
 
 
       const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate2/np_duplicate3' }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate2/np_duplicate3' }).populate({ path: 'revision', model: 'Revision' });
@@ -1181,7 +1252,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(baseRevision2).toBeTruthy();
       expect(baseRevision2).toBeTruthy();
 
 
       const newPagePath = '/dup_np_duplicate4';
       const newPagePath = '/dup_np_duplicate4';
-      await duplicate(_page1, newPagePath, npDummyUser1, true);
+      await duplicate(_page1, newPagePath, npDummyUser1, true, false);
 
 
       const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate4/np_duplicate5' }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate4/np_duplicate5' }).populate({ path: 'revision', model: 'Revision' });
@@ -1203,6 +1274,40 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
       expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
     });
     });
+    test('Should duplicate only user related resources when onlyDuplicateUserRelatedResources is true', async() => {
+      const _path1 = '/np_duplicate7';
+      const _path2 = '/np_duplicate7/np_duplicate8';
+      const _path3 = '/np_duplicate7/np_duplicate9';
+      const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _page2 = await Page.findOne({ path: _path2, parent: _page1._id });
+      const _page3 = await Page.findOne({ path: _path3, parent: _page1._id });
+      const _revision1 = _page1.revision;
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_revision1).toBeTruthy();
+
+      const newPagePath = '/dup_np_duplicate7';
+      await duplicate(_page1, newPagePath, npDummyUser1, true, true);
+
+      const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate7/np_duplicate8' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage3 = await Page.findOne({ path: '/dup_np_duplicate7/np_duplicate9' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedRevision1 = duplicatedPage1.revision;
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage1).toBeTruthy();
+      expect(duplicatedPage2).toBeFalsy();
+      expect(duplicatedPage3).toBeFalsy();
+      expect(duplicatedRevision1).toBeTruthy();
+      expect(normalizeGrantedGroups(duplicatedPage1.grantedGroups)).toStrictEqual([
+        { item: groupIdA, type: GroupType.userGroup },
+        { item: externalGroupIdA, type: GroupType.externalUserGroup },
+      ]);
+      expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
+      expect(duplicatedRevision1.body).toBe(_revision1.body);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+    });
 
 
   });
   });
   describe('Delete', () => {
   describe('Delete', () => {