ryoji-s 2 лет назад
Родитель
Сommit
9bfbfe638d

+ 4 - 3
apps/app/src/components/SavePageControls.tsx

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
 
 import EventEmitter from 'events';
 
-import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-path-utils';
+import { isMovablePage } from '@growi/core/dist/utils/page-path-utils';
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledButtonDropdown, Button,
@@ -11,7 +11,7 @@ import {
 
 import type { IPageGrantData } from '~/interfaces/page';
 import {
-  useIsEditable, useIsAclEnabled,
+  useIsEditable, useIsAclEnabled, useIsUsersHomepageDeletionEnabled,
 } from '~/stores/context';
 import { useWaitingSaveProcessing } from '~/stores/editor';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -39,6 +39,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: isEditable } = useIsEditable();
   const { data: isAclEnabled } = useIsAclEnabled();
+  const { data: isUsersHomepageDeletionEnabled } = useIsUsersHomepageDeletionEnabled();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
 
@@ -69,7 +70,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
 
   const { grant, grantedGroup } = grantData;
 
-  const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
+  const isGrantSelectorDisabledPage = !isMovablePage(currentPage?.path ?? '', currentPage?.creator?.status, isUsersHomepageDeletionEnabled ?? undefined);
   const labelSubmitButton = (currentPage != null && !currentPage.isEmpty) ? t('Update') : t('Create');
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 

+ 8 - 5
apps/app/src/components/Sidebar/PageTree/Item.tsx

