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

Merge remote-tracking branch 'origin/dev/6.2.x'

Yuki Takei 2 лет назад
Родитель
Сommit
beb9bde961

+ 3 - 1
CHANGELOG.md

@@ -1,6 +1,8 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.2.3...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.2.4...HEAD)
+
+## [v6.2.4](https://github.com/weseek/growi/compare/v6.2.3...v6.2.4) - 2023-11-29
 
 *Please do not manually update this file. We've automated the process.*
 

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`6.2.3`, `6.2`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.3/apps/app/docker/Dockerfile)
+* [`6.2.4`, `6.2`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 * [`6.0.15`, `6.0` (Dockerfile)](https://github.com/weseek/growi/blob/v6.0.15/packages/app/docker/Dockerfile)
 * [`5.1.7`, `5.1`, `5` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.7/packages/app/docker/Dockerfile)

+ 3 - 2
apps/app/public/static/locales/en_US/admin.json

@@ -48,8 +48,9 @@
     "anyone": "Anyone",
     "user_homepage_deletion": {
       "user_homepage_deletion": "User homepage deletion",
-      "enable_user_homepage_deletion": "Complete deletion of user homepage, when user deletion",
-      "desc": "When deleting a user, the user homepage and its sub pages are also completely deleted."
+      "enable_user_homepage_deletion": "Enable user homepage deletion",
+      "enable_force_delete_user_homepage_on_user_deletion": "When you delete a user, the user's homepage and all its sub pages will be completely deleted",
+      "desc": "You will be able to delete a deleted user's homepage."
     },
     "session": "Session",
     "max_age": "Max age (msec)",

+ 3 - 2
apps/app/public/static/locales/ja_JP/admin.json

@@ -57,8 +57,9 @@
     "anyone": "誰でも可能",
     "user_homepage_deletion": {
       "user_homepage_deletion": "ユーザーホームページの削除",
-      "enable_user_homepage_deletion": "ユーザー削除時にユーザーホームページを完全削除する",
-      "desc": "ユーザーを削除する際に、ユーザーホームページとその配下のページも完全削除されます。"
+      "enable_user_homepage_deletion": "ユーザーホームページの削除を有効化",
+      "enable_force_delete_user_homepage_on_user_deletion": "ユーザーを削除したとき、ユーザーホームページとその配下のページを完全削除する",
+      "desc": "削除済みユーザーのユーザーホームページを削除できるようになります。"
     },
     "session": "セッション",
     "max_age": "有効期間 (ミリ秒)",

+ 4 - 3
apps/app/public/static/locales/zh_CN/admin.json

@@ -56,9 +56,10 @@
 		"admin_and_author": "管理员|作者",
 		"anyone": "任何人",
     "user_homepage_deletion": {
-      "user_homepage_deletion": "删除用户页面",
-      "enable_user_homepage_deletion": "用户删除时,完全删除用户主页",
-      "desc": "删除用户时,用户主页及其下属页面也会被完全删除。"
+      "user_homepage_deletion": "删除用户主页",
+      "enable_user_homepage_deletion": "启用用户主页删除功能",
+      "enable_force_delete_user_homepage_on_user_deletion": "删除用户时,该用户的主页及其所有子页面将被完全删除",
+      "desc": "您可以删除已删除用户的主页。"
     },
     "session": "会议",
     "max_age": "有效期间  (msec)",

+ 10 - 0
apps/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -39,6 +39,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
       isUsersHomepageDeletionEnabled: false,
+      isForceDeleteUserHomepageOnUserDeletion: false,
       isLocalEnabled: false,
       isLdapEnabled: false,
       isSamlEnabled: false,
@@ -75,6 +76,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion: generalSetting.isForceDeleteUserHomepageOnUserDeletion,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
@@ -202,6 +204,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ isUsersHomepageDeletionEnabled: !this.state.isUsersHomepageDeletionEnabled });
   }
 
