Jelajahi Sumber

Merge branch 'master' into dev/7.0.x

Futa Arai 2 tahun lalu
induk
melakukan
3358ed5735
24 mengubah file dengan 461 tambahan dan 175 penghapusan
  1. 39 2
      CHANGELOG.md
  2. 2 0
      apps/app/package.json
  3. 1 1
      apps/app/public/static/locales/en_US/admin.json
  4. 1 1
      apps/app/public/static/locales/ja_JP/admin.json
  5. 1 1
      apps/app/public/static/locales/zh_CN/admin.json
  6. 4 1
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  7. 1 0
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  8. 4 4
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  9. 39 1
      apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts
  10. 8 1
      apps/app/src/features/growi-plugin/server/models/vo/github-url.ts
  11. 9 12
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts
  12. 3 1
      apps/app/src/server/events/user.ts
  13. 1 0
      apps/app/src/server/models/page.ts
  14. 2 1
      apps/app/src/server/routes/apiv3/users.js
  15. 2 0
      apps/app/src/server/service/page/consts.ts
  16. 121 0
      apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts
  17. 122 0
      apps/app/src/server/service/page/delete-completely-user-home-by-system.ts
  18. 46 147
      apps/app/src/server/service/page/index.ts
  19. 12 0
      apps/app/src/server/service/page/page-service.ts
  20. 20 0
      apps/app/src/server/service/page/should-use-v4-process.ts
  21. 5 0
      apps/app/src/server/service/passport.ts
  22. 1 1
      apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts
  23. 1 0
      apps/app/test/integration/service/passport.test.js
  24. 16 1
      yarn.lock

+ 39 - 2
CHANGELOG.md