@@ -45,6 +45,7 @@ interface ItemProps {
   isEnableActions: boolean
   isReadOnlyUser: boolean
   itemNode: ItemNode
+  isUsersHomepageDeletionEnabled?: boolean
   targetPathOrId?: Nullable<string>
   isOpen?: boolean
   onRenamed?(fromPath: string | undefined, toPath: string): void
@@ -112,8 +113,9 @@ const NotDraggableForClosableTextInput = (props: NotDraggableProps): JSX.Element
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const {
-    itemNode, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
+    isEnableActions, isReadOnlyUser, itemNode,
+    isUsersHomepageDeletionEnabled, targetPathOrId, isOpen: _isOpen = false,
+    onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem,
   } = props;
 
   const { page, children } = itemNode;
@@ -155,10 +157,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     type: 'PAGE_TREE',
     item: { page },
     canDrag: () => {
-      if (page.path == null) {
+      if (page.path == null || page.creator?.status == null) {
         return false;
       }
-      return !pagePathUtils.isUsersProtectedPages(page.path);
+      return !pagePathUtils.isUsersProtectedPages(page.path, page.creator.status, isUsersHomepageDeletionEnabled);
     },
     end: (item, monitor) => {
       // in order to set d-none to dropped Item
@@ -542,9 +544,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             <Item
               isEnableActions={isEnableActions}
               isReadOnlyUser={isReadOnlyUser}
+              isUsersHomepageDeletionEnabled={isUsersHomepageDeletionEnabled}
               itemNode={node}
-              isOpen={false}
               targetPathOrId={targetPathOrId}
+              isOpen={false}
               onRenamed={onRenamed}
               onClickDuplicateMenuItem={onClickDuplicateMenuItem}
               onClickDeleteMenuItem={onClickDeleteMenuItem}

+ 6 - 3
apps/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -13,6 +13,7 @@ import { toastError, toastSuccess } from '~/client/util/toastr';
 import { AncestorsChildrenResult, RootPageResult, TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { SocketEventName, UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
+import { useIsUsersHomepageDeletionEnabled } from '~/stores/context';
 import {
   IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
 } from '~/stores/modal';
@@ -109,6 +110,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const { data: ancestorsChildrenResult, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: rootPageResult, error: error2 } = useSWRxRootPage();
   const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isUsersHomepageDeletionEnabled } = useIsUsersHomepageDeletionEnabled();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: sidebarScrollerRef } = useSidebarScrollerRef();
@@ -274,11 +276,12 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
       <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} ref={rootElemRef}>
         <Item
           key={initialItemNode.page.path}
-          targetPathOrId={targetPathOrId}
-          itemNode={initialItemNode}
-          isOpen
           isEnableActions={isEnableActions}
           isReadOnlyUser={isReadOnlyUser}
+          itemNode={initialItemNode}
+          isUsersHomepageDeletionEnabled={isUsersHomepageDeletionEnabled ?? undefined}
+          targetPathOrId={targetPathOrId}
+          isOpen
           onRenamed={onRenamed}
           onClickDuplicateMenuItem={onClickDuplicateMenuItem}
           onClickDeleteMenuItem={onClickDeleteMenuItem}

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

@@ -32,7 +32,7 @@ import {
   useCurrentUser,
   useIsForbidden, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing, useIsUsersHomepageDeletionEnabled,
   useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useIsEnabledMarp, useCurrentPathname,
@@ -170,6 +170,7 @@ type Props = CommonProps & {
   adminPreferredIndentSize: number,
   isIndentSizeForced: boolean,
   disableLinkSharing: boolean,
+  isUsersHomepageDeletionEnabled: boolean,
   skipSSR: boolean,
   ssrMaxRevisionBodyLength: number,
 
@@ -218,6 +219,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useDefaultIndentSize(props.adminPreferredIndentSize);
   useIsIndentSizeForced(props.isIndentSizeForced);
   useDisableLinkSharing(props.disableLinkSharing);
+  useIsUsersHomepageDeletionEnabled(props.isUsersHomepageDeletionEnabled);
   useRendererConfig(props.rendererConfig);
   useIsEnabledMarp(props.rendererConfig.isEnabledMarp);
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
@@ -579,6 +581,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
   props.isEnabledStaleNotification = configManager.getConfig('crowi', 'customize:isEnabledStaleNotification');
   props.disableLinkSharing = configManager.getConfig('crowi', 'security:disableLinkSharing');
+  props.isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled');
   props.editorConfig = {
     upload: {
       isUploadableFile: crowi.fileUploadService.getFileUploadEnabled(),

+ 12 - 5
apps/app/src/server/models/page.ts

@@ -429,8 +429,15 @@ export class PageQueryBuilder {
     return this;
   }
 
-  addConditionToMinimizeDataForRendering(): PageQueryBuilder {
-    this.query = this.query.select('_id path isEmpty grant revision descendantCount');
+  async addConditionToMinimizeDataForRendering(): Promise<PageQueryBuilder> {
+    // eslint-disable-next-line rulesdir/no-populate
+    this.query = this.query
+      .select('_id path isEmpty grant revision descendantCount creator')
+      .populate({
+        path: 'creator',
+        model: 'User',
+        select: 'status',
+      });
 
     return this;
   }
@@ -658,11 +665,11 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
   // Do not populate
   const queryBuilder = new PageQueryBuilder(this.find(), true);
   await queryBuilder.addViewerCondition(user, userGroups);
+  queryBuilder.addConditionAsOnTree();
+  queryBuilder.addConditionToListByPathsArray(ancestorPaths);
+  await queryBuilder.addConditionToMinimizeDataForRendering();
 
   const _targetAndAncestors: PageDocument[] = await queryBuilder
-    .addConditionAsOnTree()
-    .addConditionToListByPathsArray(ancestorPaths)
-    .addConditionToMinimizeDataForRendering()
     .addConditionToSortPagesByDescPath()
     .query
     .lean()

+ 6 - 4
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -140,22 +140,24 @@ const routerFactory = (crowi: Crowi): Router => {
       const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> = {};
 
       const isGuestUser = req.user == null;
-      for (const page of pages) {
+      const promiseArray = pages.map(async(page) => {
         // construct isIPageInfoForListing
-        const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
+        const basicPageInfo = await pageService.constructBasicPageInfo(page, isGuestUser);
 
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
           // create IPageInfoForListing
           : {
             ...basicPageInfo,
-            isAbleToDeleteCompletely: pageService.canDeleteCompletely(page.path, (page.creator as IUserHasId)?._id, req.user, false), // use normal delete config
+            isAbleToDeleteCompletely: await pageService.canDeleteCompletely(page.path, (page.creator as IUserHasId)?._id, req.user, false), // use normal delete config
             bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
             revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
           } as IPageInfoForListing;
 
         idToPageInfoMap[page._id] = pageInfo;
-      }
+      });
+
+      await Promise.all(promiseArray);
 
       return res.apiv3(idToPageInfoMap);
     }

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

@@ -641,7 +641,7 @@ module.exports = (crowi) => {
 
     const pagesInTrash = await crowi.pageService.findAllTrashPages(req.user);
 
-    const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
+    const deletablePages = await crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
 
     if (deletablePages.length === 0) {
       const msg = 'No pages can be deleted.';
@@ -910,14 +910,14 @@ module.exports = (crowi) => {
      * Delete Completely
      */
     if (isCompletely) {
-      pagesCanBeDeleted = crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user, isRecursively);
+      pagesCanBeDeleted = await crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user, isRecursively);
     }
     /*
      * Trash
      */
     else {
       pagesCanBeDeleted = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
-      pagesCanBeDeleted = crowi.pageService.filterPagesByCanDelete(pagesToDelete, req.user, isRecursively);
+      pagesCanBeDeleted = await crowi.pageService.filterPagesByCanDelete(pagesToDelete, req.user, isRecursively);
     }
 
     if (pagesCanBeDeleted.length === 0) {

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

@@ -762,7 +762,7 @@ module.exports = function(crowi, app) {
 
     try {
       if (isCompletely) {
-        if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
+        if (!await crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
         }
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively, false, activityParameters);
@@ -778,7 +778,7 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
 
-        if (!crowi.pageService.canDelete(page.path, creator, req.user, isRecursively)) {
+        if (!await crowi.pageService.canDelete(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
         }
 

+ 49 - 18
apps/app/src/server/service/page.ts

@@ -40,12 +40,14 @@ import ShareLink from '../models/share-link';
 import Subscription from '../models/subscription';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
 
+import { configManager } from './config-manager';
+
 const debug = require('debug')('growi:services:page');
 
 const logger = loggerFactory('growi:services:page');
 const {
   isTrashPage, isTopPage, omitDuplicateAreaPageFromPages,
-  isMovablePage, canMoveByPath, isUsersProtectedPages, hasSlash, generateChildrenRegExp,
+  isMovablePage, canMoveByPath, hasSlash, generateChildrenRegExp,
 } = pagePathUtils;
 
 const { addTrailingSlash } = pathUtils;
@@ -163,8 +165,13 @@ class PageService {
     this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
   }
 
-  canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isTopPage(path) || isUsersProtectedPages(path)) return false;
+  async canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: IUserHasId, isRecursively: boolean): Promise<boolean> {
+    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled');
+    const User = mongoose.model('User');
+    const creator = await User.findById(creatorId);
+    if (operator == null || !isMovablePage(path, creator?.status, isUsersHomepageDeletionEnabled)) {
+      return false;
+    }
 
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
@@ -174,8 +181,13 @@ class PageService {
     return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
-  canDelete(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isUsersProtectedPages(path) || isTopPage(path)) return false;
+  async canDelete(path: string, creatorId: ObjectIdLike, operator: IUserHasId, isRecursively: boolean): Promise<boolean> {
+    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled');
+    const User = mongoose.model('User');
+    const creator = await User.findById(creatorId);
+    if (operator == null || !isMovablePage(path, creator?.status, isUsersHomepageDeletionEnabled)) {
+      return false;
+    }
 
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
@@ -217,12 +229,22 @@ class PageService {
     return false;
   }
 
-  filterPagesByCanDeleteCompletely(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.path, p.creator, user, isRecursively));
+  async filterPagesByCanDeleteCompletely(pages, user, isRecursively: boolean): Promise<boolean[]> {
+    const filteredPages = await Promise.all(pages.map(async(p) => {
+      const canDeleteCompletely = await this.canDeleteCompletely(p.path, p.creator, user, isRecursively);
+      return p.isEmpty || canDeleteCompletely;
+    }));
+
+    return filteredPages;
   }
 
-  filterPagesByCanDelete(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDelete(p.path, p.creator, user, isRecursively));
+  async filterPagesByCanDelete(pages, user, isRecursively: boolean): Promise<boolean[]> {
+    const filteredPages = await Promise.all(pages.map(async(p) => {
+      const canDeleteCompletely = await this.canDelete(p.path, p.creator, user, isRecursively);
+      return p.isEmpty || canDeleteCompletely;
+    }));
+
+    return filteredPages;
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -259,7 +281,7 @@ class PageService {
     }
 
     const isGuestUser = user == null;
-    const pageInfo = this.constructBasicPageInfo(page, isGuestUser);
+    const pageInfo = await this.constructBasicPageInfo(page, isGuestUser);
 
     const Bookmark = this.crowi.model('Bookmark');
     const bookmarkCount = await Bookmark.countByPageId(pageId);
@@ -288,8 +310,8 @@ class PageService {
       const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
       creatorId = notEmptyClosestAncestor.creator;
     }
-    const isDeletable = this.canDelete(page.path, creatorId, user, false);
-    const isAbleToDeleteCompletely = this.canDeleteCompletely(page.path, creatorId, user, false); // use normal delete config
+    const isDeletable = await this.canDelete(page.path, creatorId, user, false);
+    const isAbleToDeleteCompletely = await this.canDeleteCompletely(page.path, creatorId, user, false); // use normal delete config
 
     return {
       data: page,
@@ -1370,6 +1392,7 @@ class PageService {
      * Common Operation
      */
     const Page = mongoose.model('Page') as PageModel;
+    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled');
 
     // Separate v4 & v5 process
     const shouldUseV4Process = this.shouldUseV4Process(page);
@@ -1385,7 +1408,8 @@ class PageService {
       throw new Error('This method does NOT support deleting trashed pages.');
     }
 
-    if (!isMovablePage(page.path)) {
+    const populatedPage = await page.populate('creator');
+    if (!isMovablePage(page.path, populatedPage.creator?.status, isUsersHomepageDeletionEnabled)) {
       throw new Error('Page is not deletable.');
     }
 
@@ -1525,6 +1549,7 @@ class PageService {
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled');
 
     const newPath = Page.getDeletedPageName(page.path);
     const isTrashed = isTrashPage(page.path);
@@ -1533,7 +1558,8 @@ class PageService {
       throw new Error('This method does NOT support deleting trashed pages.');
     }
 
-    if (!isMovablePage(page.path)) {
+    const populatedPage = await page.populate('creator');
+    if (!isMovablePage(page.path, populatedPage.creator?.status, isUsersHomepageDeletionEnabled)) {
       throw new Error('Page is not deletable.');
     }
 
@@ -2383,8 +2409,12 @@ class PageService {
     });
   }
 
-  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
-    const isMovable = isGuestUser ? false : isMovablePage(page.path);
+  async constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): Promise<IPageInfo | IPageInfoForEntity> {
+    const populatedPage = await page.populate('creator');
+    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled');
+    const isMovable = isGuestUser
+      ? false
+      : isMovablePage(page.path, populatedPage.creator?.status, isUsersHomepageDeletionEnabled);
 
     if (page.isEmpty) {
       return {
@@ -4168,9 +4198,10 @@ class PageService {
     // get pages at once
     const queryBuilder = new PageQueryBuilder(Page.find({ path: { $in: regexps } }), true);
     await queryBuilder.addViewerCondition(user, userGroups);
+    queryBuilder.addConditionAsOnTree();
+    await queryBuilder.addConditionToMinimizeDataForRendering();
+
     const pages = await queryBuilder
-      .addConditionAsOnTree()
-      .addConditionToMinimizeDataForRendering()
       .addConditionToSortPagesByAscPath()
       .query
       .lean()

+ 4 - 0
apps/app/src/stores/context.tsx

@@ -68,6 +68,10 @@ export const useDisableLinkSharing = (initialData?: Nullable<boolean>): SWRRespo
   return useContextSWR<Nullable<boolean>, Error>('disableLinkSharing', initialData);
 };
 
+export const useIsUsersHomepageDeletionEnabled = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
+  return useContextSWR<Nullable<boolean>, Error>('isUsersHomepageDeletionEnabled', initialData);
+};
+
 export const useRegistrationWhitelist = (initialData?: Nullable<string[]>): SWRResponse<Nullable<string[]>, Error> => {
   return useContextSWR<Nullable<string[]>, Error>('registrationWhitelist', initialData);
 };