+  /**
+   * Switch isForceDeleteUserHomepageOnUserDeletion
+   */
+  switchIsForceDeleteUserHomepageOnUserDeletion() {
+    this.setState({ isForceDeleteUserHomepageOnUserDeletion: !this.state.isForceDeleteUserHomepageOnUserDeletion });
+  }
+
   /**
    * Update restrictGuestMode
    * @memberOf AdminGeneralSecuritySContainer
@@ -219,6 +228,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion: this.state.isForceDeleteUserHomepageOnUserDeletion,
     };
 
     requestParams = await removeNullPropertyFromObject(requestParams);

+ 3 - 3
apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx

@@ -17,9 +17,8 @@ type Props = {
 const CustomizePresentationSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
 
-  console.log(adminCustomizeContainer);
-
   const { t } = useTranslation();
+
   const onClickSubmit = useCallback(async() => {
     try {
       await adminCustomizeContainer.updateCustomizePresentation();
@@ -28,7 +27,8 @@ const CustomizePresentationSetting = (props: Props): JSX.Element => {
     catch (err) {
       toastError(err);
     }
-  }, [adminCustomizeContainer]);
+  }, [adminCustomizeContainer, t]);
+
   return (
     <React.Fragment>
       <h2 className="admin-setting-header">{t('admin:customize_settings.custom_presentation')}</h2>

+ 14 - 1
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -468,8 +468,21 @@ class SecuritySetting extends React.Component {
                 {t('security_settings.user_homepage_deletion.enable_user_homepage_deletion')}
               </label>
             </div>
+            <div className="custom-control custom-switch custom-checkbox-success mt-2">
+              <input
+                type="checkbox"
+                className="form-check-input"
+                id="is-force-delete-user-homepage-on-user-deletion"
+                checked={adminGeneralSecurityContainer.state.isForceDeleteUserHomepageOnUserDeletion}
+                onChange={() => { adminGeneralSecurityContainer.switchIsForceDeleteUserHomepageOnUserDeletion() }}
+                disabled={!adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled}
+              />
+              <label className="form-check-label" htmlFor="is-force-delete-user-homepage-on-user-deletion">
+                {t('security_settings.user_homepage_deletion.enable_force_delete_user_homepage_on_user_deletion')}
+              </label>
+            </div>
             <p
-              className="form-text text-muted small"
+              className="form-text text-muted small mt-2"
               dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.desc') }}
             />
           </div>

+ 5 - 3
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -86,7 +86,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
       return;
     }
-    if (!pageInfo?.isMovable) {
+
+    if (!pageInfo?.isDeletable) {
       logger.warn('This page could not be renamed.');
       return;
     }
@@ -177,9 +178,10 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && (
           <DropdownItem
             onClick={renameItemClickedHandler}
+            disabled={!pageInfo.isDeletable}
             data-testid="open-page-move-rename-modal-btn"
             className="grw-page-control-dropdown-item"
           >
@@ -231,7 +233,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* divider */}
         {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && (
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem

+ 6 - 6
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx

@@ -12,7 +12,7 @@ type Props = {
   id: string,
   name: string,
   url: string,
-  isEnalbed: boolean,
+  isEnabled: boolean,
   desc?: string,
   onDelete: () => void,
 }
@@ -20,27 +20,27 @@ type Props = {
 export const PluginCard = (props: Props): JSX.Element => {
 
   const {
-    id, name, url, isEnalbed, desc,
+    id, name, url, isEnabled, desc,
   } = props;
 
   const { t } = useTranslation('admin');
 
   const PluginCardButton = (): JSX.Element => {
-    const [isEnabled, setState] = useState<boolean>(isEnalbed);
+    const [_isEnabled, setIsEnabled] = useState<boolean>(isEnabled);
 
     const onChangeHandler = async() => {
       try {
-        if (isEnabled) {
+        if (_isEnabled) {
           const reqUrl = `/plugins/${id}/deactivate`;
           const res = await apiv3Put(reqUrl);
-          setState(!isEnabled);
+          setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.deactivate_plugin_success', { pluginName }));
         }
         else {
           const reqUrl = `/plugins/${id}/activate`;
           const res = await apiv3Put(reqUrl);
-          setState(!isEnabled);
+          setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.activate_plugin_success', { pluginName }));
         }

+ 1 - 1
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -54,7 +54,7 @@ export const PluginsExtensionPageContents = (): JSX.Element => {
                     id={plugin._id}
                     name={plugin.meta.name}
                     url={plugin.origin.url}
-                    isEnalbed={plugin.isEnabled}
+                    isEnabled={plugin.isEnabled}
                     desc={plugin.meta.desc}
                     onDelete={() => openPluginDeleteModal(plugin)}
                   />

+ 7 - 2
apps/app/src/server/events/user.ts

@@ -1,8 +1,10 @@
 import EventEmitter from 'events';
 
-import type { IUserHasId } from '@growi/core';
+import type { IPage, IUserHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
+import mongoose from 'mongoose';
 
+import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:events:user');
@@ -23,11 +25,14 @@ class UserEvent extends EventEmitter {
       return;
     }
 
-    const Page = this.crowi.model('Page');
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const userHomepagePath = pagePathUtils.userHomepagePath(user);
 
     let page = await Page.findByPath(userHomepagePath, true);
 
+    // TODO: Make it more type safe
+    // Since the type of page.creator is 'any', we resort to the following comparison,
+    // checking if page.creator.toString() is not equal to user._id.toString(). Our code covers null, string, or object types.
     if (page != null && page.creator != null && page.creator.toString() !== user._id.toString()) {
       await this.crowi.pageService.deleteCompletelyUserHomeBySystem(userHomepagePath);
       page = null;

+ 2 - 1
apps/app/src/server/models/config.ts

@@ -71,7 +71,8 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
   'security:disableLinkSharing' : false,
-  'security:isUsersHomepageDeletionEnabled': false,
+  'security:user-homepage-deletion:isEnabled': false,
+  'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': false,
 
   'security:passport-local:isEnabled' : true,
   'security:passport-ldap:isEnabled' : false,

+ 1 - 0
apps/app/src/server/models/page.ts

@@ -74,6 +74,7 @@ export interface PageModel extends Model<PageDocument> {
   generateGrantCondition(
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
+  removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
 
   PageQueryBuilder: typeof PageQueryBuilder
 

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

@@ -906,18 +906,12 @@ module.exports = (crowi) => {
     }
 
     let pagesCanBeDeleted;
-    /*
-     * 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);
+      const filteredPages = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
+      pagesCanBeDeleted = await crowi.pageService.filterPagesByCanDelete(filteredPages, req.user, isRecursively);
     }
 
     if (pagesCanBeDeleted.length === 0) {

+ 12 - 3
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -31,6 +31,7 @@ const validator = {
     body('hideRestrictedByOwner').if(value => value != null).isBoolean(),
     body('hideRestrictedByGroup').if(value => value != null).isBoolean(),
     body('isUsersHomepageDeletionEnabled').if(value => value != null).isBoolean(),
+    body('isForceDeleteUserHomepageOnUserDeletion').if(value => value != null).isBoolean(),
   ],
   shareLinkSetting: [
     body('disableLinkSharing').if(value => value != null).isBoolean(),
@@ -358,7 +359,9 @@ module.exports = (crowi) => {
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
-        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled'),
+        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
+        isForceDeleteUserHomepageOnUserDeletion:
+        await configManager.getConfig('crowi', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion'),
         wikiMode: await configManager.getConfig('crowi', 'security:wikiMode'),
         sessionMaxAge: await configManager.getConfig('crowi', 'security:sessionMaxAge'),
       },
@@ -626,7 +629,11 @@ module.exports = (crowi) => {
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
-      'security:isUsersHomepageDeletionEnabled': req.body.isUsersHomepageDeletionEnabled,
+      'security:user-homepage-deletion:isEnabled': req.body.isUsersHomepageDeletionEnabled,
+      // Validate user-homepage-deletion config
+      'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': req.body.isUsersHomepageDeletionEnabled
+        ? req.body.isForceDeleteUserHomepageOnUserDeletion
+        : false,
     };
 
     // Validate delete config
@@ -655,7 +662,9 @@ module.exports = (crowi) => {
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
-        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled'),
+        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
+        isForceDeleteUserHomepageOnUserDeletion:
+        await configManager.getConfig('crowi', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion'),
       };
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };

+ 5 - 10
apps/app/src/server/routes/apiv3/users.js

@@ -779,7 +779,7 @@ module.exports = (crowi) => {
    *        tags: [Users]
    *        operationId: removeUser
    *        summary: /users/{id}/remove
-   *        description: Delete user and if isUsersHomepageDeletionEnabled delete user homepage and subpages
+   *        description: Delete user
    *        parameters:
    *          - name: id
    *            in: path
@@ -789,7 +789,7 @@ module.exports = (crowi) => {
    *              type: string
    *        responses:
    *          200:
-   *            description: Deleting user success and if isUsersHomepageDeletionEnabled delete user homepage and subpages success
+   *            description: Deleting user success
    *            content:
    *              application/json:
    *                schema:
@@ -797,16 +797,11 @@ module.exports = (crowi) => {
    *                    user:
    *                      type: object
    *                      description: data of deleted user
-   *                    userHomepagePath:
-   *                      type: string
-   *                      description: a user homepage path
-   *                    isUsersHomepageDeletionEnabled:
-   *                      type: boolean
-   *                      description: is users homepage deletion enabled
    */
   router.delete('/:id/remove', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
     const { id } = req.params;
-    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled');
+    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled');
+    const isForceDeleteUserHomepageOnUserDeletion = configManager.getConfig('crowi', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion');
 
     try {
       const user = await User.findById(id);
@@ -823,7 +818,7 @@ module.exports = (crowi) => {
 
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
 
-      if (isUsersHomepageDeletionEnabled) {
+      if (isUsersHomepageDeletionEnabled && isForceDeleteUserHomepageOnUserDeletion) {
         crowi.pageService.deleteCompletelyUserHomeBySystem(homepagePath);
       }
 

+ 20 - 1
apps/app/src/server/routes/page.js

@@ -136,7 +136,7 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
 
-  const { pathUtils } = require('@growi/core/dist/utils');
+  const { pathUtils, pagePathUtils } = require('@growi/core/dist/utils');
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
@@ -765,6 +765,16 @@ module.exports = function(crowi, app) {
         if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
         }
+
+        if (pagePathUtils.isUsersHomepage(page.path)) {
+          if (!crowi.pageService.canDeleteUserHomepageByConfig()) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+          if (!await crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+        }
+
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively, false, activityParameters);
       }
       else {
@@ -782,6 +792,15 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
         }
 
+        if (pagePathUtils.isUsersHomepage(page.path)) {
+          if (!crowi.pageService.canDeleteUserHomepageByConfig()) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+          if (!await crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+        }
+
         await crowi.pageService.deletePage(page, req.user, options, isRecursively, activityParameters);
       }
     }

+ 104 - 24
apps/app/src/server/service/page.ts

@@ -2,14 +2,13 @@ import pathlib from 'path';
 import { Readable, Writable } from 'stream';
 
 import type {
-  Ref, HasObjectId, IUserHasId,
+  Ref, HasObjectId, IUserHasId, IUser,
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
 } from '@growi/core';
-import { PageGrant, PageStatus } from '@growi/core';
+import { PageGrant, PageStatus, getIdForRef } from '@growi/core';
 import {
   pagePathUtils, pathUtils,
 } from '@growi/core/dist/utils';
-import { collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, Cursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
@@ -43,14 +42,13 @@ import ShareLink from '../models/share-link';
 import Subscription from '../models/subscription';
 import UserGroupRelation from '../models/user-group-relation';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
-import { divideByType } from '../util/granted-group';
 
 const debug = require('debug')('growi:services:page');
 
 const logger = loggerFactory('growi:services:page');
 const {
-  isTrashPage, isTopPage, omitDuplicateAreaPageFromPages,
-  isMovablePage, canMoveByPath, isUsersProtectedPages, hasSlash, generateChildrenRegExp,
+  isTrashPage, isTopPage, omitDuplicateAreaPageFromPages, getUsernameByPath, collectAncestorPaths,
+  canMoveByPath, isUsersTopPage, isMovablePage, isUsersHomepage, hasSlash, generateChildrenRegExp,
 } = pagePathUtils;
 
 const { addTrailingSlash } = pathUtils;
@@ -157,6 +155,8 @@ class PageService {
 
     // init
     this.initPageEvent();
+    this.canDeleteCompletely = this.canDeleteCompletely.bind(this);
+    this.canDelete = this.canDelete.bind(this);
   }
 
   private initPageEvent() {
@@ -169,7 +169,7 @@ class PageService {
   }
 
   canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isTopPage(path) || isUsersProtectedPages(path)) return false;
+    if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
 
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
@@ -180,7 +180,7 @@ class PageService {
   }
 
   canDelete(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isUsersProtectedPages(path) || isTopPage(path)) return false;
+    if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
 
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
@@ -190,6 +190,20 @@ class PageService {
     return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
+  canDeleteUserHomepageByConfig(): boolean {
+    return configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled') ?? false;
+  }
+
+  async isUsersHomepageOwnerAbsent(path: string): Promise<boolean> {
+    const User = mongoose.model('User');
+    const username = getUsernameByPath(path);
+    if (username == null) {
+      throw new Error('Cannot found username by path');
+    }
+    const ownerExists = await User.exists({ username });
+    return ownerExists === null;
+  }
+
   private canDeleteLogic(
       creatorId: ObjectIdLike,
       operator,
@@ -222,12 +236,58 @@ class PageService {
     return false;
   }
 
-  filterPagesByCanDeleteCompletely(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.path, p.creator, user, isRecursively));
+  private async getAbsenseUserHomeList(pages: PageDocument[]): Promise<string[]> {
+    const userHomepages = pages.filter(p => isUsersHomepage(p.path));
+
+    const User = mongoose.model<IUser>('User');
+    const usernames = userHomepages
+      .map(page => getUsernameByPath(page.path))
+      // see: https://zenn.dev/kimuson/articles/filter_safety_type_guard
+      .filter((username): username is Exclude<typeof username, null> => username !== null);
+    const existingUsernames = await User.distinct<string>('username', { username: { $in: usernames } });
+
+    return userHomepages.filter((page) => {
+      const username = getUsernameByPath(page.path);
+      if (username == null) {
+        throw new Error('Cannot found username by path');
+      }
+      return !existingUsernames.includes(username);
+    }).map(p => p.path);
   }
 
-  filterPagesByCanDelete(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDelete(p.path, p.creator, user, isRecursively));
+  private async filterPages(
+      pages: PageDocument[],
+      user: IUserHasId,
+      isRecursively: boolean,
+      canDeleteFunction: (path: string, creatorId: ObjectIdLike, operator: any, isRecursively: boolean) => boolean,
+  ): Promise<PageDocument[]> {
+    const filteredPages = pages.filter(p => p.isEmpty || canDeleteFunction(p.path, p.creator, user, isRecursively));
+
+    if (!this.canDeleteUserHomepageByConfig()) {
+      return filteredPages.filter(p => !isUsersHomepage(p.path));
+    }
+
+    // Confirmation of deletion of user homepages is an asynchronous process,
+    // so it is processed separately for performance optimization.
+    const absenseUserHomeList = await this.getAbsenseUserHomeList(filteredPages);
+
+    const excludeActiveUserHomepage = (path: string) => {
+      if (!isUsersHomepage(path)) {
+        return true;
+      }
+      return absenseUserHomeList.includes(path);
+    };
+
+    return filteredPages
+      .filter(p => excludeActiveUserHomepage(p.path));
+  }
+
+  async filterPagesByCanDeleteCompletely(pages: PageDocument[], user: IUserHasId, isRecursively: boolean): Promise<PageDocument[]> {
+    return this.filterPages(pages, user, isRecursively, this.canDeleteCompletely);
+  }
+
+  async filterPagesByCanDelete(pages: PageDocument[], user: IUserHasId, isRecursively: boolean): Promise<PageDocument[]> {
+    return this.filterPages(pages, user, isRecursively, this.canDelete);
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -255,7 +315,6 @@ class PageService {
         meta: {
           isV5Compatible: isTopPage(page.path) || page.parent != null,
           isEmpty: page.isEmpty,
-          isMovable: false,
           isDeletable: false,
           isAbleToDeleteCompletely: false,
           isRevertible: false,
@@ -1390,10 +1449,19 @@ class PageService {
       throw new Error('This method does NOT support deleting trashed pages.');
     }
 
-    if (!isMovablePage(page.path)) {
+    if (isTopPage(page.path) || isUsersTopPage(page.path)) {
       throw new Error('Page is not deletable.');
     }
 
+    if (pagePathUtils.isUsersHomepage(page.path)) {
+      if (!this.crowi.pageService.canDeleteUserHomepageByConfig()) {
+        throw new Error('User Homepage is not deletable.');
+      }
+      if (!await this.crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
+        throw new Error('User Homepage is not deletable.');
+      }
+    }
+
     const newPath = Page.getDeletedPageName(page.path);
 
     const canOperate = await this.crowi.pageOperationService.canOperate(isRecursively, page.path, newPath);
@@ -1976,24 +2044,38 @@ class PageService {
    * @throws {Error} - If an error occurs during the deletion process.
    */
   async deleteCompletelyUserHomeBySystem(userHomepagePath: string): Promise<void> {
-    const Page = this.crowi.model('Page');
+    if (!isUsersHomepage(userHomepagePath)) {
+      const msg = 'input value is not user homepage path.';
+      logger.error(msg);
+      throw new Error(msg);
+    }
+
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const userHomepage = await Page.findByPath(userHomepagePath, true);
 
     if (userHomepage == null) {
-      logger.error('user homepage is not found.');
-      return;
+      const msg = 'user homepage is not found.';
+      logger.error(msg);
+      throw new Error(msg);
+    }
+
+    if (userHomepage.parent == null) {
+      const msg = 'user homepage parent is not found.';
+      logger.error(msg);
+      throw new Error(msg);
     }
 
     const shouldUseV4Process = this.shouldUseV4Process(userHomepage);
 
     const ids = [userHomepage._id];
     const paths = [userHomepage.path];
+    const parentId = getIdForRef(userHomepage.parent);
 
     try {
       if (!shouldUseV4Process) {
         // Ensure consistency of ancestors
         const inc = userHomepage.isEmpty ? -userHomepage.descendantCount : -(userHomepage.descendantCount + 1);
-        await this.updateDescendantCountOfAncestors(userHomepage.parent, inc, true);
+        await this.updateDescendantCountOfAncestors(parentId, inc, true);
       }
 
       // Delete the user's homepage
@@ -2001,7 +2083,7 @@ class PageService {
 
       if (!shouldUseV4Process) {
         // Remove leaf empty pages
-        await Page.removeLeafEmptyPagesRecursively(userHomepage.parent);
+        await Page.removeLeafEmptyPagesRecursively(parentId);
       }
 
       if (!userHomepage.isEmpty) {
@@ -2014,7 +2096,7 @@ class PageService {
       // Find descendant pages with system deletion condition
       const builder = new PageQueryBuilder(Page.find(), true)
         .addConditionForSystemDeletion()
-        .addConditionToListOnlyDescendants(userHomepage.path);
+        .addConditionToListOnlyDescendants(userHomepage.path, {});
 
       // Stream processing to delete descendant pages
       // ────────┤ start │─────────
@@ -2406,13 +2488,12 @@ class PageService {
   }
 
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
-    const isMovable = isGuestUser ? false : isMovablePage(page.path);
+    const isDeletable = !(isGuestUser || isTopPage(page.path) || isUsersTopPage(page.path));
 
     if (page.isEmpty) {
       return {
         isV5Compatible: true,
         isEmpty: true,
-        isMovable,
         isDeletable: false,
         isAbleToDeleteCompletely: false,
         isRevertible: false,
@@ -2429,8 +2510,7 @@ class PageService {
       likerIds: this.extractStringIds(likers),
       seenUserIds: this.extractStringIds(seenUsers),
       sumOfSeenUsers: page.seenUsers.length,
-      isMovable,
-      isDeletable: isMovable,
+      isDeletable,
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
       contentAge: page.getContentAge(),

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "6.2.4-slackbot-proxy.0",
+  "version": "6.2.5-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.3.0-RC.0",
+  "version": "6.2.4-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 0 - 1
packages/core/src/interfaces/page.ts

@@ -83,7 +83,6 @@ export type IPageHasId = IPage & HasObjectId;
 export type IPageInfo = {
   isV5Compatible: boolean,
   isEmpty: boolean,
-  isMovable: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
   isRevertible: boolean,

+ 20 - 2
packages/core/src/utils/page-path-utils/index.spec.ts

@@ -1,9 +1,9 @@
 import {
-  isMovablePage, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths, getUsernameByPath,
+  isMovablePage, isTopPage, isUsersProtectedPages, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths, getUsernameByPath,
 } from './index';
 
 describe.concurrent('isMovablePage test', () => {
-  test('should decide deletable or not', () => {
+  test('should decide movable or not', () => {
     expect(isMovablePage('/')).toBeFalsy();
     expect(isMovablePage('/hoge')).toBeTruthy();
     expect(isMovablePage('/user')).toBeFalsy();
@@ -13,6 +13,24 @@ describe.concurrent('isMovablePage test', () => {
   });
 });
 
+describe.concurrent('isTopPage test', () => {
+  test('should decide deletable or not', () => {
+    expect(isTopPage('/')).toBeTruthy();
+    expect(isTopPage('/hoge')).toBeFalsy();
+    expect(isTopPage('/user/xxx/hoge')).toBeFalsy();
+  });
+});
+
+describe.concurrent('isUsersProtectedPages test', () => {
+  test('Should decide users protected pages or not', () => {
+    expect(isUsersProtectedPages('/hoge')).toBeFalsy();
+    expect(isUsersProtectedPages('/user')).toBeTruthy();
+    expect(isUsersProtectedPages('/user/xxx')).toBeTruthy();
+    expect(isUsersProtectedPages('/user/xxx123')).toBeTruthy();
+    expect(isUsersProtectedPages('/user/xxx/hoge')).toBeFalsy();
+  });
+});
+
 describe.concurrent('convertToNewAffiliationPath test', () => {
   test.concurrent('Child path is not converted normally', () => {
     const result = convertToNewAffiliationPath('parent/', 'parent2/', 'parent/child');