@@ -1,12 +1,47 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.2.4...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.3.0...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v6.3.0](https://github.com/weseek/growi/compare/v6.2.5...v6.3.0) - 2023-12-14
+
+### BREAKING CHANGES
+
+* support: Remove obsolete route for attachment on MongoDB GridFS (#8239) @yuki-takei
+
+### 💎 Features
+
+* feat: LDAP/Keycloak group sync (#7857) @arafubeatbox
+
+### 🚀 Improvement
+
+* imprv: Refactor DrawioViewer re-rendering by the resizing trigger (#8314) @yuki-takei
+* imprv: Apply content headers for attachment response (#8245) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: SAML callback action throws the field is undefined error when the ACL Rule string is only white space (#8322) @yuki-takei
+* fix: Remove groups not related to the user from the user groups that are specified automatically when creating child pages (#8266) @arafubeatbox
+* fix: Certify shared page attachment middleware (#8255) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Add test for delete-completely-user-home-by-system.ts (#8323) @jam411
+* ci(deps-dev): bump vite from 4.5.0 to 4.5.1 (#8302) @dependabot
+* support: TypeScriptize attachment codes (#8243) @yuki-takei
+* support: Remove obsolete route for attachment on MongoDB GridFS (#8239) @yuki-takei
+
+## [v6.2.5](https://github.com/weseek/growi/compare/v6.2.4...v6.2.5) - 2023-12-14
+
+### 🐛 Bug Fixes
+
+* fix: Update deleteCompletelyUserHomeBySystem for v4 process (#8289) @jam411
+
 ## [v6.2.4](https://github.com/weseek/growi/compare/v6.2.3...v6.2.4) - 2023-11-29
 ## [v6.2.4](https://github.com/weseek/growi/compare/v6.2.3...v6.2.4) - 2023-11-29
 
 
 ### 💎 Features
 ### 💎 Features
+
 * feat: Show create date in Attachment Data list (#8229) @sakazuki
 * feat: Show create date in Attachment Data list (#8229) @sakazuki
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement
@@ -14,11 +49,13 @@
 * imprv: Add Marp preset template for ja_JP and zh_CN (#8179) @AikaHiyama
 * imprv: Add Marp preset template for ja_JP and zh_CN (#8179) @AikaHiyama
 * imprv: Allow deletion of user homepage when the user is deleted (#8224) @jam411
 * imprv: Allow deletion of user homepage when the user is deleted (#8224) @jam411
 
 
+### 🐛 Bug Fixes
+* fix: Certify shared page attachment middleware (6.2.x) (#8256) @yuki-takei
+
 ### 🧰 Maintenance
 ### 🧰 Maintenance
 
 
 * support: Refactor deleteCompletelyUserHomeBySystem (#8262) @jam411
 * support: Refactor deleteCompletelyUserHomeBySystem (#8262) @jam411
 
 
-
 ## [v6.2.3](https://github.com/weseek/growi/compare/v6.2.2...v6.2.3) - 2023-11-13
 ## [v6.2.3](https://github.com/weseek/growi/compare/v6.2.2...v6.2.3) - 2023-11-13
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 2 - 0
apps/app/package.json

@@ -205,6 +205,7 @@
     "uglifycss": "^0.0.29",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
     "universal-bunyan": "^0.9.2",
     "unstated": "^2.1.1",
     "unstated": "^2.1.1",
+    "unzip-stream": "^0.3.1",
     "unzipper": "^0.10.5",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "usehooks-ts": "^2.6.0",
@@ -230,6 +231,7 @@
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
     "@types/throttle-debounce": "^5.0.1",
     "@types/throttle-debounce": "^5.0.1",
     "@types/url-join": "^4.0.2",
     "@types/url-join": "^4.0.2",
+    "@types/unzip-stream": "^0.3.4",
     "@vitest/coverage-v8": "^0.34.6",
     "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
     "babel-loader": "^8.2.5",

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

@@ -1107,7 +1107,7 @@
       "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
       "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
       "updated_group_sync_settings": "Updated Keycloak group sync settings",
       "updated_group_sync_settings": "Updated Keycloak group sync settings",
       "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
       "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
-      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before sync"
+      "auth_not_set": "Enable OIDC or SAML host that includes 'Host' and 'Group Realm' of group sync settings"
     },
     },
     "auto_generate_user_on_sync": "Auto Generate User on Sync",
     "auto_generate_user_on_sync": "Auto Generate User on Sync",
     "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."
     "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."

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

@@ -1117,7 +1117,7 @@
       "group_sync_client_secret_detail": "Keycloak admin API にリクエストするための認証に使う client の secret",
       "group_sync_client_secret_detail": "Keycloak admin API にリクエストするための認証に使う client の secret",
       "updated_group_sync_settings": "Keycloak グループ同期設定を更新しました",
       "updated_group_sync_settings": "Keycloak グループ同期設定を更新しました",
       "preserve_deleted_keycloak_groups": "Keycloak から削除されたグループを GROWI に残す",
       "preserve_deleted_keycloak_groups": "Keycloak から削除されたグループを GROWI に残す",
-      "auth_not_set": "同期実行前にセキュリティ設定で Keycloak を使った OIDC または SAML 認証を有効にし、設定してください"
+      "auth_not_set": "グループ同期設定の Host と Group Realm が発行ホストに含まれる OIDC または SAML 認証をセキュリティ設定で有効にしてください"
     },
     },
     "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
     "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
     "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。"
     "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。"

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

@@ -1116,7 +1116,7 @@
       "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
       "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
       "updated_group_sync_settings": "Updated Keycloak group sync settings",
       "updated_group_sync_settings": "Updated Keycloak group sync settings",
       "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
       "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
-      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before sync"
+      "auth_not_set": "Enable OIDC or SAML host that includes 'Host' and 'Group Realm' of group sync settings"
     },
     },
     "auto_generate_user_on_sync": "Auto Generate User on Sync",
     "auto_generate_user_on_sync": "Auto Generate User on Sync",
     "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."
     "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."

+ 4 - 1
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -344,7 +344,10 @@ module.exports = (crowi: Crowi): Router => {
     }
     }
 
 
     const getAuthProviderType = () => {
     const getAuthProviderType = () => {
-      const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+      let kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+      if (kcHost?.endsWith('/')) {
+        kcHost = kcHost.slice(0, -1);
+      }
       const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
       const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
 
 
       // starts with kcHost, contains kcGroupRealm in path
       // starts with kcHost, contains kcGroupRealm in path

+ 1 - 0
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts

@@ -148,6 +148,7 @@ describe('KeycloakUserGroupSyncService.generateExternalUserGroupTrees', () => {
   beforeAll(async() => {
   beforeAll(async() => {
     await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
     await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
     keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
     keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
+    keycloakUserGroupSyncService.init('oidc');
   });
   });
 
 
   it('creates ExternalUserGroupTrees', async() => {
   it('creates ExternalUserGroupTrees', async() => {

+ 4 - 4
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts

@@ -30,18 +30,18 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   constructor(s2sMessagingService: S2sMessagingService | null, socketIoService) {
   constructor(s2sMessagingService: S2sMessagingService | null, socketIoService) {
+    super(ExternalGroupProviderType.keycloak, s2sMessagingService, socketIoService);
+  }
+
+  init(authProviderType: 'oidc' | 'saml'): void {
     const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
     const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
     const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
     const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
     const kcGroupSyncClientRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm');
     const kcGroupSyncClientRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm');
     const kcGroupDescriptionAttribute = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute');
     const kcGroupDescriptionAttribute = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute');
 
 
-    super(ExternalGroupProviderType.keycloak, s2sMessagingService, socketIoService);
     this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
     this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
     this.realm = kcGroupRealm;
     this.realm = kcGroupRealm;
     this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
     this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
-  }
-
-  init(authProviderType: 'oidc' | 'saml'): void {
     this.authProviderType = authProviderType;
     this.authProviderType = authProviderType;
     this.isInitialized = true;
     this.isInitialized = true;
   }
   }

+ 39 - 1
apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts

@@ -63,6 +63,44 @@ describe('archiveUrl()', () => {
     const { archiveUrl } = githubUrl;
     const { archiveUrl } = githubUrl;
 
 
     // then
     // then
-    expect(archiveUrl).toEqual('https://github.com/org/repos/archive/refs/heads/fix/bug.zip');
+    expect(archiveUrl).toEqual('https://github.com/org/repos/archive/refs/heads/fix%2Fbug.zip');
   });
   });
 });
 });
+
+describe('extractedArchiveDirName()', () => {
+
+  describe('certain characters in the branch name are converted to slashes, and if they are consecutive, they become a single hyphen', () => {
+    it.concurrent.each`
+      branchName
+      ${'a"\'!,;-=@`]<>|&{}()$%+#/b'}
+      ${'a---b'}
+    `("'$branchName'", ({ branchName }) => {
+      // setup
+      const githubUrl = new GitHubUrl('https://github.com/org/repos', branchName);
+
+      // when
+      const { extractedArchiveDirName } = githubUrl;
+
+      // then
+      expect(extractedArchiveDirName).toEqual('a-b');
+    });
+  });
+
+  describe('when no certain characters in the branch name', () => {
+    it.concurrent.each`
+      branchName
+      ${'a.b'}
+      ${'a_b'}
+    `("'$branchName'", ({ branchName }) => {
+      // setup
+      const githubUrl = new GitHubUrl('https://github.com/org/repos', branchName);
+
+      // when
+      const { extractedArchiveDirName } = githubUrl;
+
+      // then
+      expect(extractedArchiveDirName).toEqual(branchName);
+    });
+  });
+
+});

+ 8 - 1
apps/app/src/features/growi-plugin/server/models/vo/github-url.ts

@@ -2,6 +2,8 @@ import sanitize from 'sanitize-filename';
 
 
 // https://regex101.com/r/fK2rV3/1
 // https://regex101.com/r/fK2rV3/1
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
+// https://regex101.com/r/YhZVsj/1
+const sanitizeChars = new RegExp(/[^a-zA-Z_.]+/g);
 
 
 export class GitHubUrl {
 export class GitHubUrl {
 
 
@@ -24,10 +26,15 @@ export class GitHubUrl {
   }
   }
 
 
   get archiveUrl(): string {
   get archiveUrl(): string {
-    const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/heads/${this.branchName}.zip`, 'https://github.com');
+    const encodedBranchName = encodeURIComponent(this.branchName);
+    const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/heads/${encodedBranchName}.zip`, 'https://github.com');
     return ghUrl.toString();
     return ghUrl.toString();
   }
   }
 
 
+  get extractedArchiveDirName(): string {
+    return this._branchName.replaceAll(sanitizeChars, '-');
+  }
+
   constructor(url: string, branchName = 'main') {
   constructor(url: string, branchName = 'main') {
 
 
     let matched;
     let matched;

+ 9 - 12
apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts

@@ -8,9 +8,8 @@ import { importPackageJson, validateGrowiDirective } from '@growi/pluginkit/dist
 // eslint-disable-next-line no-restricted-imports
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
 import axios from 'axios';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
-import sanitize from 'sanitize-filename';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
-import unzipper from 'unzipper';
+import unzipStream from 'unzip-stream';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -77,11 +76,11 @@ export class GrowiPluginService implements IGrowiPluginService {
 
 
           // TODO: imprv Document version and repository version possibly different.
           // TODO: imprv Document version and repository version possibly different.
           const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.ghBranch);
           const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.ghBranch);
-          const { reposName, branchName, archiveUrl } = ghUrl;
+          const { reposName, archiveUrl, extractedArchiveDirName } = ghUrl;
 
 
-          const zipFilePath = path.join(PLUGIN_STORING_PATH, `${branchName}.zip`);
+          const zipFilePath = path.join(PLUGIN_STORING_PATH, `${extractedArchiveDirName}.zip`);
           const unzippedPath = PLUGIN_STORING_PATH;
           const unzippedPath = PLUGIN_STORING_PATH;
-          const unzippedReposPath = path.join(PLUGIN_STORING_PATH, `${reposName}-${branchName}`);
+          const unzippedReposPath = path.join(PLUGIN_STORING_PATH, `${reposName}-${extractedArchiveDirName}`);
 
 
           try {
           try {
             // download github repository to local file system
             // download github repository to local file system
@@ -111,16 +110,14 @@ export class GrowiPluginService implements IGrowiPluginService {
   async install(origin: IGrowiPluginOrigin): Promise<string> {
   async install(origin: IGrowiPluginOrigin): Promise<string> {
     const ghUrl = new GitHubUrl(origin.url, origin.ghBranch);
     const ghUrl = new GitHubUrl(origin.url, origin.ghBranch);
     const {
     const {
-      organizationName, reposName, branchName, archiveUrl,
+      organizationName, reposName, archiveUrl, extractedArchiveDirName,
     } = ghUrl;
     } = ghUrl;
 
 
-    const sanitizedBranchName = sanitize(branchName);
-
     const installedPath = `${organizationName}/${reposName}`;
     const installedPath = `${organizationName}/${reposName}`;
 
 
     const organizationPath = path.join(PLUGIN_STORING_PATH, organizationName);
     const organizationPath = path.join(PLUGIN_STORING_PATH, organizationName);
-    const zipFilePath = path.join(organizationPath, `${reposName}-${sanitizedBranchName}.zip`);
-    const temporaryReposPath = path.join(organizationPath, `${reposName}-${sanitizedBranchName}`);
+    const zipFilePath = path.join(organizationPath, `${reposName}-${extractedArchiveDirName}.zip`);
+    const temporaryReposPath = path.join(organizationPath, `${reposName}-${extractedArchiveDirName}`);
     const reposPath = path.join(organizationPath, reposName);
     const reposPath = path.join(organizationPath, reposName);
 
 
     if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
     if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
@@ -205,9 +202,9 @@ export class GrowiPluginService implements IGrowiPluginService {
   private async unzip(zipFilePath: fs.PathLike, destPath: fs.PathLike): Promise<void> {
   private async unzip(zipFilePath: fs.PathLike, destPath: fs.PathLike): Promise<void> {
     try {
     try {
       const stream = fs.createReadStream(zipFilePath);
       const stream = fs.createReadStream(zipFilePath);
-      const unzipStream = stream.pipe(unzipper.Extract({ path: destPath }));
+      const unzipFileStream = stream.pipe(unzipStream.Extract({ path: destPath.toString() }));
 
 
-      await streamToPromise(unzipStream);
+      await streamToPromise(unzipFileStream);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);

+ 3 - 1
apps/app/src/server/events/user.ts

@@ -7,6 +7,8 @@ import mongoose from 'mongoose';
 import type { PageModel } from '~/server/models/page';
 import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { deleteCompletelyUserHomeBySystem } from '../service/page/delete-completely-user-home-by-system';
+
 const logger = loggerFactory('growi:events:user');
 const logger = loggerFactory('growi:events:user');
 
 
 class UserEvent extends EventEmitter {
 class UserEvent extends EventEmitter {
@@ -30,7 +32,7 @@ class UserEvent extends EventEmitter {
       // Since the type of page.creator is 'any', we resort to the following comparison,
       // 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.
       // 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()) {
       if (page != null && page.creator != null && page.creator.toString() !== user._id.toString()) {
-        await this.crowi.pageService.deleteCompletelyUserHomeBySystem(userHomepagePath);
+        await deleteCompletelyUserHomeBySystem(userHomepagePath, this.crowi.pageService);
         page = null;
         page = null;
       }
       }
 
 

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

@@ -66,6 +66,7 @@ export type CreateMethod = (path: string, body: string, user, options: PageCreat
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
   [x: string]: any; // for obsolete static methods
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
+  findByPath(path: string, includeEmpty?: boolean): Promise<PageDocument | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>
   countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>
   countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>

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

@@ -8,6 +8,7 @@ import Activity from '~/server/models/activity';
 import ExternalAccount from '~/server/models/external-account';
 import ExternalAccount from '~/server/models/external-account';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
+import { deleteCompletelyUserHomeBySystem } from '~/server/service/page/delete-completely-user-home-by-system';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -819,7 +820,7 @@ module.exports = (crowi) => {
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
 
 
       if (isUsersHomepageDeletionEnabled && isForceDeleteUserHomepageOnUserDeletion) {
       if (isUsersHomepageDeletionEnabled && isForceDeleteUserHomepageOnUserDeletion) {
-        crowi.pageService.deleteCompletelyUserHomeBySystem(homepagePath);
+        deleteCompletelyUserHomeBySystem(homepagePath, crowi.pageService);
       }
       }
 
 
       return res.apiv3({ user: serializedUser });
       return res.apiv3({ user: serializedUser });

+ 2 - 0
apps/app/src/server/service/page/consts.ts

@@ -0,0 +1,2 @@
+export const BULK_REINDEX_SIZE = 100;
+export const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;

+ 121 - 0
apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts

@@ -0,0 +1,121 @@
+import type EventEmitter from 'events';
+
+import mongoose from 'mongoose';
+import { vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+
+import { getPageSchema } from '~/server/models/obsolete-page';
+import { configManager } from '~/server/service/config-manager';
+
+import pageModel from '../../models/page';
+
+import { deleteCompletelyUserHomeBySystem } from './delete-completely-user-home-by-system';
+import type { IPageService } from './page-service';
+
+// TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
+// ref: https://github.com/vitest-dev/vitest/issues/846
+const userSchema = new mongoose.Schema({
+  name: { type: String },
+  username: { type: String, required: true, unique: true },
+  email: { type: String, unique: true, sparse: true },
+}, {
+  timestamps: true,
+});
+const User = mongoose.model('User', userSchema);
+
+describe('delete-completely-user-home-by-system test', () => {
+  let Page;
+
+  const initialEnv = process.env;
+
+  const userId1 = new mongoose.Types.ObjectId();
+  const user1HomepageId = new mongoose.Types.ObjectId();
+
+  beforeAll(async() => {
+    // setup page model
+    getPageSchema(null);
+    pageModel(null);
+    Page = mongoose.model('Page');
+
+    // setup config
+    await configManager.loadConfigs();
+    await configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
+    const isV5Compatible = configManager.getConfig('crowi', 'app:isV5Compatible');
+    expect(isV5Compatible).toBeTruthy();
+
+    // setup user documents
+    const user1 = await User.create({
+      _id: userId1, name: 'user1', username: 'user1', email: 'user1@example.com',
+    });
+
+    // setup page documents
+    await Page.insertMany([
+      {
+        _id: user1HomepageId,
+        path: '/user/user1',
+        grant: Page.GRANT_PUBLIC,
+        creator: user1,
+        lastUpdateUser: user1,
+        parent: new mongoose.Types.ObjectId(),
+        descendantCount: 2,
+        isEmpty: false,
+        status: 'published',
+      },
+      {
+        path: '/user/user1/subpage1',
+        grant: Page.GRANT_PUBLIC,
+        creator: user1,
+        lastUpdateUser: user1,
+        parent: user1HomepageId,
+      },
+      {
+        path: '/user/user1/subpage2',
+        grant: Page.GRANT_PUBLIC,
+        creator: user1,
+        lastUpdateUser: user1,
+        parent: user1HomepageId,
+      },
+    ]);
+  });
+
+  afterAll(() => {
+    process.env = initialEnv;
+    Page.deleteMany({});
+  });
+
+  describe('deleteCompletelyUserHomeBySystem()', () => {
+    // setup
+    const mockUpdateDescendantCountOfAncestors = vi.fn().mockImplementation(() => Promise.resolve());
+    const mockDeleteCompletelyOperation = vi.fn().mockImplementation(() => Promise.resolve());
+    const mockPageEvent = mock<EventEmitter>();
+    const mockDeleteMultipleCompletely = vi.fn().mockImplementation(() => Promise.resolve());
+
+    const mockPageService: IPageService = {
+      updateDescendantCountOfAncestors: mockUpdateDescendantCountOfAncestors,
+      deleteCompletelyOperation: mockDeleteCompletelyOperation,
+      getEventEmitter: () => mockPageEvent,
+      deleteMultipleCompletely: mockDeleteMultipleCompletely,
+    };
+
+    it('should call used page service functions', async() => {
+      // when
+      const existsUserHomepagePath = '/user/user1';
+      await deleteCompletelyUserHomeBySystem(existsUserHomepagePath, mockPageService);
+
+      // then
+      expect(mockUpdateDescendantCountOfAncestors).toHaveBeenCalled();
+      expect(mockDeleteCompletelyOperation).toHaveBeenCalled();
+      expect(mockPageEvent.emit).toHaveBeenCalled();
+      expect(mockDeleteMultipleCompletely).toHaveBeenCalled();
+    });
+
+    it('should throw error if userHomepage is not exists', async() => {
+      // when
+      const notExistsUserHomepagePath = '/user/not_exists_user';
+      const deleteUserHomepageFunction = deleteCompletelyUserHomeBySystem(notExistsUserHomepagePath, mockPageService);
+
+      // then
+      expect(deleteUserHomepageFunction).rejects.toThrow('user homepage is not found.');
+    });
+  });
+});

+ 122 - 0
apps/app/src/server/service/page/delete-completely-user-home-by-system.ts

@@ -0,0 +1,122 @@
+import { Writable } from 'stream';
+
+import { getIdForRef } from '@growi/core';
+import type { IPage, Ref } from '@growi/core';
+import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
+import mongoose from 'mongoose';
+import streamToPromise from 'stream-to-promise';
+
+import type { PageModel } from '~/server/models/page';
+import { createBatchStream } from '~/server/util/batch-stream';
+import loggerFactory from '~/utils/logger';
+
+import { BULK_REINDEX_SIZE } from './consts';
+import type { IPageService } from './page-service';
+import { shouldUseV4Process } from './should-use-v4-process';
+
+const logger = loggerFactory('growi:services:page');
+
+
+type IPageUnderV5 = Omit<IPage, 'parent'> & { parent: Ref<IPage> }
+
+const _shouldUseV5Process = (page: IPage): page is IPageUnderV5 => {
+  return !shouldUseV4Process(page);
+};
+
+/**
+   * @description This function is intended to be used exclusively for forcibly deleting the user homepage by the system.
+   * It should only be called from within the appropriate context and with caution as it performs a system-level operation.
+   *
+   * @param {string} userHomepagePath - The path of the user's homepage.
+   * @returns {Promise<void>} - A Promise that resolves when the deletion is complete.
+   * @throws {Error} - If an error occurs during the deletion process.
+   */
+export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string, pageService: IPageService): Promise<void> => {
+  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) {
+    const msg = 'user homepage is not found.';
+    logger.error(msg);
+    throw new Error(msg);
+  }
+
+  const shouldUseV5Process = _shouldUseV5Process(userHomepage);
+
+  const ids = [userHomepage._id];
+  const paths = [userHomepage.path];
+
+  try {
+    if (shouldUseV5Process) {
+      // Ensure consistency of ancestors
+      const inc = userHomepage.isEmpty ? -userHomepage.descendantCount : -(userHomepage.descendantCount + 1);
+      await pageService.updateDescendantCountOfAncestors(getIdForRef(userHomepage.parent), inc, true);
+    }
+
+    // Delete the user's homepage
+    await pageService.deleteCompletelyOperation(ids, paths);
+
+    if (shouldUseV5Process) {
+      // Remove leaf empty pages
+      await Page.removeLeafEmptyPagesRecursively(getIdForRef(userHomepage.parent));
+    }
+
+    if (!userHomepage.isEmpty) {
+      // Emit an event for the search service
+      pageService.getEventEmitter().emit('deleteCompletely', userHomepage);
+    }
+
+    const { PageQueryBuilder } = Page;
+
+    // Find descendant pages with system deletion condition
+    const builder = new PageQueryBuilder(Page.find(), true)
+      .addConditionForSystemDeletion()
+      .addConditionToListOnlyDescendants(userHomepage.path, {});
+
+    // Stream processing to delete descendant pages
+    // ────────┤ start │─────────
+    const readStream = await builder
+      .query
+      .lean()
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
+
+    let count = 0;
+
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          // Delete multiple pages completely
+          await pageService.deleteMultipleCompletely(batch, undefined);
+          logger.debug(`Adding pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Adding pages has completed: (totalCount=${count})`);
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(writeStream);
+    // ────────┤ end │─────────
+  }
+  catch (err) {
+    logger.error('Error occurred while deleting user homepage and subpages.', err);
+    throw err;
+  }
+};

+ 46 - 147
apps/app/src/server/service/page.ts → apps/app/src/server/service/page/index.ts

@@ -1,3 +1,4 @@
+import type EventEmitter from 'events';
 import pathlib from 'path';
 import pathlib from 'path';
 import { Readable, Writable } from 'stream';
 import { Readable, Writable } from 'stream';
 
 
@@ -5,7 +6,7 @@ import type {
   Ref, HasObjectId, IUserHasId, IUser,
   Ref, HasObjectId, IUserHasId, IUser,
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
 } from '@growi/core';
 } from '@growi/core';
-import { PageGrant, PageStatus, getIdForRef } from '@growi/core';
+import { PageGrant, PageStatus } from '@growi/core';
 import {
 import {
   pagePathUtils, pathUtils,
   pagePathUtils, pathUtils,
 } from '@growi/core/dist/utils';
 } from '@growi/core/dist/utils';
@@ -32,21 +33,27 @@ import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
-import { Attachment } from '../models';
-import { PathAlreadyExistsError } from '../models/errors';
-import type { IOptionsForCreate, IOptionsForUpdate } from '../models/interfaces/page-operation';
-import PageOperation, { type PageOperationDocument } from '../models/page-operation';
-import type { PageRedirectModel } from '../models/page-redirect';
-import { serializePageSecurely } from '../models/serializers/page-serializer';
-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';
-
-import { configManager } from './config-manager';
-import { preNotifyService } from './pre-notify';
+import { ObjectIdLike } from '../../interfaces/mongoose-utils';
+import { Attachment } from '../../models';
+import { PathAlreadyExistsError } from '../../models/errors';
+import { IOptionsForCreate, IOptionsForUpdate } from '../../models/interfaces/page-operation';
+import PageOperation, { PageOperationDocument } from '../../models/page-operation';
+import { PageRedirectModel } from '../../models/page-redirect';
+import { serializePageSecurely } from '../../models/serializers/page-serializer';
+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';
+import { configManager } from '../config-manager';
+import { preNotifyService } from '../pre-notify';
+
+import { BULK_REINDEX_SIZE, LIMIT_FOR_MULTIPLE_PAGE_OP } from './consts';
+import { IPageService } from './page-service';
+import { shouldUseV4Process } from './should-use-v4-process';
+
+export * from './page-service';
+
 
 
 const debug = require('debug')('growi:services:page');
 const debug = require('debug')('growi:services:page');
 
 
@@ -58,9 +65,6 @@ const {
 
 
 const { addTrailingSlash } = pathUtils;
 const { addTrailingSlash } = pathUtils;
 
 
-const BULK_REINDEX_SIZE = 100;
-const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
-
 // TODO: improve type
 // TODO: improve type
 class PageCursorsForDescendantsFactory {
 class PageCursorsForDescendantsFactory {
 
 
@@ -142,11 +146,16 @@ class PageCursorsForDescendantsFactory {
 
 
 }
 }
 
 
-class PageService {
+
+class PageService implements IPageService {
 
 
   crowi: any;
   crowi: any;
 
 
-  pageEvent: any;
+  pageEvent: EventEmitter & {
+    onCreate,
+    onCreateMany,
+    onAddSeenUsers,
+  };
 
 
   tagEvent: any;
   tagEvent: any;
 
 
@@ -173,6 +182,10 @@ class PageService {
     this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
     this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
   }
   }
 
 
+  getEventEmitter(): EventEmitter {
+    return this.pageEvent;
+  }
+
   canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
   canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
     if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
     if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
 
 
@@ -373,20 +386,6 @@ class PageService {
     };
     };
   }
   }
 
 
-  private shouldUseV4Process(page): boolean {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    const isTrashPage = page.status === Page.STATUS_DELETED;
-    const isPageMigrated = page.parent != null;
-    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    const isRoot = isTopPage(page.path);
-    const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
-
-    const shouldUseV4Process = !isRoot && (!isV5Compatible || !isPageMigrated || isTrashPage || isPageRestricted);
-
-    return shouldUseV4Process;
-  }
-
   private shouldUseV4ProcessForRevert(page): boolean {
   private shouldUseV4ProcessForRevert(page): boolean {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
@@ -455,8 +454,8 @@ class PageService {
     }
     }
 
 
     // Separate v4 & v5 process
     // Separate v4 & v5 process
-    const shouldUseV4Process = this.shouldUseV4Process(page);
-    if (shouldUseV4Process) {
+    const isShouldUseV4Process = shouldUseV4Process(page);
+    if (isShouldUseV4Process) {
       return this.renamePageV4(page, newPagePath, user, options);
       return this.renamePageV4(page, newPagePath, user, options);
     }
     }
 
 
@@ -1021,8 +1020,8 @@ class PageService {
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
 
     // 1. Separate v4 & v5 process
     // 1. Separate v4 & v5 process
-    const shouldUseV4Process = this.shouldUseV4Process(page);
-    if (shouldUseV4Process) {
+    const isShouldUseV4Process = shouldUseV4Process(page);
+    if (isShouldUseV4Process) {
       return this.duplicateV4(page, newPagePath, user, isRecursively);
       return this.duplicateV4(page, newPagePath, user, isRecursively);
     }
     }
 
 
@@ -1446,8 +1445,8 @@ class PageService {
     const Page = mongoose.model('Page') as PageModel;
     const Page = mongoose.model('Page') as PageModel;
 
 
     // Separate v4 & v5 process
     // Separate v4 & v5 process
-    const shouldUseV4Process = this.shouldUseV4Process(page);
-    if (shouldUseV4Process) {
+    const isShouldUseV4Process = shouldUseV4Process(page);
+    if (isShouldUseV4Process) {
       return this.deletePageV4(page, user, options, isRecursively);
       return this.deletePageV4(page, user, options, isRecursively);
     }
     }
     // Validate
     // Validate
@@ -1773,7 +1772,7 @@ class PageService {
     return nDeletedNonEmptyPages;
     return nDeletedNonEmptyPages;
   }
   }
 
 
-  private async deleteCompletelyOperation(pageIds, pagePaths) {
+  async deleteCompletelyOperation(pageIds, pagePaths): Promise<void> {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     const Bookmark = this.crowi.model('Bookmark');
     const Bookmark = this.crowi.model('Bookmark');
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
@@ -1784,7 +1783,7 @@ class PageService {
     const { attachmentService } = this.crowi;
     const { attachmentService } = this.crowi;
     const attachments = await Attachment.find({ page: { $in: pageIds } });
     const attachments = await Attachment.find({ page: { $in: pageIds } });
 
 
-    return Promise.all([
+    await Promise.all([
       Bookmark.deleteMany({ page: { $in: pageIds } }),
       Bookmark.deleteMany({ page: { $in: pageIds } }),
       Comment.deleteMany({ page: { $in: pageIds } }),
       Comment.deleteMany({ page: { $in: pageIds } }),
       PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
       PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
@@ -1797,7 +1796,7 @@ class PageService {
   }
   }
 
 
   // delete multiple pages
   // delete multiple pages
-  private async deleteMultipleCompletely(pages, user, options = {}) {
+  async deleteMultipleCompletely(pages, user) {
     const ids = pages.map(page => (page._id));
     const ids = pages.map(page => (page._id));
     const paths = pages.map(page => (page.path));
     const paths = pages.map(page => (page.path));
 
 
@@ -1825,8 +1824,8 @@ class PageService {
     }
     }
 
 
     // v4 compatible process
     // v4 compatible process
-    const shouldUseV4Process = this.shouldUseV4Process(page);
-    if (shouldUseV4Process) {
+    const isShouldUseV4Process = shouldUseV4Process(page);
+    if (isShouldUseV4Process) {
       return this.deleteCompletelyV4(page, user, options, isRecursively, preventEmitting);
       return this.deleteCompletelyV4(page, user, options, isRecursively, preventEmitting);
     }
     }
 
 
@@ -2004,7 +2003,7 @@ class PageService {
 
 
         try {
         try {
           count += batch.length;
           count += batch.length;
-          await deleteMultipleCompletely(batch, user, options);
+          await deleteMultipleCompletely(batch, user);
           const subscribedUsers = await Subscription.getSubscriptions(batch);
           const subscribedUsers = await Subscription.getSubscriptions(batch);
           subscribedUsers.forEach((eachUser) => {
           subscribedUsers.forEach((eachUser) => {
             descendantsSubscribedSets.add(eachUser);
             descendantsSubscribedSets.add(eachUser);
@@ -2056,106 +2055,6 @@ class PageService {
     }
     }
   }
   }
 
 
-  /**
-   * @description This function is intended to be used exclusively for forcibly deleting the user homepage by the system.
-   * It should only be called from within the appropriate context and with caution as it performs a system-level operation.
-   *
-   * @param {string} userHomepagePath - The path of the user's homepage.
-   * @returns {Promise<void>} - A Promise that resolves when the deletion is complete.
-   * @throws {Error} - If an error occurs during the deletion process.
-   */
-  async deleteCompletelyUserHomeBySystem(userHomepagePath: string): Promise<void> {
-    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) {
-      const msg = 'user homepage 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(parentId, inc, true);
-      }
-
-      // Delete the user's homepage
-      await this.deleteCompletelyOperation(ids, paths);
-
-      if (!shouldUseV4Process) {
-        // Remove leaf empty pages
-        await Page.removeLeafEmptyPagesRecursively(parentId);
-      }
-
-      if (!userHomepage.isEmpty) {
-        // Emit an event for the search service
-        this.pageEvent.emit('deleteCompletely', userHomepage);
-      }
-
-      const { PageQueryBuilder } = Page;
-
-      // Find descendant pages with system deletion condition
-      const builder = new PageQueryBuilder(Page.find(), true)
-        .addConditionForSystemDeletion()
-        .addConditionToListOnlyDescendants(userHomepage.path, {});
-
-      // Stream processing to delete descendant pages
-      // ────────┤ start │─────────
-      const readStream = await builder
-        .query
-        .lean()
-        .cursor({ batchSize: BULK_REINDEX_SIZE });
-
-      let count = 0;
-
-      const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
-      const writeStream = new Writable({
-        objectMode: true,
-        async write(batch, encoding, callback) {
-          try {
-            count += batch.length;
-            // Delete multiple pages completely
-            await deleteMultipleCompletely(batch, null, {});
-            logger.debug(`Adding pages progressing: (count=${count})`);
-          }
-          catch (err) {
-            logger.error('addAllPages error on add anyway: ', err);
-          }
-          callback();
-        },
-        final(callback) {
-          logger.debug(`Adding pages has completed: (totalCount=${count})`);
-          callback();
-        },
-      });
-
-      readStream
-        .pipe(createBatchStream(BULK_REINDEX_SIZE))
-        .pipe(writeStream);
-
-      await streamToPromise(writeStream);
-      // ────────┤ end │─────────
-    }
-    catch (err) {
-      logger.error('Error occurred while deleting user homepage and subpages.', err);
-      throw err;
-    }
-  }
-
   // use the same process in both v4 and v5
   // use the same process in both v4 and v5
   private async revertDeletedDescendants(pages, user) {
   private async revertDeletedDescendants(pages, user) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');

+ 12 - 0
apps/app/src/server/service/page/page-service.ts

@@ -0,0 +1,12 @@
+import type EventEmitter from 'events';
+
+import type { IUser } from '@growi/core';
+
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
+export interface IPageService {
+  updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
+  deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
+  getEventEmitter: () => EventEmitter,
+  deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
+}

+ 20 - 0
apps/app/src/server/service/page/should-use-v4-process.ts

@@ -0,0 +1,20 @@
+import type { IPage } from '@growi/core';
+import { isTopPage } from '@growi/core/dist/utils/page-path-utils';
+import mongoose from 'mongoose';
+
+import { PageModel } from '~/server/models/page';
+import { configManager } from '~/server/service/config-manager';
+
+export const shouldUseV4Process = (page: IPage): boolean => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+
+  const isTrashPage = page.status === Page.STATUS_DELETED;
+  const isPageMigrated = page.parent != null;
+  const isV5Compatible = configManager.getConfig('crowi', 'app:isV5Compatible');
+  const isRoot = isTopPage(page.path);
+  const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
+
+  const shouldUseV4Process = !isRoot && (!isV5Compatible || !isPageMigrated || isTrashPage || isPageRestricted);
+
+  return shouldUseV4Process;
+};

+ 5 - 0
apps/app/src/server/service/passport.ts

@@ -849,6 +849,11 @@ class PassportService implements S2sMessageHandlable {
     }
     }
 
 
     const { field, term } = luceneRule;
     const { field, term } = luceneRule;
+
+    if (field == null) {
+      return true;
+    }
+
     const unescapedField = this.literalUnescape(field);
     const unescapedField = this.literalUnescape(field);
     if (unescapedField === '<implicit>') {
     if (unescapedField === '<implicit>') {
       return attributes[term] != null;
       return attributes[term] != null;

+ 1 - 1
apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts

@@ -106,7 +106,7 @@ context('Editor while uploading to a new page', () => {
 
 
 });
 });
 
 
-context.skip('Editor while navigation', () => {
+context('Editor while navigation', () => {
 
 
   const ssPrefix = 'editor-while-navigation-';
   const ssPrefix = 'editor-while-navigation-';
 
 

+ 1 - 0
apps/app/test/integration/service/passport.test.js

@@ -24,6 +24,7 @@ describe('PassportService test', () => {
     let i = 0;
     let i = 0;
     describe.each`
     describe.each`
       conditionId | departments   | positions     | ruleStr                                                         | expected
       conditionId | departments   | positions     | ruleStr                                                         | expected
+      ${i++}      | ${undefined}  | ${undefined}  | ${' '}                                                          | ${true}
       ${i++}      | ${undefined}  | ${undefined}  | ${'Department: A'}                                              | ${false}
       ${i++}      | ${undefined}  | ${undefined}  | ${'Department: A'}                                              | ${false}
       ${i++}      | ${[]}         | ${['Leader']} | ${'Position'}                                                   | ${true}
       ${i++}      | ${[]}         | ${['Leader']} | ${'Position'}                                                   | ${true}
       ${i++}      | ${[]}         | ${['Leader']} | ${'Position: Leader'}                                           | ${true}
       ${i++}      | ${[]}         | ${['Leader']} | ${'Position: Leader'}                                           | ${true}

+ 16 - 1
yarn.lock

@@ -4300,6 +4300,13 @@
   resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
   resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
   integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
   integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
 
 
+"@types/unzip-stream@^0.3.4":
+  version "0.3.4"
+  resolved "https://registry.yarnpkg.com/@types/unzip-stream/-/unzip-stream-0.3.4.tgz#6e762ef8b8fcf902ba7d7999a149a3af84064144"
+  integrity sha512-ud0vtsNRF+joUCyvNMyo0j5DKX2Lh/im+xVgRzBEsfHhQYZ+i4fKTveova9XxLzt6Jl6G0e/0mM4aC0gqZYSnA==
+  dependencies:
+    "@types/node" "*"
+
 "@types/url-join@^4.0.2":
 "@types/url-join@^4.0.2":
   version "4.0.2"
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-4.0.2.tgz#e8774924c7f492626ee3309baf6697f80e1414df"
   resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-4.0.2.tgz#e8774924c7f492626ee3309baf6697f80e1414df"
@@ -5320,7 +5327,7 @@ binary-extensions@^2.0.0:
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
   integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
   integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
 
-binary@~0.3.0:
+binary@^0.3.0, binary@~0.3.0:
   version "0.3.0"
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79"
   resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79"
   integrity sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=
   integrity sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=
@@ -17760,6 +17767,14 @@ untildify@^4.0.0:
   resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b"
   resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b"
   integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==
   integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==
 
 
+unzip-stream@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/unzip-stream/-/unzip-stream-0.3.1.tgz#2333b5cd035d29db86fb701ca212cf8517400083"
+  integrity sha512-RzaGXLNt+CW+T41h1zl6pGz3EaeVhYlK+rdAap+7DxW5kqsqePO8kRtWPaCiVqdhZc86EctSPVYNix30YOMzmw==
+  dependencies:
+    binary "^0.3.0"
+    mkdirp "^0.5.1"
+
 unzipper@^0.10.5:
 unzipper@^0.10.5:
   version "0.10.5"
   version "0.10.5"
   resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.5.tgz#4d189ae6f8af634b26efe1a1817c399e0dd4a1a0"
   resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.5.tgz#4d189ae6f8af634b26efe1a1817c399e0dd4a1a0"