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

Merge pull request #9007 from weseek/master

Release v7.0.17
Yuki Takei 1 год назад
Родитель
Сommit
e331fe6931
100 измененных файлов с 901 добавлено и 638 удалено
  1. 5 3
      .github/workflows/auto-labeling.yml
  2. 1 0
      .github/workflows/ci-app.yml
  3. 4 2
      .github/workflows/reusable-app-prod.yml
  4. 7 4
      apps/app/package.json
  5. 227 0
      apps/app/playwright/30-search/search.spect.ts
  6. 1 0
      apps/app/public/static/locales/en_US/translation.json
  7. 1 0
      apps/app/public/static/locales/fr_FR/translation.json
  8. 1 0
      apps/app/public/static/locales/ja_JP/translation.json
  9. 1 0
      apps/app/public/static/locales/zh_CN/translation.json
  10. 1 1
      apps/app/src/client/components/Admin/AuditLog/ActivityTable.tsx
  11. 1 1
      apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx
  12. 2 2
      apps/app/src/client/components/Admin/G2GDataTransferExportForm.tsx
  13. 11 9
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  14. 6 6
      apps/app/src/client/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  15. 5 6
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  16. 1 1
      apps/app/src/client/components/Common/CountBadge.tsx
  17. 4 3
      apps/app/src/client/components/Me/ProfileImageSettings.tsx
  18. 21 5
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  19. 6 3
      apps/app/src/client/components/PageComment.tsx
  20. 9 4
      apps/app/src/client/components/PageControls/PageControls.tsx
  21. 2 1
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  22. 4 11
      apps/app/src/client/components/SearchPage/SearchResultContent.tsx
  23. 3 1
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  24. 3 1
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  25. 1 8
      apps/app/src/client/services/update-page/index.ts
  26. 7 0
      apps/app/src/client/services/update-page/update-page.ts
  27. 25 0
      apps/app/src/client/services/update-page/use-update-page.tsx
  28. 1 2
      apps/app/src/components/Layout/AdminLayout.tsx
  29. 1 1
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  30. 5 5
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  31. 2 2
      apps/app/src/interfaces/crowi-request.ts
  32. 4 2
      apps/app/src/migrations/20180926134048-make-email-unique.js
  33. 8 6
      apps/app/src/migrations/20181019114028-abolish-page-group-relation.js
  34. 3 3
      apps/app/src/migrations/20190618104011-add-config-app-installed.js
  35. 4 2
      apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js
  36. 3 3
      apps/app/src/migrations/20200620203632-normalize-locale-id.js
  37. 4 3
      apps/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js
  38. 4 3
      apps/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  39. 4 4
      apps/app/src/migrations/20211005120030-slack-app-integration-rename-keys.js
  40. 3 2
      apps/app/src/migrations/20220411114257-set-sparse-option-to-slack-member-id.js
  41. 1 1
      apps/app/src/migrations/20220613064207-add-attachment-type-to-existing-attachments.js
  42. 3 4
      apps/app/src/migrations/20230731075753-add_installed_date_to_config.js
  43. 2 1
      apps/app/src/models/admin/growi-archive-import-option.js
  44. 0 19
      apps/app/src/models/admin/import-option-for-pages.js
  45. 31 0
      apps/app/src/models/admin/import-option-for-pages.ts
  46. 3 5
      apps/app/src/models/admin/import-option-for-revisions.js
  47. 13 5
      apps/app/src/pages/[[...path]].page.tsx
  48. 30 7
      apps/app/src/pages/_app.page.tsx
  49. 4 13
      apps/app/src/pages/_document.page.tsx
  50. 7 0
      apps/app/src/pages/share/[[...path]].page.tsx
  51. 22 6
      apps/app/src/pages/utils/commons.ts
  52. 0 47
      apps/app/src/server/console.js
  53. 17 30
      apps/app/src/server/crowi/index.js
  54. 75 0
      apps/app/src/server/crowi/setup-models.ts
  55. 6 8
      apps/app/src/server/events/user.ts
  56. 2 2
      apps/app/src/server/interfaces/mongoose-utils.ts
  57. 2 1
      apps/app/src/server/middlewares/access-token-parser.js
  58. 3 1
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  59. 3 1
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  60. 3 3
      apps/app/src/server/models/attachment.ts
  61. 3 1
      apps/app/src/server/models/bookmark.js
  62. 3 3
      apps/app/src/server/models/external-account.ts
  63. 0 27
      apps/app/src/server/models/index.ts
  64. 6 6
      apps/app/src/server/models/named-query.ts
  65. 28 28
      apps/app/src/server/models/page.ts
  66. 1 3
      apps/app/src/server/models/password-reset-order.ts
  67. 15 6
      apps/app/src/server/models/revision.ts
  68. 1 1
      apps/app/src/server/models/serializers/bookmark-serializer.js
  69. 0 1
      apps/app/src/server/models/serializers/index.ts
  70. 1 1
      apps/app/src/server/models/serializers/page-serializer.js
  71. 1 1
      apps/app/src/server/models/serializers/revision-serializer.js
  72. 1 1
      apps/app/src/server/models/serializers/user-group-relation-serializer.js
  73. 0 35
      apps/app/src/server/models/serializers/user-serializer.js
  74. 1 2
      apps/app/src/server/models/share-link.ts
  75. 10 1
      apps/app/src/server/models/slack-app-integration.js
  76. 3 4
      apps/app/src/server/models/update-post.ts
  77. 4 5
      apps/app/src/server/models/user-group-relation.ts
  78. 1 3
      apps/app/src/server/models/user-group.ts
  79. 12 5
      apps/app/src/server/models/user.js
  80. 39 0
      apps/app/src/server/repl.ts
  81. 12 10
      apps/app/src/server/routes/apiv3/activity.ts
  82. 4 5
      apps/app/src/server/routes/apiv3/attachment.js
  83. 2 1
      apps/app/src/server/routes/apiv3/bookmarks.js
  84. 1 1
      apps/app/src/server/routes/apiv3/customize-setting.js
  85. 1 1
      apps/app/src/server/routes/apiv3/forgot-password.js
  86. 13 6
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  87. 36 44
      apps/app/src/server/routes/apiv3/import.js
  88. 31 17
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  89. 9 4
      apps/app/src/server/routes/apiv3/invited.ts
  90. 1 1
      apps/app/src/server/routes/apiv3/notification-setting.js
  91. 0 32
      apps/app/src/server/routes/apiv3/overwrite-params/attachmentFiles.chunks.js
  92. 0 62
      apps/app/src/server/routes/apiv3/overwrite-params/pages.js
  93. 0 31
      apps/app/src/server/routes/apiv3/overwrite-params/revisions.js
  94. 10 9
      apps/app/src/server/routes/apiv3/page-listing.ts
  95. 2 3
      apps/app/src/server/routes/apiv3/page/create-page.ts
  96. 3 2
      apps/app/src/server/routes/apiv3/page/index.ts
  97. 7 3
      apps/app/src/server/routes/apiv3/page/update-page.ts
  98. 2 2
      apps/app/src/server/routes/apiv3/pages/index.js
  99. 2 3
      apps/app/src/server/routes/apiv3/revisions.js
  100. 2 3
      apps/app/src/server/routes/apiv3/user-group-relation.js

+ 5 - 3
.github/workflows/auto-labeling.yml

@@ -20,7 +20,8 @@ jobs:
     runs-on: ubuntu-latest
 
     if: |
-      !contains(github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog')
+      (!contains( github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog' )
+        && !startsWith( github.head_ref, 'changeset-release/' ))
 
     steps:
       - uses: release-drafter/release-drafter@v5
@@ -33,8 +34,9 @@ jobs:
     runs-on: ubuntu-latest
 
     if: |
-      (!contains( github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog' ) &&
-        !startsWith( github.head_ref, 'dependabot/' ))
+      (!contains( github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog' )
+        && !startsWith( github.head_ref, 'changeset-release/' )
+        && !startsWith( github.head_ref, 'dependabot/' ))
 
     steps:
       - uses: amannn/action-semantic-pull-request@v5

+ 1 - 0
.github/workflows/ci-app.yml

@@ -5,6 +5,7 @@ on:
     branches-ignore:
       - release/**
       - rc/**
+      - changeset-release/**
     paths:
       - .github/workflows/ci-app.yml
       - .eslint*

+ 4 - 2
.github/workflows/reusable-app-prod.yml

@@ -213,7 +213,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['21', '30', '50']
+        spec-group: ['21', '50']
 
     services:
       mongodb:
@@ -348,7 +348,9 @@ jobs:
 
     runs-on: ubuntu-latest
     container:
-      image: mcr.microsoft.com/playwright:latest
+      # Match the Playwright version
+      # https://github.com/microsoft/playwright/issues/20010
+      image: mcr.microsoft.com/playwright:v1.46.0-jammy
 
     strategy:
       fail-fast: false

+ 7 - 4
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.16",
+  "version": "7.0.17-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -44,7 +44,8 @@
     "vitest:run:components": "vitest run -c vitest.config.components.ts src --coverage",
     "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
-    "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
+    "console": "yarn repl",
+    "repl": "yarn cross-env NODE_ENV=development yarn ts-node src/server/repl.ts",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
@@ -208,7 +209,7 @@
     "usehooks-ts": "^2.6.0",
     "validator": "^13.7.0",
     "ws": "^8.17.1",
-    "xss": "^1.0.14",
+    "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",
     "y-socket.io": "^1.1.3",
     "yjs": "^13.6.18"
@@ -216,7 +217,8 @@
   "// comments for defDependencies": {
     "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798",
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
-    "handsontable": "v7.0.0 or above is no loger MIT lisence."
+    "handsontable": "v7.0.0 or above is no loger MIT lisence.",
+    "mongodb": "mongoose which is used requires mongo@4.16.0."
   },
   "devDependencies": {
     "@growi/core-styles": "link:../../packages/core-styles",
@@ -260,6 +262,7 @@
     "jest-localstorage-mock": "^2.4.14",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
+    "mongodb": "4.16.0",
     "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",

+ 227 - 0
apps/app/playwright/30-search/search.spect.ts

@@ -0,0 +1,227 @@
+import { test, expect } from '@playwright/test';
+
+test('Search page with "q" param is successfully loaded', async({ page }) => {
+  // Navigate to the search page with query parameters
+  await page.goto('/_search?q=alerts');
+
+  // Confirm search result elements are visible
+  await expect(page.getByTestId('search-result-base')).toBeVisible();
+  await expect(page.getByTestId('search-result-list')).toBeVisible();
+  await expect(page.getByTestId('search-result-content')).toBeVisible();
+  await expect(page.locator('.wiki')).toBeVisible();
+});
+
+test('checkboxes behaviors', async({ page }) => {
+  // Navigate to the search page with query parameters
+  await page.goto('/_search?q=alerts');
+
+  // Confirm search result elements are visible
+  await expect(page.getByTestId('search-result-base')).toBeVisible();
+  await expect(page.getByTestId('search-result-list')).toBeVisible();
+  await expect(page.getByTestId('search-result-content')).toBeVisible();
+  await expect(page.locator('.wiki')).toBeVisible();
+
+  // Click the first checkbox
+  await page.getByTestId('cb-select').first().click({ force: true });
+
+  // Unclick the first checkbox
+  await page.getByTestId('cb-select').first().click({ force: true });
+
+  // Click the select all checkbox
+  await page.getByTestId('delete-control-button').first().click({ force: true });
+  await page.getByTestId('cb-select-all').click({ force: true });
+
+  // Unclick the first checkbox after selecting all
+  await page.getByTestId('cb-select').first().click({ force: true });
+
+  // Click the first checkbox again
+  await page.getByTestId('cb-select').first().click({ force: true });
+
+  // Unclick the select all checkbox
+  await page.getByTestId('cb-select').first().click({ force: true });
+});
+
+
+test('successfully loads /_private-legacy-pages', async({ page }) => {
+  await page.goto('/_private-legacy-pages');
+
+  // Confirm search result elements are visible
+  await expect(page.locator('[data-testid="search-result-base"]')).toBeVisible();
+  await expect(page.locator('[data-testid="search-result-private-legacy-pages"]')).toBeVisible();
+});
+
+test('Search all pages by word', async({ page }) => {
+  await page.goto('/');
+  await page.getByTestId('open-search-modal-button').click();
+  await expect(page.getByTestId('search-modal')).toBeVisible();
+  await page.locator('.form-control').fill('sand');
+  await expect(page.locator('.search-menu-item').first()).toBeVisible();
+});
+
+test.describe.serial('Search all pages', () => {
+  const tag = 'help';
+  const searchText = `tag:${tag}`;
+
+  test('Successfully created tags', async({ page }) => {
+    await page.goto('/');
+
+    // open Edit Tags Modal to add tag
+    await page.locator('.grw-side-contents-sticky-container').isVisible();
+    await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
+    await expect(page.locator('#edit-tag-modal')).toBeVisible();
+    await page.locator('.rbt-input-main').fill(tag);
+    await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
+    await page.getByTestId('tag-edit-done-btn').click();
+
+  });
+
+  test('Search all pages by tag is successfully loaded', async({ page }) => {
+    await page.goto('/');
+
+    // Search
+    await page.getByTestId('open-search-modal-button').click();
+    await expect(page.getByTestId('search-modal')).toBeVisible();
+    await page.locator('.form-control').fill(searchText);
+    await page.getByTestId('search-all-menu-item').click();
+
+    // Confirm search result elements are visible
+    const searchResultList = page.getByTestId('search-result-list');
+    await expect(searchResultList).toBeVisible();
+    await expect(searchResultList.locator('li')).toHaveCount(1);
+  });
+
+  test('Successfully order page search results by tag', async({ page }) => {
+    await page.goto('/');
+
+    await page.locator('.grw-tag-simple-bar').locator('a').click();
+
+    expect(page.getByTestId('search-result-base')).toBeVisible();
+    expect(page.getByTestId('search-result-list')).toBeVisible();
+    expect(page.getByTestId('search-result-content')).toBeVisible();
+  });
+});
+
+test.describe('Sort with dropdown', () => {
+  test.beforeEach(async({ page }) => {
+    await page.goto('/_search?q=sand');
+
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+
+    // open sort dropdown
+    await page.locator('.search-control').locator('button').first().click();
+  });
+
+  test('Open sort dropdown', async({ page }) => {
+    await expect(page.locator('.search-control .dropdown-menu.show')).toBeVisible();
+  });
+
+  test('Sort by relevance', async({ page }) => {
+    const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
+
+    await expect(dropdownMenu).toBeVisible();
+    await dropdownMenu.locator('.dropdown-item').nth(0).click();
+
+
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+  });
+
+  test('Sort by creation date', async({ page }) => {
+    const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
+
+    await expect(dropdownMenu).toBeVisible();
+    await dropdownMenu.locator('.dropdown-item').nth(1).click();
+
+
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+  });
+
+  test('Sort by last update date', async({ page }) => {
+    const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
+
+    await expect(dropdownMenu).toBeVisible();
+    await dropdownMenu.locator('.dropdown-item').nth(2).click();
+
+
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+  });
+});
+
+test.describe('Search and use', () => {
+  test.beforeEach(async({ page }) => {
+    await page.goto('/_search?q=alerts');
+
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+
+    await page.getByTestId('page-list-item-L').first().getByTestId('open-page-item-control-btn').click();
+    await expect(page.locator('.dropdown-menu.show')).toBeVisible();
+  });
+
+  test('Successfully the dropdown is opened', async({ page }) => {
+    await expect(page.locator('.dropdown-menu.show')).toBeVisible();
+  });
+
+  test('Successfully add bookmark', async({ page }) => {
+    const dropdonwMenu = page.locator('.dropdown-menu.show');
+
+    await expect(dropdonwMenu).toBeVisible();
+
+    // Add bookmark
+    await dropdonwMenu.getByTestId('add-bookmark-btn').click();
+
+    await expect(page.getByTestId('search-result-content').locator('.btn-bookmark.active').first()).toBeVisible();
+  });
+
+  test('Successfully open duplicate modal', async({ page }) => {
+    const dropdonwMenu = page.locator('.dropdown-menu.show');
+
+    await expect(dropdonwMenu).toBeVisible();
+
+    await dropdonwMenu.getByTestId('open-page-duplicate-modal-btn').click();
+
+    await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
+  });
+
+  test('Successfully open move/rename modal', async({ page }) => {
+    const dropdonwMenu = page.locator('.dropdown-menu.show');
+
+    await expect(dropdonwMenu).toBeVisible();
+
+    await dropdonwMenu.getByTestId('rename-page-btn').click();
+
+    await expect(page.getByTestId('page-rename-modal')).toBeVisible();
+  });
+
+  test('Successfully open delete modal', async({ page }) => {
+    const dropdonwMenu = page.locator('.dropdown-menu.show');
+
+    await expect(dropdonwMenu).toBeVisible();
+
+    await dropdonwMenu.getByTestId('open-page-delete-modal-btn').click();
+
+    await expect(page.getByTestId('page-delete-modal')).toBeVisible();
+  });
+});
+
+test('Search current tree by word is successfully loaded', async({ page }) => {
+  await page.goto('/');
+  const searchText = 'GROWI';
+
+  await page.getByTestId('open-search-modal-button').click();
+  await expect(page.getByTestId('search-modal')).toBeVisible();
+  await page.locator('.form-control').fill(searchText);
+  await page.getByTestId('search-prefix-menu-item').click();
+
+  await expect(page.getByTestId('search-result-base')).toBeVisible();
+  await expect(page.getByTestId('search-result-list')).toBeVisible();
+  await expect(page.getByTestId('search-result-content')).toBeVisible();
+});

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

@@ -765,6 +765,7 @@
     "receive_notifications": "Receive Notifications",
     "stop_notification": "Stop Notification",
     "footprints": "Footprints",
+    "login_required": "Login required",
     "operation": {
       "attention": {
         "rename": "Renaming paths of descendant pages was not successful, please open the menu from the 3-point reader and select 'Path recovery'"

+ 1 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -759,6 +759,7 @@
     "receive_notifications": "Recevoir les notifications",
     "stop_notification": "Stopper les notifications",
     "footprints": "Visiteurs",
+    "login_required": "Connexion requise",
     "operation": {
       "attention": {
         "rename": "Échec du renommage du chemin des pages descendantes, ouvrir le menu du lecteur 3-points et sélectionner 'Récupération du chemin'"

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

@@ -798,6 +798,7 @@
     "receive_notifications": "通知を受け取る",
     "stop_notification": "通知を止める",
     "footprints": "足跡",
+    "login_required": "ログインが必要です",
     "operation": {
       "attention": {
         "rename": "配下のページパスの更新が正常に行われませんでした。3点リーダーからメニューを開き、「パスを修復」を選択してしてください。"

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

@@ -768,6 +768,7 @@
     "receive_notifications": "接收通知",
     "stop_notification": "停止通知",
     "footprints": "脚印",
+    "login_required": "需要登录",
     "operation": {
       "attention": {
         "rename": "重命名子孙页的路径没有成功,请从三点式阅读器上打开菜单,选择 '路径恢复'。"

+ 1 - 1
apps/app/src/client/components/Admin/AuditLog/ActivityTable.tsx

@@ -15,7 +15,7 @@ type Props = {
   activityList: IActivityHasId[]
 }
 
-const formatDate = (date) => {
+const formatDate = (date: Date): string => {
   return format(new Date(date), 'yyyy/MM/dd HH:mm:ss');
 };
 

+ 1 - 1
apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx

@@ -14,7 +14,7 @@ import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
 
 
 const IGNORED_COLLECTION_NAMES = [
-  'sessions', 'rlflx', 'activities', 'yjs-writings', 'transferkeys',
+  'sessions', 'rlflx', 'yjs-writings', 'transferkeys',
 ];
 
 const ExportArchiveDataPage = (): JSX.Element => {

+ 2 - 2
apps/app/src/client/components/Admin/G2GDataTransferExportForm.tsx

@@ -5,8 +5,8 @@ import React, {
 import { useTranslation } from 'next-i18next';
 
 import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
-import ImportOptionForPages from '~/models/admin/import-option-for-pages';
-import ImportOptionForRevisions from '~/models/admin/import-option-for-revisions';
+import { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
+import { ImportOptionForRevisions } from '~/models/admin/import-option-for-revisions';
 
 import ImportCollectionConfigurationModal from './ImportData/GrowiArchive/ImportCollectionConfigurationModal';
 import ImportCollectionItem, { DEFAULT_MODE, MODE_RESTRICTED_COLLECTION } from './ImportData/GrowiArchive/ImportCollectionItem';

+ 11 - 9
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -6,8 +6,8 @@ import PropTypes from 'prop-types';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
-import ImportOptionForPages from '~/models/admin/import-option-for-pages';
-import ImportOptionForRevisions from '~/models/admin/import-option-for-revisions';
+import { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
+import { ImportOptionForRevisions } from '~/models/admin/import-option-for-revisions';
 import { useAdminSocket } from '~/stores/socket-io';
 
 
@@ -41,7 +41,7 @@ class ImportForm extends React.Component {
       isImporting: false,
       isImported: false,
       progressMap: [],
-      errorsMap: [],
+      errorsMap: {},
 
       selectedCollections: new Set(),
 
@@ -73,7 +73,7 @@ class ImportForm extends React.Component {
         : DEFAULT_MODE;
       // create GrowiArchiveImportOption instance
       const ImportOption = IMPORT_OPTION_CLASS_MAPPING[collectionName] || GrowiArchiveImportOption;
-      this.initialState.optionsMap[collectionName] = new ImportOption(initialMode);
+      this.initialState.optionsMap[collectionName] = new ImportOption(collectionName, initialMode);
     });
 
     this.state = this.initialState;
@@ -109,8 +109,10 @@ class ImportForm extends React.Component {
       const { progressMap, errorsMap } = this.state;
       progressMap[collectionName] = collectionProgress;
 
-      const errors = errorsMap[collectionName] || [];
-      errorsMap[collectionName] = errors.concat(appendedErrors);
+      if (appendedErrors != null) {
+        const errors = errorsMap[collectionName] || [];
+        errorsMap[collectionName] = errors.concat(appendedErrors);
+      }
 
       this.setState({
         isImporting: true,
@@ -303,7 +305,7 @@ class ImportForm extends React.Component {
       await apiv3Post('/import', {
         fileName,
         collections: Array.from(selectedCollections),
-        optionsMap,
+        options: Object.values(optionsMap),
       });
 
       if (onPostImport != null) {
@@ -378,7 +380,7 @@ class ImportForm extends React.Component {
       <div className="row">
         {collectionNames.map((collectionName) => {
           const collectionProgress = progressMap[collectionName];
-          const errors = errorsMap[collectionName];
+          const errorsCount = errorsMap[collectionName]?.length ?? 0;
           const isConfigButtonAvailable = Object.keys(IMPORT_OPTION_CLASS_MAPPING).includes(collectionName);
 
           return (
@@ -388,7 +390,7 @@ class ImportForm extends React.Component {
                 isImported={collectionProgress ? isImported : false}
                 insertedCount={collectionProgress ? collectionProgress.insertedCount : 0}
                 modifiedCount={collectionProgress ? collectionProgress.modifiedCount : 0}
-                errorsCount={errors ? errors.length : 0}
+                errorsCount={errorsCount}
                 collectionName={collectionName}
                 isSelected={selectedCollections.has(collectionName)}
                 option={optionsMap[collectionName]}

+ 6 - 6
apps/app/src/client/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -2,7 +2,7 @@ import type { FC } from 'react';
 import React, { useCallback, useState, useMemo } from 'react';
 
 import {
-  getIdForRef, isPopulated, type IGrantedGroup, type IUserGroupHasId,
+  getIdStringForRef, isPopulated, type IGrantedGroup, type IUserGroupHasId,
 } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
@@ -90,8 +90,8 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   }, [setActionName]);
 
   const handleGroupChange = useCallback((e) => {
-    const transferToUserGroupId = e.target.value;
-    const selectedGroup = userGroups.find(group => getIdForRef(group.item) === transferToUserGroupId) ?? null;
+    const transferToUserGroupId: string = e.target.value;
+    const selectedGroup = userGroups.find(group => getIdStringForRef(group.item) === transferToUserGroupId) ?? null;
     setTransferToUserGroup(selectedGroup);
   }, [userGroups]);
 
@@ -136,11 +136,11 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
     }
 
     const groups = userGroups.filter((group) => {
-      return getIdForRef(group.item) !== deleteUserGroup._id;
+      return getIdStringForRef(group.item) !== deleteUserGroup._id;
     });
 
     const options = groups.map((group) => {
-      const groupId = getIdForRef(group.item);
+      const groupId = getIdStringForRef(group.item);
       const groupName = isPopulated(group.item) ? group.item.name : null;
       return { id: groupId, name: groupName };
     }).filter(obj => obj.name != null)
@@ -153,7 +153,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
       <select
         name="transferToUserGroup"
         className={`form-control ${actionName === PageActionOnGroupDelete.transfer ? '' : 'd-none'}`}
-        value={transferToUserGroup != null ? getIdForRef(transferToUserGroup.item) : ''}
+        value={transferToUserGroup != null ? getIdStringForRef(transferToUserGroup.item) : ''}
         onChange={handleGroupChange}
       >
         <option value="" disabled>{defaultOptionText}</option>

+ 5 - 6
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import {
-  GroupType, getIdForRef, type IGrantedGroup, type IUserGroup, type IUserGroupHasId,
+  GroupType, getIdStringForRef, type IGrantedGroup, type IUserGroup, type IUserGroupHasId,
 } from '@growi/core';
 import { objectIdUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
@@ -130,8 +130,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     setSearchType(searchType);
   }, []);
 
-  const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
-    const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
+  const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: IUserGroupHasId, forceUpdateParents: boolean) => {
     if (isExternalGroup) {
       await apiv3Put<{ userGroup: IExternalUserGroupHasId }>(`/external-user-groups/${userGroup._id}`, {
         description: update.description,
@@ -141,7 +140,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
         name: update.name,
         description: update.description,
-        parentId: parentId ?? null,
+        parentId: update.parent != null ? getIdStringForRef(update.parent) : null,
         forceUpdateParents,
       });
     }
@@ -154,7 +153,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, isExternalGroup]);
 
   const onSubmitUpdateGroup = useCallback(
-    async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
+    async(targetGroup: IUserGroupHasId, userGroupData: IUserGroupHasId, forceUpdateParents: boolean): Promise<void> => {
       try {
         await updateUserGroup(targetGroup, userGroupData, forceUpdateParents);
         toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
@@ -303,7 +302,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
   const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroup: IGrantedGroup | null) => {
     const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
-    const transferToUserGroupId = transferToUserGroup != null ? getIdForRef(transferToUserGroup.item) : null;
+    const transferToUserGroupId = transferToUserGroup != null ? getIdStringForRef(transferToUserGroup.item) : null;
     const transferToUserGroupType = transferToUserGroup != null ? transferToUserGroup.type : null;
     try {
       const res = await apiv3Delete(url, {

+ 1 - 1
apps/app/src/client/components/Common/CountBadge.tsx

@@ -11,7 +11,7 @@ const CountBadge: FC<CountProps> = (props:CountProps) => {
 
 
   return (
-    <span className="grw-count-badge px-2 badge rounded-pill bg-light text-dark">
+    <span className="grw-count-badge px-2 badge bg-body-tertiary text-body-tertiary">
       { count == null && <span className="text-muted">―</span> }
       { count != null && count + offset }
     </span>

+ 4 - 3
apps/app/src/client/components/Me/ProfileImageSettings.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback, useState } from 'react';
 
 
+import { isPopulated } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import ImageCropModal from '~/client/components/Common/ImageCropModal';
@@ -21,10 +22,10 @@ const ProfileImageSettings = (): JSX.Element => {
 
   const [isGravatarEnabled, setGravatarEnabled] = useState(currentUser?.isGravatarEnabled);
   const [uploadedPictureSrc, setUploadedPictureSrc] = useState(() => {
-    if (typeof currentUser?.imageAttachment === 'string') {
-      return currentUser?.image;
+    if (currentUser?.imageAttachment != null && isPopulated(currentUser.imageAttachment)) {
+      return currentUser.imageAttachment.filePathProxied ?? currentUser.image;
     }
-    return currentUser?.imageAttachment?.filePathProxied ?? currentUser?.image;
+    return currentUser?.image;
   });
 
   const [showImageCropModal, setShowImageCropModal] = useState(false);

+ 21 - 5
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -9,12 +9,13 @@ import type {
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
+import { auto } from '@popperjs/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
 import Sticky from 'react-stickynode';
-import { DropdownItem } from 'reactstrap';
+import { DropdownItem, UncontrolledTooltip } from 'reactstrap';
 
 import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
@@ -23,7 +24,7 @@ import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import {
   useCurrentPathname,
-  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
+  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsLocalAccountRegistrationEnabled, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
 import { useEditorMode } from '~/stores-universal/ui';
 import {
@@ -233,6 +234,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
+  const { data: isLocalAccountRegistrationEnabled } = useIsLocalAccountRegistrationEnabled();
   const { data: isSharedUser } = useIsSharedUser();
 
   const shouldExpandContent = useShouldExpandContent(currentPage);
@@ -387,9 +389,23 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
             { isGuestUser && (
               <div className="mt-2">
-                <Link href="/login#register" className="btn me-2" prefetch={false}>
-                  <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
-                </Link>
+                <span>
+                  <span className="d-inline-block" id="sign-up-link">
+                    <Link
+                      href={!isLocalAccountRegistrationEnabled ? '#' : '/login#register'}
+                      className={`btn me-2 ${!isLocalAccountRegistrationEnabled ? 'opacity-25' : ''}`}
+                      style={{ pointerEvents: !isLocalAccountRegistrationEnabled ? 'none' : undefined }}
+                      prefetch={false}
+                    >
+                      <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
+                    </Link>
+                  </span>
+                  {!isLocalAccountRegistrationEnabled && (
+                    <UncontrolledTooltip target="sign-up-link" fade={false}>
+                      {t('tooltip.login_required')}
+                    </UncontrolledTooltip>
+                  )}
+                </span>
                 <Link href="/login#login" className="btn btn-primary" prefetch={false}>
                   <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
                 </Link>

+ 6 - 3
apps/app/src/client/components/PageComment.tsx

@@ -3,7 +3,10 @@ import React, {
   useState, useMemo, memo, useCallback,
 } from 'react';
 
-import { isPopulated, getIdForRef, type IRevisionHasId } from '@growi/core';
+import type { IRevision, Ref } from '@growi/core';
+import {
+  isPopulated, getIdStringForRef,
+} from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 
@@ -30,7 +33,7 @@ type PageCommentProps = {
   rendererOptions?: RendererOptions,
   pageId: string,
   pagePath: string,
-  revision: string | IRevisionHasId,
+  revision: Ref<IRevision>,
   currentUser: any,
   isReadOnly: boolean,
 }
@@ -121,7 +124,7 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
     return <></>;
   }
 
-  const revisionId = getIdForRef(revision);
+  const revisionId = getIdStringForRef(revision);
   const revisionCreatedAt = (isPopulated(revision)) ? revision.createdAt : undefined;
 
   const commentElement = (comment: ICommentHasId) => (

+ 9 - 4
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -225,11 +225,13 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
   const switchContentWidthClickHandler = useCallback(() => {
+    if (onClickSwitchContentWidth == null) {
+      return;
+    }
 
     const newValue = !expandContentWidth;
-    if (onClickSwitchContentWidth == null || (isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
+    if ((isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
       logger.warn('Could not switch content width', {
-        onClickSwitchContentWidth: onClickSwitchContentWidth == null ? 'null' : 'not null',
         isGuestUser,
         isReadOnlyUser,
       });
@@ -250,12 +252,15 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     if (!isIPageInfoForEntity(pageInfo)) {
       return undefined;
     }
-    const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => {
+    if (onClickSwitchContentWidth == null) {
+      return undefined;
+    }
 
+    const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => {
       return <WideViewMenuItem {...props} onClick={switchContentWidthClickHandler} expandContentWidth={expandContentWidth} />;
     };
     return wideviewMenuItemRenderer;
-  }, [pageInfo, switchContentWidthClickHandler, expandContentWidth]);
+  }, [pageInfo, expandContentWidth, onClickSwitchContentWidth, switchContentWidthClickHandler]);
 
   if (!isIPageInfoForEntity(pageInfo)) {
     return <></>;

+ 2 - 1
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -19,7 +19,7 @@ import { useTranslation } from 'next-i18next';
 import { throttle, debounce } from 'throttle-debounce';
 
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
-import { updatePage, extractRemoteRevisionDataFromErrorObj } from '~/client/services/update-page';
+import { useUpdatePage, extractRemoteRevisionDataFromErrorObj } from '~/client/services/update-page';
 import { uploadAttachments } from '~/client/services/upload-attachments';
 import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
@@ -118,6 +118,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const shouldExpandContent = useShouldExpandContent(currentPage);
 
+  const updatePage = useUpdatePage();
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
   useConflictEffect();

+ 4 - 11
apps/app/src/client/components/SearchPage/SearchResultContent.tsx

@@ -3,7 +3,7 @@ import React, {
   useCallback, useEffect, useRef,
 } from 'react';
 
-import { getIdForRef } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
 import type { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
@@ -11,7 +11,7 @@ import { animateScroll } from 'react-scroll';
 import { DropdownItem } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
-import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
+import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/toastr';
 import { PagePathNav } from '~/components/Common/PagePathNav';
 import type { IPageWithSearchMeta } from '~/interfaces/search';
@@ -173,18 +173,12 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }, [onDeletedHandler, openDeleteModal]);
 
-  const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
-    await updateContentWidth(pageId, value);
-
-    // TODO: revalidate page data and update shouldExpandContent
-  }, []);
-
   const RightComponent = useCallback(() => {
     if (page == null) {
       return <></>;
     }
 
-    const revisionId = page.revision != null ? getIdForRef(page.revision) : null;
+    const revisionId = page.revision != null ? getIdStringForRef(page.revision) : null;
     const additionalMenuItemRenderer = revisionId != null
       ? props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />
       : undefined;
@@ -202,12 +196,11 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           onClickDuplicateMenuItem={duplicateItemClickedHandler}
           onClickRenameMenuItem={renameItemClickedHandler}
           onClickDeleteMenuItem={deleteItemClickedHandler}
-          onClickSwitchContentWidth={switchContentWidthHandler}
         />
       </div>
     );
   }, [page, shouldExpandContent, showPageControlDropdown, forceHideMenuItems,
-      duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, switchContentWidthHandler]);
+      duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
 
   const fluidLayoutClass = shouldExpandContent ? _fluidLayoutClass : '';
 

+ 3 - 1
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -6,7 +6,7 @@ import { Origin } from '@growi/core';
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
 import { replaceDrawioInMarkdown } from '~/client/components/Page/markdown-drawio-util-for-view';
-import { extractRemoteRevisionDataFromErrorObj, updatePage as _updatePage } from '~/client/services/update-page';
+import { extractRemoteRevisionDataFromErrorObj, useUpdatePage } from '~/client/services/update-page';
 import { useShareLinkId } from '~/stores-universal/context';
 import { useConflictDiffModal, useDrawioModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -36,6 +36,8 @@ export const useDrawioModalLauncherForView = (opts?: {
 
   const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModal();
 
+  const _updatePage = useUpdatePage();
+
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
 
   // eslint-disable-next-line max-len

+ 3 - 1
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -6,7 +6,7 @@ import { Origin } from '@growi/core';
 import type { MarkdownTable } from '@growi/editor';
 
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/client/components/Page/markdown-table-util-for-view';
-import { extractRemoteRevisionDataFromErrorObj, updatePage as _updatePage } from '~/client/services/update-page';
+import { extractRemoteRevisionDataFromErrorObj, useUpdatePage } from '~/client/services/update-page';
 import { useShareLinkId } from '~/stores-universal/context';
 import { useHandsontableModal, useConflictDiffModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -36,6 +36,8 @@ export const useHandsontableModalLauncherForView = (opts?: {
 
   const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModal();
 
+  const _updatePage = useUpdatePage();
+
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
 
   // eslint-disable-next-line max-len

+ 1 - 8
apps/app/src/client/services/update-page/index.ts

@@ -1,9 +1,2 @@
-import { apiv3Put } from '~/client/util/apiv3-client';
-import type { IApiv3PageUpdateParams, IApiv3PageUpdateResponse } from '~/interfaces/apiv3';
-
 export * from './conflict';
-
-export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
-  const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
-  return res.data;
-};
+export * from './use-update-page';

+ 7 - 0
apps/app/src/client/services/update-page/update-page.ts

@@ -0,0 +1,7 @@
+import { apiv3Put } from '~/client/util/apiv3-client';
+import type { IApiv3PageUpdateParams, IApiv3PageUpdateResponse } from '~/interfaces/apiv3';
+
+export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
+  const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
+  return res.data;
+};

+ 25 - 0
apps/app/src/client/services/update-page/use-update-page.tsx

@@ -0,0 +1,25 @@
+import { useCallback } from 'react';
+
+import type { IApiv3PageUpdateParams, IApiv3PageUpdateResponse } from '~/interfaces/apiv3';
+import { useIsUntitledPage } from '~/stores/ui';
+
+import { updatePage } from './update-page';
+
+
+type UseUpdatePage = (params: IApiv3PageUpdateParams) => Promise<IApiv3PageUpdateResponse>;
+
+
+export const useUpdatePage = (): UseUpdatePage => {
+  const { mutate: mutateUntitledPage } = useIsUntitledPage();
+
+  const updatePageExt: UseUpdatePage = useCallback(async(params) => {
+    const result = await updatePage(params);
+
+    // set false to isUntitledPage
+    mutateUntitledPage(false);
+
+    return result;
+  }, [mutateUntitledPage]);
+
+  return updatePageExt;
+};

+ 1 - 2
apps/app/src/components/Layout/AdminLayout.tsx

@@ -6,14 +6,13 @@ import Link from 'next/link';
 
 import GrowiLogo from '~/components/Common/GrowiLogo';
 
-import { AdminNavigation } from '../Admin/Common/AdminNavigation';
-
 import { RawLayout } from './RawLayout';
 
 
 import styles from './Admin.module.scss';
 
 
+const AdminNavigation = dynamic(() => import('../Admin/Common/AdminNavigation').then(mod => mod.AdminNavigation), { ssr: false });
 const PageCreateModal = dynamic(() => import('~/client/components/PageCreateModal'), { ssr: false });
 const SystemVersion = dynamic(() => import('~/client/components/SystemVersion'), { ssr: false });
 const HotkeysManager = dynamic(() => import('~/client/components/Hotkeys/HotkeysManager'), { ssr: false });

+ 1 - 1
apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts

@@ -26,7 +26,7 @@ export interface ExternalUserGroupRelationModel extends Model<ExternalUserGroupR
 
   findAllGroupsForUser: (user) => Promise<ExternalUserGroupDocument[]>
 
-  findAllUserGroupIdsRelatedToUser: (user) => Promise<string[]>
+  findAllUserGroupIdsRelatedToUser: (user) => Promise<ObjectIdLike[]>
 }
 
 const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>({

+ 5 - 5
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts

@@ -1,19 +1,19 @@
 import { ErrorV3 } from '@growi/core/dist/models';
-import { Router, Request } from 'express';
+import type { Router, Request } from 'express';
 
-import { IExternalUserGroupRelationHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import type { IExternalUserGroupRelationHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import Crowi from '~/server/crowi';
+import type Crowi from '~/server/crowi';
+import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
-import { ApiV3Response } from '../../../../../server/routes/apiv3/interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
 const { query } = require('express-validator');
 
-const { serializeUserGroupRelationSecurely } = require('~/server/models/serializers/user-group-relation-serializer');
 
 const router = express.Router();
 

+ 2 - 2
apps/app/src/interfaces/crowi-request.ts

@@ -1,11 +1,11 @@
 import type { IUser } from '@growi/core';
 import type { Request } from 'express';
-import type { Document } from 'mongoose';
+import type { HydratedDocument } from 'mongoose';
 
 
 export interface CrowiProperties {
 
-  user?: IUser & Document,
+  user?: HydratedDocument<IUser>,
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   crowi: any,

+ 4 - 2
apps/app/src/migrations/20180926134048-make-email-unique.js

@@ -1,8 +1,10 @@
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import userModelFactory from '~/server/models/user';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:migrate:make-email-unique');
 
 module.exports = {
@@ -11,7 +13,7 @@ module.exports = {
     logger.info('Start migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
 
     // get all users who has 'deleted@deleted' email
     const users = await User.find({ email: 'deleted@deleted' });

+ 8 - 6
apps/app/src/migrations/20181019114028-abolish-page-group-relation.js

@@ -1,9 +1,11 @@
 import mongoose from 'mongoose';
 
-import getPageModel from '~/server/models/page';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import pageModelFactory from '~/server/models/page';
+import userGroupModelFactory from '~/server/models/user-group';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:migrate:abolish-page-group-relation');
 
 async function isCollectionExists(db, collectionName) {
@@ -38,8 +40,8 @@ module.exports = {
       return;
     }
 
-    const Page = getModelSafely('Page') || getPageModel();
-    const UserGroup = getModelSafely('UserGroup') || require('~/server/models/user-group')();
+    const Page = pageModelFactory();
+    const UserGroup = userGroupModelFactory();
 
     // retrieve all documents from 'pagegrouprelations'
     const relations = await db.collection('pagegrouprelations').find().toArray();
@@ -75,8 +77,8 @@ module.exports = {
     logger.info('Rollback migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = getModelSafely('Page') || getPageModel();
-    const UserGroup = getModelSafely('UserGroup') || require('~/server/models/user-group')();
+    const Page = pageModelFactory();
+    const UserGroup = userGroupModelFactory();
 
     // retrieve all Page documents which granted by UserGroup
     const relatedPages = await Page.find({ grant: Page.GRANT_USER_GROUP });

+ 3 - 3
apps/app/src/migrations/20190618104011-add-config-app-installed.js

@@ -1,8 +1,8 @@
 import mongoose from 'mongoose';
 
-// eslint-disable-next-line import/no-named-as-default
 import { Config } from '~/server/models/config';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import userModelFactory from '~/server/models/user';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:add-config-app-installed');
@@ -21,7 +21,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
 
     // find 'app:siteUrl'
     const appInstalled = await Config.findOne({

+ 4 - 2
apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js

@@ -1,9 +1,11 @@
 import mongoose from 'mongoose';
 
+import userModelFactory from '~/server/models/user';
 import UserGroupRelation from '~/server/models/user-group-relation';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:migrate:remove-deleteduser-from-relationgroup');
 
 module.exports = {
@@ -11,7 +13,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
 
     const deletedUsers = await User.find({ status: 4 }); // deleted user
     const requests = await UserGroupRelation.remove({ relatedUser: deletedUsers });

+ 3 - 3
apps/app/src/migrations/20200620203632-normalize-locale-id.js

@@ -1,8 +1,8 @@
 import mongoose from 'mongoose';
 
-// eslint-disable-next-line import/no-named-as-default
 import { Config } from '~/server/models/config';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import userModelFactory from '~/server/models/user';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:normalize-locale-id');
@@ -12,7 +12,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
 
     await Promise.all([
       // update en-US -> en_US

+ 4 - 3
apps/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js

@@ -1,8 +1,10 @@
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import slackAppIntegrationFactory from '~/server/models/slack-app-integration';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:migrate:slack-app-integration-set-default-value');
 
 module.exports = {
@@ -10,8 +12,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    // Add columns + set all default commands if supportedCommandsForBroadcastUse column does not exist
-    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+    const SlackAppIntegration = slackAppIntegrationFactory();
 
     // Add togetter command if supportedCommandsForBroadcastUse already exists
     const slackAppIntegrations = await SlackAppIntegration.find();

+ 4 - 3
apps/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js

@@ -1,7 +1,8 @@
 import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import slackAppIntegrationFactory from '~/server/models/slack-app-integration';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 
@@ -26,7 +27,7 @@ module.exports = {
       await mongoose.connect(getMongoUri(), mongoOptions);
     }
 
-    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+    const SlackAppIntegration = slackAppIntegrationFactory();
 
     const slackAppIntegrations = await SlackAppIntegration.find();
 
@@ -95,7 +96,7 @@ module.exports = {
     logger.info('Rollback migration');
     await mongoose.connect(getMongoUri(), mongoOptions);
 
-    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+    const SlackAppIntegration = slackAppIntegrationFactory();
 
     const slackAppIntegrations = await SlackAppIntegration.find();
 

+ 4 - 4
apps/app/src/migrations/20211005120030-slack-app-integration-rename-keys.js

@@ -1,16 +1,16 @@
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import slackAppIntegrationFactory from '~/server/models/slack-app-integration';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:migrate:slack-app-integration-rename-keys');
 
 module.exports = {
   async up(db) {
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+    const SlackAppIntegration = slackAppIntegrationFactory();
 
     const slackAppIntegrations = await SlackAppIntegration.find();
 
@@ -47,7 +47,7 @@ module.exports = {
   async down(db, next) {
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+    const SlackAppIntegration = slackAppIntegrationFactory();
 
     const slackAppIntegrations = await SlackAppIntegration.find();
 

+ 3 - 2
apps/app/src/migrations/20220411114257-set-sparse-option-to-slack-member-id.js

@@ -1,6 +1,7 @@
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import userModelFactory from '~/server/models/user';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:set-sparse-option-to-slack-member-id');
@@ -13,7 +14,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
     await User.syncIndexes();
 
     logger.info('Migration has successfully applied');

+ 1 - 1
apps/app/src/migrations/20220613064207-add-attachment-type-to-existing-attachments.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 import { AttachmentType } from '~/server/interfaces/attachment';
-import { Attachment } from '~/server/models';
+import { Attachment } from '~/server/models/attachment';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 3 - 4
apps/app/src/migrations/20230731075753-add_installed_date_to_config.js

@@ -1,9 +1,8 @@
-// eslint-disable-next-line import/no-named-as-default
 import { Config } from '~/server/models/config';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import userModelFactory from '~/server/models/user';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:migrate:add-installed-date-to-config');
 
 const mongoose = require('mongoose');
@@ -12,7 +11,7 @@ module.exports = {
   async up() {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
-    const User = getModelSafely('User') || require('~/server/models/user')();
+    const User = userModelFactory();
 
     const appInstalled = await Config.findOne({ key: 'app:installed' });
     if (appInstalled != null && appInstalled.createdAt == null) {

+ 2 - 1
apps/app/src/models/admin/growi-archive-import-option.js

@@ -1,6 +1,7 @@
 class GrowiArchiveImportOption {
 
-  constructor(mode, initProps = {}) {
+  constructor(collectionName, mode, initProps = {}) {
+    this.collectionName = collectionName;
     this.mode = mode;
 
     Object.entries(initProps).forEach(([key, value]) => {

+ 0 - 19
apps/app/src/models/admin/import-option-for-pages.js

@@ -1,19 +0,0 @@
-const GrowiArchiveImportOption = require('./growi-archive-import-option');
-
-const DEFAULT_PROPS = {
-  isOverwriteAuthorWithCurrentUser: false,
-  makePublicForGrant2: false,
-  makePublicForGrant4: false,
-  makePublicForGrant5: false,
-  initPageMetadatas: false,
-};
-
-class ImportOptionForPages extends GrowiArchiveImportOption {
-
-  constructor(mode, initProps) {
-    super(mode, initProps || DEFAULT_PROPS);
-  }
-
-}
-
-module.exports = ImportOptionForPages;

+ 31 - 0
apps/app/src/models/admin/import-option-for-pages.ts

@@ -0,0 +1,31 @@
+import GrowiArchiveImportOption from './growi-archive-import-option';
+
+const DEFAULT_PROPS = {
+  isOverwriteAuthorWithCurrentUser: false,
+  makePublicForGrant2: false,
+  makePublicForGrant4: false,
+  makePublicForGrant5: false,
+  initPageMetadatas: false,
+};
+
+export class ImportOptionForPages extends GrowiArchiveImportOption {
+
+  isOverwriteAuthorWithCurrentUser;
+
+  makePublicForGrant2;
+
+  makePublicForGrant4;
+
+  makePublicForGrant5;
+
+  initPageMetadatas;
+
+  constructor(collectionName: string, mode: string, initProps) {
+    super(collectionName, mode, initProps || DEFAULT_PROPS);
+  }
+
+}
+
+export const isImportOptionForPages = (opt: GrowiArchiveImportOption): opt is ImportOptionForPages => {
+  return 'isOverwriteAuthorWithCurrentUser' in opt;
+};

+ 3 - 5
apps/app/src/models/admin/import-option-for-revisions.js

@@ -4,12 +4,10 @@ const DEFAULT_PROPS = {
   isOverwriteAuthorWithCurrentUser: false,
 };
 
-class ImportOptionForRevisions extends GrowiArchiveImportOption {
+export class ImportOptionForRevisions extends GrowiArchiveImportOption {
 
-  constructor(mode, initProps) {
-    super(mode, initProps || DEFAULT_PROPS);
+  constructor(collectionName, mode, initProps) {
+    super(collectionName, mode, initProps || DEFAULT_PROPS);
   }
 
 }
-
-module.exports = ImportOptionForRevisions;

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

@@ -3,9 +3,9 @@ import React, { useEffect } from 'react';
 
 import EventEmitter from 'events';
 
-import { isIPageInfoForEntity } from '@growi/core';
+import { isIPageInfo } from '@growi/core';
 import type {
-  IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision,
+  IDataWithMeta, IPageInfo, IPagePopulatedToShowRevision,
 } from '@growi/core';
 import {
   isClient, pagePathUtils, pathUtils,
@@ -25,6 +25,7 @@ import { PageView } from '~/components/PageView/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { SupportedAction, type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { RegistrationMode } from '~/interfaces/registration-mode';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { CurrentPageYjsData } from '~/interfaces/yjs';
@@ -43,6 +44,7 @@ import {
   useIsAllReplyShown, useIsContainerFluid, useIsNotCreatable,
   useIsUploadAllFileAllowed, useIsUploadEnabled,
   useElasticsearchMaxBodyLengthToIndex,
+  useIsLocalAccountRegistrationEnabled,
 } from '~/stores-universal/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
@@ -98,7 +100,7 @@ const {
 } = pagePathUtils;
 const { removeHeadingSlash } = pathUtils;
 
-type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfoForEntity>;
+type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfo>;
 type IPageToShowRevisionWithMetaSerialized = IDataWithMeta<string, string>;
 
 superjson.registerCustom<IPageToShowRevisionWithMeta, IPageToShowRevisionWithMetaSerialized>(
@@ -106,8 +108,7 @@ superjson.registerCustom<IPageToShowRevisionWithMeta, IPageToShowRevisionWithMet
     isApplicable: (v): v is IPageToShowRevisionWithMeta => {
       return v?.data != null
         && v?.data.toObject != null
-        && v?.meta != null
-        && isIPageInfoForEntity(v.meta);
+        && isIPageInfo(v.meta);
     },
     serialize: (v) => {
       return {
@@ -155,6 +156,8 @@ type Props = CommonProps & {
   templateTagData?: string[],
   templateBodyData?: string,
 
+  isLocalAccountRegistrationEnabled: boolean,
+
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
@@ -237,6 +240,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
   useIsUploadEnabled(props.isUploadEnabled);
 
+  useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
+
   const { pageWithMeta } = props;
 
   const pageId = pageWithMeta?.data._id;
@@ -556,6 +561,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isUploadAllFileAllowed = crowi.fileUploadService.getFileUploadEnabled();
   props.isUploadEnabled = crowi.fileUploadService.getIsUploadable();
 
+  props.isLocalAccountRegistrationEnabled = crowi.passportService.isLocalStrategySetup
+  && configManager.getConfig('crowi', 'security:registrationMode') !== RegistrationMode.CLOSED;
+
   props.adminPreferredIndentSize = configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize');
   props.isIndentSizeForced = configManager.getConfig('markdown', 'markdown:isIndentSizeForced');
 

+ 30 - 7
apps/app/src/pages/_app.page.tsx

@@ -1,25 +1,27 @@
 import type { ReactElement, ReactNode } from 'react';
 import React, { useEffect } from 'react';
 
+import type { Locale } from '@growi/core';
 import type { NextPage } from 'next';
 import { appWithTranslation } from 'next-i18next';
-import type { AppProps } from 'next/app';
+import type { AppContext, AppProps } from 'next/app';
+import App from 'next/app';
+import { useRouter } from 'next/router';
 import { SWRConfig } from 'swr';
 
 import * as nextI18nConfig from '^/config/next-i18next.config';
 
 import { GlobalFonts } from '~/components/FontFamily/GlobalFonts';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import {
   useAppTitle, useConfidential, useGrowiVersion, useSiteUrl, useIsDefaultLogo, useForcedColorScheme,
 } from '~/stores-universal/context';
 import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
-import type { CommonProps } from './utils/commons';
-import { registerTransformerForObjectId } from './utils/objectid-transformer';
-
+import { getLocaleAtServerSide, type CommonProps } from './utils/commons';
 import '~/styles/prebuilt/vendor.css';
 import '~/styles/style-app.scss';
-
+import { registerTransformerForObjectId } from './utils/objectid-transformer';
 
 // eslint-disable-next-line @typescript-eslint/ban-types
 export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
@@ -28,17 +30,31 @@ export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
 
 type GrowiAppProps = AppProps & {
   Component: NextPageWithLayout,
+  userLocale: Locale,
 };
 
 // register custom serializer
 registerTransformerForObjectId();
 
-function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
+function GrowiApp({ Component, pageProps, userLocale }: GrowiAppProps): JSX.Element {
+  const router = useRouter();
+
+  useEffect(() => {
+    const updateLangAttribute = () => {
+      if (document.documentElement.getAttribute('lang') !== userLocale) {
+        document.documentElement.setAttribute('lang', userLocale);
+      }
+    };
+    router.events.on('routeChangeComplete', updateLangAttribute);
+    return () => {
+      router.events.off('routeChangeComplete', updateLangAttribute);
+    };
+  }, [router, userLocale]);
+
   useEffect(() => {
     import('bootstrap/dist/js/bootstrap');
   }, []);
 
-
   const commonPageProps = pageProps as CommonProps;
   useAppTitle(commonPageProps.appTitle);
   useSiteUrl(commonPageProps.siteUrl);
@@ -60,4 +76,11 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
   );
 }
 
+GrowiApp.getInitialProps = async(appContext: AppContext) => {
+  const appProps = App.getInitialProps(appContext);
+  const userLocale = getLocaleAtServerSide(appContext.ctx.req as unknown as CrowiRequest);
+
+  return { ...appProps, userLocale };
+};
+
 export default appWithTranslation(GrowiApp, nextI18nConfig);

+ 4 - 13
apps/app/src/pages/_document.page.tsx

@@ -1,7 +1,7 @@
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 
-import { Lang } from '@growi/core';
+import type { Locale } from '@growi/core';
 import type { DocumentContext, DocumentInitialProps } from 'next/document';
 import Document, {
   Html, Head, Main, NextScript,
@@ -9,11 +9,9 @@ import Document, {
 
 import type { GrowiPluginResourceEntries } from '~/features/growi-plugin/server/services';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import { configManager } from '~/server/service/config-manager';
-import { detectLocaleFromBrowserAcceptLanguage } from '~/server/util/locale-utils';
 import loggerFactory from '~/utils/logger';
 
-import { getLocateAtServerSide } from './utils/commons';
+import { getLocaleAtServerSide } from './utils/commons';
 
 const logger = loggerFactory('growi:page:_document');
 
@@ -46,7 +44,7 @@ interface GrowiDocumentProps {
   customCss: string | null,
   customNoscript: string | null,
   pluginResourceEntries: GrowiPluginResourceEntries;
-  locale: string;
+  locale: Locale;
 }
 declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
 
@@ -54,13 +52,6 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
 
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
 
-    const langMap = {
-      [Lang.ja_JP]: 'ja-jp',
-      [Lang.en_US]: 'en-us',
-      [Lang.zh_CN]: 'zh-cn',
-      [Lang.fr_FR]: 'fr-fr',
-    } as const;
-
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
     const req = ctx.req as CrowiRequest;
     const { crowi } = req;
@@ -75,7 +66,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const growiPluginService = await import('~/features/growi-plugin/server/services').then(mod => mod.growiPluginService);
     const pluginResourceEntries = await growiPluginService.retrieveAllPluginResourceEntries();
 
-    const locale = langMap[getLocateAtServerSide(req)];
+    const locale = getLocaleAtServerSide(req);
 
     return {
       ...initialProps,

+ 7 - 0
apps/app/src/pages/share/[[...path]].page.tsx

@@ -15,6 +15,7 @@ import { ShareLinkPageView } from '~/components/ShareLinkPageView';
 import type { SupportedActionType } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { RegistrationMode } from '~/interfaces/registration-mode';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import type { PageDocument } from '~/server/models/page';
@@ -22,6 +23,7 @@ import ShareLink from '~/server/models/share-link';
 import {
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid, useIsEnabledMarp,
+  useIsLocalAccountRegistrationEnabled,
 } from '~/stores-universal/context';
 import { useCurrentPageId, useIsNotFound, useSWRMUTxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
@@ -48,6 +50,7 @@ type Props = CommonProps & {
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
   isEnabledMarp: boolean,
+  isLocalAccountRegistrationEnabled: boolean,
   drawioUri: string | null,
   rendererConfig: RendererConfig,
   skipSSR: boolean,
@@ -98,6 +101,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsEnabledMarp(props.rendererConfig.isEnabledMarp);
+  useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
   useIsContainerFluid(props.isContainerFluid);
 
   const { trigger: mutateCurrentPage, data: currentPage } = useSWRMUTxCurrentPage();
@@ -163,6 +167,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
   props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
 
+  props.isLocalAccountRegistrationEnabled = crowi.passportService.isLocalStrategySetup
+    && configManager.getConfig('crowi', 'security:registrationMode') !== RegistrationMode.CLOSED;
+
   props.rendererConfig = {
     isSharedPage: true,
     isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),

+ 22 - 6
apps/app/src/pages/utils/commons.ts

@@ -1,11 +1,10 @@
-import type { ColorScheme, IUserHasId } from '@growi/core';
+import type { ColorScheme, IUserHasId, Locale } from '@growi/core';
 import { Lang, AllLang } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { isServer } from '@growi/core/dist/utils';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { SSRConfig, UserConfig } from 'next-i18next';
 
-
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
 import { type SupportedActionType } from '~/interfaces/activity';
@@ -106,13 +105,29 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   return { props };
 };
 
+export type LangMap = {
+  readonly [key in Lang]: Locale;
+};
+
+export const langMap: LangMap = {
+  [Lang.ja_JP]: 'ja-JP',
+  [Lang.en_US]: 'en-US',
+  [Lang.zh_CN]: 'zh-CN',
+  [Lang.fr_FR]: 'fr-FR',
+} as const;
 
-export const getLocateAtServerSide = (req: CrowiRequest): Lang => {
+// use this function to translate content
+export const getLangAtServerSide = (req: CrowiRequest): Lang => {
   const { user, headers } = req;
   const { configManager } = req.crowi;
 
   return user == null ? detectLocaleFromBrowserAcceptLanguage(headers)
-    : (user.lang ?? configManager.getConfig('crowi', 'app:globalLang') as Lang ?? Lang.en_US);
+    : (user.lang ?? configManager.getConfig('crowi', 'app:globalLang') as Lang ?? Lang.en_US) ?? Lang.en_US;
+};
+
+// use this function to get locale for html lang attribute
+export const getLocaleAtServerSide = (req: CrowiRequest): Locale => {
+  return langMap[getLangAtServerSide(req)];
 };
 
 export const getNextI18NextConfig = async(
@@ -126,7 +141,7 @@ export const getNextI18NextConfig = async(
 
   // determine language
   const req: CrowiRequest = context.req as CrowiRequest;
-  const locale = getLocateAtServerSide(req);
+  const lang = getLangAtServerSide(req);
 
   const namespaces = ['commons'];
   if (namespacesRequired != null) {
@@ -137,7 +152,8 @@ export const getNextI18NextConfig = async(
     namespaces.push('translation');
   }
 
-  return serverSideTranslations(locale, namespaces, nextI18NextConfig, preloadAllLang ? AllLang : false);
+  // The first argument must be a language code with an underscore, such as en_US
+  return serverSideTranslations(lang, namespaces, nextI18NextConfig, preloadAllLang ? AllLang : false);
 };
 
 /**

+ 0 - 47
apps/app/src/server/console.js

@@ -1,47 +0,0 @@
-const fs = require('fs');
-const path = require('path');
-const repl = require('repl');
-
-const mongoose = require('mongoose');
-
-const { getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
-
-const models = require('./models');
-
-Object.keys(models).forEach((modelName) => {
-  global[modelName] = models[modelName];
-});
-
-mongoose.Promise = global.Promise;
-
-const replServer = repl.start({
-  prompt: `${process.env.NODE_ENV} > `,
-  ignoreUndefined: true,
-});
-
-// add history function into repl
-// see: https://qiita.com/acro5piano/items/dc62b94d7b04505a4aca
-// see: https://qiita.com/potato4d/items/7131028497de53ceb48e
-const userHome = process.env[process.platform === 'win32' ? 'USERPROFILE' : 'HOME'];
-const replHistoryPath = path.join(userHome, '.node_repl_history');
-fs.readFile(replHistoryPath, 'utf8', (err, data) => {
-  if (err != null) {
-    return;
-  }
-  return data.split('\n').forEach((command) => { return replServer.history.push(command) });
-});
-
-replServer.context.mongoose = mongoose;
-replServer.context.models = models;
-
-mongoose.connect(getMongoUri(), mongoOptions)
-  .then(() => {
-    replServer.context.db = mongoose.connection.db;
-  });
-
-replServer.on('exit', () => {
-  fs.writeFile(replHistoryPath, replServer.history.join('\n'), (err) => {
-    console.log(err); // eslint-disable-line no-console
-    process.exit();
-  });
-});

+ 17 - 30
apps/app/src/server/crowi/index.js

@@ -18,7 +18,6 @@ import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 import UserEvent from '../events/user';
-import { modelsDependsOnCrowi } from '../models';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
@@ -26,6 +25,7 @@ import { configManager as configManagerSingletonInstance } from '../service/conf
 import { instanciate as instanciateExternalAccountService } from '../service/external-account';
 import { FileUploader, getUploader } from '../service/file-uploader'; // eslint-disable-line no-unused-vars
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
+import { initializeImportService } from '../service/import';
 import { InstallerService } from '../service/installer';
 import { normalizeData } from '../service/normalize-data';
 import PageService from '../service/page';
@@ -38,7 +38,9 @@ import { SocketIoService } from '../service/socket-io';
 import UserGroupService from '../service/user-group';
 import { UserNotificationService } from '../service/user-notification';
 import { initializeYjsService } from '../service/yjs';
-import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
+import { getModelSafely, getMongoUri, mongoOptions } from '../util/mongoose-utils';
+
+import { setupModelsDependentOnCrowi } from './setup-models';
 
 
 const logger = loggerFactory('growi:crowi');
@@ -60,6 +62,9 @@ class Crowi {
   /** @type {FileUploader} */
   fileUploadService;
 
+  /** @type {SocketIoService} */
+  socketIoService;
+
   constructor() {
     this.version = pkg.version;
     this.runtimeVersions = undefined; // initialized by scanRuntimeVersions()
@@ -87,7 +92,6 @@ class Crowi {
     this.restQiitaAPIService = null;
     this.growiBridgeService = null;
     this.exportService = null;
-    this.importService = null;
     this.pluginService = null;
     this.searchService = null;
     this.socketIoService = null;
@@ -101,6 +105,7 @@ class Crowi {
 
     this.tokens = null;
 
+    /** @type {import('./setup-models').ModelsMapDependentOnCrowi} */
     this.models = {};
 
     this.env = process.env;
@@ -122,7 +127,7 @@ class Crowi {
 
 Crowi.prototype.init = async function() {
   await this.setupDatabase();
-  await this.setupModels();
+  this.models = await setupModelsDependentOnCrowi(this);
   await this.setupConfigManager();
   await this.setupSessionConfig();
   this.setupCron();
@@ -210,14 +215,13 @@ Crowi.prototype.getEnv = function() {
   return this.env;
 };
 
-// getter/setter of model instance
-//
-Crowi.prototype.model = function(name, model) {
-  if (model != null) {
-    this.models[name] = model;
-  }
-
-  return this.models[name];
+/**
+ * Wrapper function of mongoose.model()
+ * @param {string} modelName
+ * @returns {mongoose.Model}
+ */
+Crowi.prototype.model = function(modelName) {
+  return getModelSafely(modelName);
 };
 
 // getter/setter of event instance
@@ -305,20 +309,6 @@ Crowi.prototype.setupSocketIoService = async function() {
   this.socketIoService = new SocketIoService(this);
 };
 
-Crowi.prototype.setupModels = async function() {
-  Object.keys(modelsDependsOnCrowi).forEach((key) => {
-    const factory = modelsDependsOnCrowi[key];
-
-    if (!(factory instanceof Function)) {
-      logger.warn(`modelsDependsOnCrowi['${key}'] is not a function. skipped.`);
-      return;
-    }
-
-    return this.model(key, modelsDependsOnCrowi[key](this));
-  });
-
-};
-
 Crowi.prototype.setupCron = function() {
   this.questionnaireCronService = new QuestionnaireCronService(this);
   this.questionnaireCronService.startCron();
@@ -692,10 +682,7 @@ Crowi.prototype.setupExport = async function() {
 };
 
 Crowi.prototype.setupImport = async function() {
-  const ImportService = require('../service/import');
-  if (this.importService == null) {
-    this.importService = new ImportService(this);
-  }
+  initializeImportService(this);
 };
 
 Crowi.prototype.setupGrowiPluginService = async function() {

+ 75 - 0
apps/app/src/server/crowi/setup-models.ts

@@ -0,0 +1,75 @@
+import type { Model } from 'mongoose';
+
+import loggerFactory from '~/utils/logger';
+
+import type Crowi from '.';
+
+const logger = loggerFactory('growi:crowi:setup-models');
+
+export type ModelsMapDependentOnCrowi = {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  [modelName: string]: Model<any>,
+}
+
+export const setupModelsDependentOnCrowi = async(crowi: Crowi): Promise<ModelsMapDependentOnCrowi> => {
+  const modelsMap: ModelsMapDependentOnCrowi = {};
+
+  const modelsDependsOnCrowi = {
+    Page: (await import('../models/page')).default,
+    User: (await import('../models/user')).default,
+    Bookmark: (await import('../models/bookmark')).default,
+    GlobalNotificationSetting: (await import('../models/GlobalNotificationSetting')).default,
+    GlobalNotificationMailSetting: (await import('../models/GlobalNotificationSetting/GlobalNotificationMailSetting')).default,
+    GlobalNotificationSlackSetting: (await import('../models/GlobalNotificationSetting/GlobalNotificationSlackSetting')).default,
+    SlackAppIntegration: (await import('../models/slack-app-integration')).default,
+  };
+
+  Object.keys(modelsDependsOnCrowi).forEach((modelName) => {
+    const factory = modelsDependsOnCrowi[modelName];
+
+    if (!(factory instanceof Function)) {
+      logger.warn(`modelsDependsOnCrowi['${modelName}'] is not a function. skipped.`);
+      return;
+    }
+
+    modelsMap[modelName] = factory(crowi);
+  });
+
+  return modelsMap;
+};
+
+export const setupIndependentModels = async(): Promise<void> => {
+  await Promise.all([
+    import('~/features/comment/server/models'),
+    import('~/features/external-user-group/server/models/external-user-group-relation'),
+    import('~/features/external-user-group/server/models/external-user-group'),
+    import('~/features/growi-plugin/server/models'),
+    import('~/features/questionnaire/server/models/proactive-questionnaire-answer'),
+    import('~/features/questionnaire/server/models/questionnaire-answer-status'),
+    import('~/features/questionnaire/server/models/questionnaire-answer'),
+    import('~/features/questionnaire/server/models/questionnaire-order'),
+    import('../models/activity'),
+    import('../models/attachment'),
+    import('../models/bookmark-folder'),
+    import('../models/config'),
+    import('../models/editor-settings'),
+    import('../models/external-account'),
+    import('../models/in-app-notification-settings'),
+    import('../models/in-app-notification'),
+    import('../models/named-query'),
+    import('../models/page-operation'),
+    import('../models/page-redirect'),
+    import('../models/page-tag-relation'),
+    import('../models/password-reset-order'),
+    import('../models/revision'),
+    import('../models/share-link'),
+    import('../models/subscription'),
+    import('../models/tag'),
+    import('../models/transfer-key'),
+    import('../models/update-post'),
+    import('../models/user-group-relation'),
+    import('../models/user-group'),
+    import('../models/user-registration-order'),
+    import('../models/user-ui-settings'),
+  ]);
+};

+ 6 - 8
apps/app/src/server/events/user.ts

@@ -1,10 +1,11 @@
 import EventEmitter from 'events';
 
-import type { IPage, IUserHasId } from '@growi/core';
+import { getIdStringForRef, type IUserHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
+import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
-import type { PageModel } from '~/server/models/page';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 
 import { deleteCompletelyUserHomeBySystem } from '../service/page/delete-completely-user-home-by-system';
@@ -22,16 +23,13 @@ class UserEvent extends EventEmitter {
   }
 
   async onActivated(user: IUserHasId): Promise<void> {
-    const Page = mongoose.model<IPage, PageModel>('Page');
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
     const userHomepagePath = pagePathUtils.userHomepagePath(user);
 
     try {
-      let page = await Page.findByPath(userHomepagePath, true);
+      let page: HydratedDocument<PageDocument> | null = 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()) {
+      if (page != null && page.creator != null && getIdStringForRef(page.creator) !== user._id.toString()) {
         await deleteCompletelyUserHomeBySystem(userHomepagePath, this.crowi.pageService);
         page = null;
       }

+ 2 - 2
apps/app/src/server/interfaces/mongoose-utils.ts

@@ -1,3 +1,3 @@
-import mongoose from 'mongoose';
+import type { Types } from 'mongoose';
 
-export type ObjectIdLike = mongoose.Types.ObjectId | string;
+export type ObjectIdLike = Types.ObjectId | string;

+ 2 - 1
apps/app/src/server/middlewares/access-token-parser.js

@@ -1,6 +1,7 @@
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+
 import loggerFactory from '~/utils/logger';
 
-const { serializeUserSecurely } = require('../models/serializers/user-serializer');
 
 const logger = loggerFactory('growi:middleware:access-token-parser');
 

+ 3 - 1
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -7,7 +7,7 @@ const GlobalNotificationSetting = require('./index');
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
 const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
 
-module.exports = function(crowi) {
+const factory = (crowi) => {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
 
@@ -23,3 +23,5 @@ module.exports = function(crowi) {
 
   return GlobalNotificationMailSettingModel;
 };
+
+export default factory;

+ 3 - 1
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -7,7 +7,7 @@ const GlobalNotificationSetting = require('./index');
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
 const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
 
-module.exports = function(crowi) {
+const factory = (crowi) => {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
 
@@ -23,3 +23,5 @@ module.exports = function(crowi) {
 
   return GlobalNotificationSlackSettingModel;
 };
+
+export default factory;

+ 3 - 3
apps/app/src/server/models/attachment.ts

@@ -3,7 +3,7 @@ import path from 'path';
 import type { IAttachment } from '@growi/core';
 import { addSeconds } from 'date-fns/addSeconds';
 import {
-  Schema, type Model, type Document, Types,
+  Schema, type Model, type Document,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
@@ -36,8 +36,8 @@ export interface IAttachmentModel extends Model<IAttachmentDocument> {
 }
 
 const attachmentSchema = new Schema({
-  page: { type: Types.ObjectId, ref: 'Page', index: true },
-  creator: { type: Types.ObjectId, ref: 'User', index: true },
+  page: { type: Schema.Types.ObjectId, ref: 'Page', index: true },
+  creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
   filePath: { type: String }, // DEPRECATED: remains for backward compatibility for v3.3.x or below
   fileName: { type: String, required: true, unique: true },
   fileFormat: { type: String, required: true },

+ 3 - 1
apps/app/src/server/models/bookmark.js

@@ -7,7 +7,7 @@ const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
-module.exports = function(crowi) {
+const factory = (crowi) => {
   const bookmarkEvent = crowi.event('bookmark');
 
   let bookmarkSchema = null;
@@ -114,3 +114,5 @@ module.exports = function(crowi) {
 
   return mongoose.model('Bookmark', bookmarkSchema);
 };
+
+export default factory;

+ 3 - 3
apps/app/src/server/models/external-account.ts

@@ -1,7 +1,8 @@
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 import type { IExternalAccount, IExternalAccountHasId, IUserHasId } from '@growi/core';
-import { Schema, Model, Document } from 'mongoose';
+import type { Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 
@@ -12,7 +13,6 @@ const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
 
 export interface ExternalAccountDocument extends IExternalAccount, Document {}
 
@@ -23,7 +23,7 @@ export interface ExternalAccountModel extends Model<ExternalAccountDocument> {
 const schema = new Schema<ExternalAccountDocument, ExternalAccountModel>({
   providerType: { type: String, required: true },
   accountId: { type: String, required: true },
-  user: { type: ObjectId, ref: 'User', required: true },
+  user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
 }, {
   timestamps: { createdAt: true, updatedAt: false },
 });

+ 0 - 27
apps/app/src/server/models/index.ts

@@ -1,27 +0,0 @@
-import GlobalNotificationSettingFactory from './GlobalNotificationSetting';
-import Page from './page';
-
-export const modelsDependsOnCrowi = {
-  Page,
-  PageTagRelation: require('./page-tag-relation'),
-  User: require('./user'),
-  Bookmark: require('./bookmark'),
-  GlobalNotificationSetting: GlobalNotificationSettingFactory,
-  GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
-  GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
-  SlackAppIntegration: require('./slack-app-integration'),
-};
-
-// setup models that independent from crowi
-export * from './attachment';
-export * as Activity from './activity';
-export * as PageRedirect from './page-redirect';
-export * from './revision';
-export * as ShareLink from './share-link';
-export * as Tag from './tag';
-export * as UserGroup from './user-group';
-export * as PageTagRelation from './page-tag-relation';
-
-export * from './serializers';
-
-export * from './GlobalNotificationSetting';

+ 6 - 6
apps/app/src/server/models/named-query.ts

@@ -1,10 +1,12 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
-import mongoose, {
-  Schema, Model, Document,
+import type { Model, Document } from 'mongoose';
+import {
+  Schema,
 } from 'mongoose';
 
-import { INamedQuery, SearchDelegatorName } from '~/interfaces/named-query';
+import type { INamedQuery } from '~/interfaces/named-query';
+import { SearchDelegatorName } from '~/interfaces/named-query';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
@@ -17,14 +19,12 @@ export interface NamedQueryDocument extends INamedQuery, Document {}
 
 export type NamedQueryModel = Model<NamedQueryDocument>
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
 const schema = new Schema<NamedQueryDocument, NamedQueryModel>({
   name: { type: String, required: true, unique: true },
   aliasOf: { type: String },
   delegatorName: { type: String, enum: SearchDelegatorName },
   creator: {
-    type: ObjectId, ref: 'User', index: true, default: null,
+    type: Schema.Types.ObjectId, ref: 'User', index: true, default: null,
   },
 });
 

+ 28 - 28
apps/app/src/server/models/page.ts

@@ -11,10 +11,12 @@ import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
-import type { Model, Document, AnyObject } from 'mongoose';
-import mongoose, {
-  Schema,
+import type {
+  Model, Document, AnyObject,
+  HydratedDocument,
+  Types,
 } from 'mongoose';
+import mongoose, { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
@@ -44,7 +46,7 @@ const PAGE_GRANT_ERROR = 1;
 const STATUS_PUBLISHED = 'published';
 const STATUS_DELETED = 'deleted';
 
-export interface PageDocument extends IPage, Document {
+export interface PageDocument extends IPage, Document<Types.ObjectId> {
   [x:string]: any // for obsolete methods
   getLatestRevisionBodyLength(): Promise<number | null | undefined>
   calculateAndUpdateLatestRevisionBodyLength(this: PageDocument): Promise<void>
@@ -63,25 +65,26 @@ type PaginatedPages = {
   offset: number
 }
 
-export type CreateMethod = (path: string, body: string, user, options: IOptionsForCreate) => Promise<PageDocument & { _id: any }>
+export type CreateMethod = (path: string, body: string, user, options: IOptionsForCreate) => Promise<HydratedDocument<PageDocument>>
 
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
-  findByIdAndViewer(pageId: ObjectIdLike, user, userGroups?, includeEmpty?: boolean): Promise<PageDocument & HasObjectId>
+  findByIdAndViewer(pageId: ObjectIdLike, user, userGroups?, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument> | null>
   findByIdsAndViewer(
     pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean,
-  ): Promise<(PageDocument & HasObjectId)[]>
-  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?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>
+  ): Promise<HydratedDocument<PageDocument>[]>
+  findByPath(path: string, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument> | null>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument> | null>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument>[]>
   countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>
+  findParentByPath(path: string | null): Promise<HydratedDocument<PageDocument> | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
-    user, userGroups: string[] | null, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
+    user, userGroups: ObjectIdLike[] | null, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
-  findNonEmptyClosestAncestor(path: string): Promise<PageDocument | null>
-  findNotEmptyParentByPathRecursively(path: string): Promise<PageDocument | undefined>
+  findNonEmptyClosestAncestor(path: string): Promise<HydratedDocument<PageDocument> | null>
+  findNotEmptyParentByPathRecursively(path: string): Promise<HydratedDocument<PageDocument> | null>
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
   findTemplate(path: string): Promise<{
     templateBody?: string,
@@ -101,22 +104,21 @@ export interface PageModel extends Model<PageDocument> {
   STATUS_DELETED
 }
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
 
 const schema = new Schema<PageDocument, PageModel>({
   parent: {
-    type: ObjectId, ref: 'Page', index: true, default: null,
+    type: Schema.Types.ObjectId, ref: 'Page', index: true, default: null,
   },
   descendantCount: { type: Number, default: 0 },
   isEmpty: { type: Boolean, default: false },
   path: {
     type: String, required: true, index: true,
   },
-  revision: { type: ObjectId, ref: 'Revision' },
+  revision: { type: Schema.Types.ObjectId, ref: 'Revision' },
   latestRevisionBodyLength: { type: Number },
   status: { type: String, default: STATUS_PUBLISHED, index: true },
   grant: { type: Number, default: GRANT_PUBLIC, index: true },
-  grantedUsers: [{ type: ObjectId, ref: 'User' }],
+  grantedUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }],
   grantedGroups: {
     type: [{
       type: {
@@ -126,7 +128,7 @@ const schema = new Schema<PageDocument, PageModel>({
         default: 'UserGroup',
       },
       item: {
-        type: ObjectId,
+        type: Schema.Types.ObjectId,
         refPath: 'grantedGroups.type',
         required: true,
         index: true,
@@ -140,16 +142,16 @@ const schema = new Schema<PageDocument, PageModel>({
     default: [],
     required: true,
   },
-  creator: { type: ObjectId, ref: 'User', index: true },
-  lastUpdateUser: { type: ObjectId, ref: 'User' },
-  liker: [{ type: ObjectId, ref: 'User' }],
-  seenUsers: [{ type: ObjectId, ref: 'User' }],
+  creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  lastUpdateUser: { type: Schema.Types.ObjectId, ref: 'User' },
+  liker: [{ type: Schema.Types.ObjectId, ref: 'User' }],
+  seenUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }],
   commentCount: { type: Number, default: 0 },
   expandContentWidth: { type: Boolean },
   wip: { type: Boolean },
   ttlTimestamp: { type: Date },
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
-  deleteUser: { type: ObjectId, ref: 'User' },
+  deleteUser: { type: Schema.Types.ObjectId, ref: 'User' },
   deletedAt: { type: Date },
 }, {
   timestamps: { createdAt: true, updatedAt: false },
@@ -423,7 +425,7 @@ export class PageQueryBuilder {
   }
 
   addConditionToFilteringByViewer(
-      user, userGroups: string[] | null, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
+      user, userGroups: ObjectIdLike[] | null, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
   ): PageQueryBuilder {
     const condition = generateGrantCondition(user, userGroups, includeAnyoneWithTheLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
 
@@ -757,10 +759,8 @@ schema.statics.createEmptyPagesByPaths = async function(paths: string[], aggrPip
 
 /**
  * Find a parent page by path
- * @param {string} path
- * @returns {Promise<PageDocument | null>}
  */
-schema.statics.findParentByPath = async function(path: string): Promise<PageDocument | null> {
+schema.statics.findParentByPath = async function(path: string): Promise<HydratedDocument<PageDocument> | null> {
   const parentPath = nodePath.dirname(path);
 
   const builder = new PageQueryBuilder(this.find({ path: parentPath }), true);
@@ -968,7 +968,7 @@ schema.statics.findParent = async function(pageId): Promise<PageDocument | null>
 schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
 
 export function generateGrantCondition(
-    user, userGroups: string[] | null, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
+    user, userGroups: ObjectIdLike[] | null, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
 ): { $or: any[] } {
   const grantConditions: AnyObject[] = [
     { grant: null },

+ 1 - 3
apps/app/src/server/models/password-reset-order.ts

@@ -10,8 +10,6 @@ import uniqueValidator from 'mongoose-unique-validator';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
 export interface IPasswordResetOrder {
   token: string,
   email: string,
@@ -39,7 +37,7 @@ const expiredAt = (): Date => {
 const schema = new Schema<PasswordResetOrderDocument, PasswordResetOrderModel>({
   token: { type: String, required: true, unique: true },
   email: { type: String, required: true },
-  relatedUser: { type: ObjectId, ref: 'User' },
+  relatedUser: { type: Schema.Types.ObjectId, ref: 'User' },
   isRevoked: { type: Boolean, default: false, required: true },
   expiredAt: { type: Date, default: expiredAt, required: true },
 }, {

+ 15 - 6
apps/app/src/server/models/revision.ts

@@ -4,8 +4,9 @@ import type {
   Origin,
 } from '@growi/core';
 import { allOrigin } from '@growi/core';
+import type { Types } from 'mongoose';
 import {
-  Schema, Types, type Document, type Model,
+  Schema, type Document, type Model,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
@@ -17,10 +18,11 @@ import type { PageDocument } from './page';
 
 const logger = loggerFactory('growi:models:revision');
 
+
 export interface IRevisionDocument extends IRevision, Document {
 }
 
-type UpdateRevisionListByPageId = (pageId: string, updateData: Partial<IRevision>) => Promise<void>;
+type UpdateRevisionListByPageId = (pageId: Types.ObjectId, updateData: Partial<IRevision>) => Promise<void>;
 type PrepareRevision = (
   pageData: PageDocument, body: string, previousBody: string | null, user: HasObjectId, origin?: Origin, options?: { format: string }
 ) => IRevisionDocument;
@@ -37,7 +39,7 @@ const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
   // The type of pageId is always converted to String at server startup
   // Refer to this method (/src/server/service/normalize-data/convert-revision-page-id-to-string.ts) to change the pageId type
   pageId: {
-    type: String, required: true, index: true,
+    type: Schema.Types.ObjectId, ref: 'Page', required: true, index: true,
   },
   body: {
     type: String,
@@ -49,7 +51,7 @@ const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
     },
   },
   format: { type: String, default: 'markdown' },
-  author: { type: Types.ObjectId, ref: 'User' },
+  author: { type: Schema.Types.ObjectId, ref: 'User' },
   hasDiffToPrev: { type: Boolean },
   origin: { type: String, enum: allOrigin },
 }, {
@@ -58,13 +60,20 @@ const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
 revisionSchema.plugin(mongoosePaginate);
 
 const updateRevisionListByPageId: UpdateRevisionListByPageId = async function(this: IRevisionModel, pageId, updateData) {
+  // Check pageId for safety
+  if (pageId == null) {
+    throw new Error('Error: pageId is required');
+  }
   await this.updateMany({ pageId }, { $set: updateData });
 };
 revisionSchema.statics.updateRevisionListByPageId = updateRevisionListByPageId;
 
 const prepareRevision: PrepareRevision = function(this: IRevisionModel, pageData, body, previousBody, user, origin, options = { format: 'markdown' }) {
-  if (!user._id) {
-    throw new Error('Error: user should have _id');
+  if (user._id == null) {
+    throw new Error('user should have _id');
+  }
+  if (pageData._id == null) {
+    throw new Error('pageData should have _id');
   }
 
   const newRevision = new this();

+ 1 - 1
apps/app/src/server/models/serializers/bookmark-serializer.js

@@ -1,4 +1,4 @@
-const { serializePageSecurely } = require('./page-serializer');
+import { serializePageSecurely } from './page-serializer';
 
 function serializeInsecurePageAttributes(bookmark) {
   if (bookmark.page != null && bookmark.page._id != null) {

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

@@ -2,4 +2,3 @@ export * from './bookmark-serializer';
 export * from './page-serializer';
 export * from './revision-serializer';
 export * from './user-group-relation-serializer';
-export * from './user-serializer';

+ 1 - 1
apps/app/src/server/models/serializers/page-serializer.js

@@ -1,4 +1,4 @@
-const { serializeUserSecurely } = require('./user-serializer');
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 function depopulate(page, attributeName) {
   // revert the ObjectID

+ 1 - 1
apps/app/src/server/models/serializers/revision-serializer.js

@@ -1,4 +1,4 @@
-const { serializeUserSecurely } = require('./user-serializer');
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 function serializeInsecureUserAttributes(revision) {
   if (revision.author != null && revision.author._id != null) {

+ 1 - 1
apps/app/src/server/models/serializers/user-group-relation-serializer.js

@@ -1,4 +1,4 @@
-const { serializeUserSecurely } = require('./user-serializer');
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 function serializeInsecureUserAttributes(userGroupRelation) {
   if (userGroupRelation.relatedUser != null && userGroupRelation.relatedUser._id != null) {

+ 0 - 35
apps/app/src/server/models/serializers/user-serializer.js

@@ -1,35 +0,0 @@
-const mongoose = require('mongoose');
-
-
-export function omitInsecureAttributes(user) {
-  // omit password
-  delete user.password;
-  // omit apiToken
-  delete user.apiToken;
-
-  // omit email
-  if (!user.isEmailPublished) {
-    delete user.email;
-  }
-  return user;
-}
-
-export function serializeUserSecurely(user) {
-  const User = mongoose.model('User');
-
-  // return when it is not a user object
-  if (user == null || !(user instanceof User)) {
-    return user;
-  }
-
-  let serialized = user;
-
-  // invoke toObject if page is a model instance
-  if (user.toObject != null) {
-    serialized = user.toObject();
-  }
-
-  omitInsecureAttributes(serialized);
-
-  return serialized;
-}

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

@@ -20,10 +20,9 @@ export type ShareLinkModel = Model<ShareLinkDocument>;
 /*
  * define schema
  */
-const ObjectId = mongoose.Schema.Types.ObjectId;
 const schema = new Schema<ShareLinkDocument, ShareLinkModel>({
   relatedPage: {
-    type: ObjectId,
+    type: Schema.Types.ObjectId,
     ref: 'Page',
     required: true,
     index: true,

+ 10 - 1
apps/app/src/server/models/slack-app-integration.js

@@ -3,6 +3,8 @@ import crypto from 'crypto';
 import { defaultSupportedSlackEventActions } from '@growi/slack';
 import mongoose from 'mongoose';
 
+import { getModelSafely } from '../util/mongoose-utils';
+
 
 const schema = new mongoose.Schema({
   tokenGtoP: { type: String, required: true, unique: true },
@@ -53,8 +55,15 @@ class SlackAppIntegration {
 
 }
 
-module.exports = function(crowi) {
+const factory = (crowi) => {
+  const modelExists = getModelSafely('SlackAppIntegration');
+  if (modelExists != null) {
+    return modelExists;
+  }
+
   SlackAppIntegration.crowi = crowi;
   schema.loadClass(SlackAppIntegration);
   return mongoose.model('SlackAppIntegration', schema);
 };
+
+export default factory;

+ 3 - 4
apps/app/src/server/models/update-post.ts

@@ -1,8 +1,7 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
-import {
-  Types, Schema, Model, Document,
-} from 'mongoose';
+import type { Types, Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
@@ -12,7 +11,7 @@ export interface IUpdatePost {
   patternPrefix2: string
   channel: string
   provider: string
-  creator: Schema.Types.ObjectId
+  creator: Types.ObjectId
   createdAt: Date
 }
 

+ 4 - 5
apps/app/src/server/models/user-group-relation.ts

@@ -13,7 +13,6 @@ const debug = require('debug')('growi:models:userGroupRelation');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
-const ObjectId = Schema.Types.ObjectId;
 
 export interface UserGroupRelationDocument extends IUserGroupRelation, Document {}
 
@@ -32,15 +31,15 @@ export interface UserGroupRelationModel extends Model<UserGroupRelationDocument>
 
   findAllGroupsForUser: (user) => Promise<UserGroupDocument[]>
 
-  findAllUserGroupIdsRelatedToUser: (user) => Promise<string[]>
+  findAllUserGroupIdsRelatedToUser: (user) => Promise<ObjectIdLike[]>
 }
 
 /*
  * define schema
  */
 const schema = new Schema<UserGroupRelationDocument, UserGroupRelationModel>({
-  relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
-  relatedUser: { type: ObjectId, ref: 'User', required: true },
+  relatedGroup: { type: Schema.Types.ObjectId, ref: 'UserGroup', required: true },
+  relatedUser: { type: Schema.Types.ObjectId, ref: 'User', required: true },
 }, {
   timestamps: { createdAt: true, updatedAt: false },
 });
@@ -143,7 +142,7 @@ schema.statics.findAllGroupsForUser = async function(user): Promise<UserGroupDoc
  * @param {User} user
  * @returns {Promise<ObjectId[]>}
  */
-schema.statics.findAllUserGroupIdsRelatedToUser = async function(user): Promise<string[]> {
+schema.statics.findAllUserGroupIdsRelatedToUser = async function(user): Promise<ObjectIdLike[]> {
   const relations = await this.find({ relatedUser: user._id })
     .select('relatedGroup')
     .exec();

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

@@ -19,11 +19,9 @@ export interface UserGroupModel extends Model<UserGroupDocument> {
 /*
  * define schema
  */
-const ObjectId = Schema.Types.ObjectId;
-
 const schema = new Schema<UserGroupDocument, UserGroupModel>({
   name: { type: String, required: true, unique: true },
-  parent: { type: ObjectId, ref: 'UserGroup', index: true },
+  parent: { type: Schema.Types.ObjectId, ref: 'UserGroup', index: true },
   description: { type: String, default: '' },
 }, {
   timestamps: true,

+ 12 - 5
apps/app/src/server/models/user.js

@@ -1,4 +1,5 @@
 /* eslint-disable no-use-before-define */
+import { omitInsecureAttributes } from '@growi/core/dist/models/serializers';
 import { pagePathUtils } from '@growi/core/dist/utils';
 
 import { i18n } from '^/config/next-i18next.config';
@@ -6,6 +7,8 @@ import { i18n } from '^/config/next-i18next.config';
 import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 
+import { getModelSafely } from '../util/mongoose-utils';
+
 import { Attachment } from './attachment';
 
 
@@ -15,13 +18,15 @@ const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
+const logger = loggerFactory('growi:models:user');
 
-const { omitInsecureAttributes } = require('./serializers/user-serializer');
+const factory = (crowi) => {
 
-const logger = loggerFactory('growi:models:user');
+  const userModelExists = getModelSafely('User');
+  if (userModelExists != null) {
+    return userModelExists;
+  }
 
-module.exports = function(crowi) {
   const STATUS_REGISTERED = 1;
   const STATUS_ACTIVE = 2;
   const STATUS_SUSPENDED = 3;
@@ -44,7 +49,7 @@ module.exports = function(crowi) {
   const userSchema = new mongoose.Schema({
     userId: String,
     image: String,
-    imageAttachment: { type: ObjectId, ref: 'Attachment' },
+    imageAttachment: { type: mongoose.Schema.Types.ObjectId, ref: 'Attachment' },
     imageUrlCached: String,
     isGravatarEnabled: { type: Boolean, default: false },
     isEmailPublished: { type: Boolean, default: true },
@@ -786,3 +791,5 @@ module.exports = function(crowi) {
 
   return mongoose.model('User', userSchema);
 };
+
+export default factory;

+ 39 - 0
apps/app/src/server/repl.ts

@@ -0,0 +1,39 @@
+import type { REPLServer } from 'node:repl';
+import repl from 'node:repl';
+
+import mongoose from 'mongoose';
+
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+
+import Crowi from './crowi';
+
+
+const setupMongoose = async(replServer: REPLServer) => {
+  mongoose.Promise = global.Promise;
+
+  await mongoose.connect(getMongoUri(), mongoOptions)
+    .then(() => {
+      replServer.context.db = mongoose.connection.db;
+    });
+
+  replServer.context.mongoose = mongoose;
+};
+
+
+const setupCrowi = async(replServer: REPLServer) => {
+  const crowi = new Crowi();
+  await crowi.init();
+  replServer.context.crowi = crowi;
+};
+
+const start = async() => {
+  const replServer = repl.start({
+    prompt: `${process.env.NODE_ENV} > `,
+    ignoreUndefined: true,
+  });
+
+  await setupMongoose(replServer);
+  await setupCrowi(replServer);
+};
+
+start();

+ 12 - 10
apps/app/src/server/routes/apiv3/activity.ts

@@ -1,16 +1,17 @@
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { parseISO, addMinutes, isValid } from 'date-fns';
-import express, { Request, Router } from 'express';
+import type { Request, Router } from 'express';
+import express from 'express';
 import { query } from 'express-validator';
 
-import { IActivity, ISearchFilter } from '~/interfaces/activity';
+import type { IActivity, ISearchFilter } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import loggerFactory from '~/utils/logger';
 
-import Crowi from '../../crowi';
+import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { serializeUserSecurely } from '../../models/serializers/user-serializer';
 
-import { ApiV3Response } from './interfaces/apiv3-response';
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
 const logger = loggerFactory('growi:routes:apiv3:activity');
@@ -96,6 +97,7 @@ module.exports = (crowi: Crowi): Router => {
       const paginateResult = await Activity.paginate(
         query,
         {
+          lean: true,
           limit,
           offset,
           sort: { createdAt: -1 },
@@ -103,12 +105,12 @@ module.exports = (crowi: Crowi): Router => {
         },
       );
 
-      const User = crowi.model('User');
       const serializedDocs = paginateResult.docs.map((doc: IActivity) => {
-        if (doc.user != null && doc.user instanceof User) {
-          doc.user = serializeUserSecurely(doc.user);
-        }
-        return doc;
+        const { user, ...rest } = doc;
+        return {
+          user: serializeUserSecurely(user),
+          ...rest,
+        };
       });
 
       const serializedPaginationResult = {

+ 4 - 5
apps/app/src/server/routes/apiv3/attachment.js

@@ -1,10 +1,13 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import express from 'express';
 import multer from 'multer';
 import autoReap from 'multer-autoreap';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
-import { Attachment } from '~/server/models';
+import { Attachment } from '~/server/models/attachment';
+import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -14,16 +17,12 @@ import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
 
 
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
-const express = require('express');
 
 const router = express.Router();
 const {
   query, param, body,
 } = require('express-validator');
 
-const { serializePageSecurely } = require('../../models/serializers/page-serializer');
-const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 /**
  * @swagger

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

@@ -1,3 +1,5 @@
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
@@ -12,7 +14,6 @@ const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-
 const express = require('express');
 const { body, query, param } = require('express-validator');
 
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const router = express.Router();
 

+ 1 - 1
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -9,7 +9,7 @@ import multer from 'multer';
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
-import { Attachment } from '~/server/models';
+import { Attachment } from '~/server/models/attachment';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';

+ 1 - 1
apps/app/src/server/routes/apiv3/forgot-password.js

@@ -1,4 +1,5 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { format, subSeconds } from 'date-fns';
 
 import { SupportedAction } from '~/interfaces/activity';
@@ -17,7 +18,6 @@ const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-dis
 const express = require('express');
 const { body } = require('express-validator');
 
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const router = express.Router();
 

+ 13 - 6
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -2,20 +2,24 @@ import { createReadStream } from 'fs';
 import path from 'path';
 
 import { ErrorV3 } from '@growi/core/dist/models';
-import express, { NextFunction, Request, Router } from 'express';
+import type { NextFunction, Request, Router } from 'express';
+import express from 'express';
 import { body } from 'express-validator';
 import multer from 'multer';
 
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
-import { IDataGROWIInfo, X_GROWI_TRANSFER_KEY_HEADER_NAME } from '~/server/service/g2g-transfer';
+import { configManager } from '~/server/service/config-manager';
+import type { IDataGROWIInfo } from '~/server/service/g2g-transfer';
+import { X_GROWI_TRANSFER_KEY_HEADER_NAME } from '~/server/service/g2g-transfer';
+import { getImportService } from '~/server/service/import';
 import loggerFactory from '~/utils/logger';
 import { TransferKey } from '~/utils/vo/transfer-key';
 
 
-import Crowi from '../../crowi';
+import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
-import { ApiV3Response } from './interfaces/apiv3-response';
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
 interface AuthorizedRequest extends Request {
   user?: any
@@ -36,9 +40,12 @@ const validator = {
  */
 module.exports = (crowi: Crowi): Router => {
   const {
-    g2gTransferPusherService, g2gTransferReceiverService, exportService, importService,
-    growiBridgeService, configManager,
+    g2gTransferPusherService, g2gTransferReceiverService, exportService,
+    growiBridgeService,
   } = crowi;
+
+  const importService = getImportService();
+
   if (g2gTransferPusherService == null || g2gTransferReceiverService == null || exportService == null || importService == null
     || growiBridgeService == null || configManager == null) {
     throw Error('GROWI is not ready for g2g transfer');

+ 36 - 44
apps/app/src/server/routes/apiv3/import.js

@@ -1,15 +1,12 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { getImportService } from '~/server/service/import';
+import { generateOverwriteParams } from '~/server/service/import/overwrite-params';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 
-import overwriteParamsAttachmentFilesChunks from './overwrite-params/attachmentFiles.chunks';
-import overwriteParamsPages from './overwrite-params/pages';
-import overwriteParamsRevisions from './overwrite-params/revisions';
-
-
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 
 const path = require('path');
@@ -18,9 +15,6 @@ const express = require('express');
 const multer = require('multer');
 
 
-const GrowiArchiveImportOption = require('~/models/admin/growi-archive-import-option');
-
-
 const router = express.Router();
 
 /**
@@ -29,6 +23,21 @@ const router = express.Router();
  *    name: Import
  */
 
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      GrowiArchiveImportOption:
+ *        description: GrowiArchiveImportOption
+ *        type: object
+ *        properties:
+ *          mode:
+ *            description: Import mode
+ *            type: string
+ *            enum: [insert, upsert, flushAndInsert]
+ */
+
 /**
  * @swagger
  *
@@ -51,27 +60,10 @@ const router = express.Router();
  *            description: whether the current importing job exists or not
  */
 
-/**
- * generate overwrite params with overwrite-params/* modules
- * @param {string} collectionName
- * @param {string} operatorUserId Operator user id
- * @param {GrowiArchiveImportOption} options GrowiArchiveImportOption instance
- */
-export const generateOverwriteParams = (collectionName, operatorUserId, options) => {
-  switch (collectionName) {
-    case 'pages':
-      return overwriteParamsPages(operatorUserId, options);
-    case 'revisions':
-      return overwriteParamsRevisions(operatorUserId, options);
-    case 'attachmentFiles.chunks':
-      return overwriteParamsAttachmentFilesChunks(operatorUserId, options);
-    default:
-      return {};
-  }
-};
-
 export default function route(crowi) {
-  const { growiBridgeService, importService, socketIoService } = crowi;
+  const { growiBridgeService, socketIoService } = crowi;
+  const importService = getImportService(crowi);
+
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
@@ -199,29 +191,27 @@ export default function route(crowi) {
    *                  type: array
    *                  items:
    *                    type: string
-   *                optionsMap:
+   *                options:
    *                  description: |
-   *                    the map object of importing option that have collection name as the key
+   *                    the array of importing option that have collection name as the key
    *                  additionalProperties:
-   *                    type: object
-   *                    properties:
-   *                      mode:
-   *                        description: Import mode
-   *                        type: string
-   *                        enum: [insert, upsert, flushAndInsert]
+   *                    type: array
+   *                    items:
+   *                      $ref: '#/components/schemas/GrowiArchiveImportOption'
    *      responses:
    *        200:
    *          description: Import process has requested
    */
   router.post('/', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
     // TODO: add express validator
-    const { fileName, collections, optionsMap } = req.body;
+    const { fileName, collections, options } = req.body;
 
     // pages collection can only be imported by upsert if isV5Compatible is true
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isImportPagesCollection = collections.includes('pages');
     if (isV5Compatible && isImportPagesCollection) {
-      const option = new GrowiArchiveImportOption(null, optionsMap.pages);
+      /** @type {ImportOptionForPages} */
+      const option = options.find(opt => opt.collectionName === 'pages');
       if (option.mode !== 'upsert') {
         return res.apiv3Err(new ErrorV3('Upsert is only available for importing pages collection.', 'only_upsert_available'));
       }
@@ -278,14 +268,16 @@ export default function route(crowi) {
     const importSettingsMap = {};
     fileStatsToImport.forEach(({ fileName, collectionName }) => {
       // instanciate GrowiArchiveImportOption
-      const options = new GrowiArchiveImportOption(null, optionsMap[collectionName]);
+      /** @type {GrowiArchiveImportOption} */
+      const option = options.find(opt => opt.collectionName === collectionName);
 
       // generate options
-      const importSettings = importService.generateImportSettings(options.mode);
-      importSettings.jsonFileName = fileName;
-
-      // generate overwrite params
-      importSettings.overwriteParams = generateOverwriteParams(collectionName, req.user._id, options);
+      /** @type {import('~/server/service/import').ImportSettings} */
+      const importSettings = {
+        mode: option.mode,
+        jsonFileName: fileName,
+        overwriteParams: generateOverwriteParams(collectionName, req.user._id, option),
+      };
 
       importSettingsMap[collectionName] = importSettings;
     });

+ 31 - 17
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -1,12 +1,14 @@
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import express from 'express';
+
 import { SupportedAction } from '~/interfaces/activity';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 
+import type { IInAppNotification } from '../../../interfaces/in-app-notification';
 
-import { IInAppNotification } from '../../../interfaces/in-app-notification';
-
-const express = require('express');
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const router = express.Router();
 
@@ -22,14 +24,18 @@ module.exports = (crowi) => {
 
   const activityEvent = crowi.event('activity');
 
-  router.get('/list', accessTokenParser, loginRequiredStrictly, async(req, res) => {
-    const user = req.user;
+  router.get('/list', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+    // user must be set by loginRequiredStrictly
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const user = req.user!;
 
-    const limit = parseInt(req.query.limit) || 10;
+    const limit = req.query.limit != null
+      ? parseInt(req.query.limit.toString()) || 10
+      : 10;
 
     let offset = 0;
-    if (req.query.offset) {
-      offset = parseInt(req.query.offset, 10);
+    if (req.query.offset != null) {
+      offset = parseInt(req.query.offset.toString(), 10);
     }
 
     const queryOptions = {
@@ -73,10 +79,13 @@ module.exports = (crowi) => {
     return res.apiv3(serializedPaginationResult);
   });
 
-  router.get('/status', accessTokenParser, loginRequiredStrictly, async(req, res) => {
-    const userId = req.user._id;
+  router.get('/status', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+    // user must be set by loginRequiredStrictly
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const user = req.user!;
+
     try {
-      const count = await inAppNotificationService.getUnreadCountByUser(userId);
+      const count = await inAppNotificationService.getUnreadCountByUser(user._id);
       return res.apiv3({ count });
     }
     catch (err) {
@@ -84,7 +93,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.post('/read', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.post('/read', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
     const user = req.user;
 
     try {
@@ -96,8 +105,11 @@ module.exports = (crowi) => {
     }
   });
 
-  router.post('/open', accessTokenParser, loginRequiredStrictly, async(req, res) => {
-    const user = req.user;
+  router.post('/open', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+    // user must be set by loginRequiredStrictly
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const user = req.user!;
+
     const id = req.body.id;
 
     try {
@@ -110,8 +122,10 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/all-statuses-open', accessTokenParser, loginRequiredStrictly, addActivity, async(req, res) => {
-    const user = req.user;
+  router.put('/all-statuses-open', accessTokenParser, loginRequiredStrictly, addActivity, async(req: CrowiRequest, res: ApiV3Response) => {
+    // user must be set by loginRequiredStrictly
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const user = req.user!;
 
     try {
       await inAppNotificationService.updateAllNotificationsAsOpened(user);

+ 9 - 4
apps/app/src/server/routes/apiv3/invited.ts

@@ -1,16 +1,18 @@
-import express, { Request, Router } from 'express';
+import type { IUser } from '@growi/core';
+import type { Request, Router } from 'express';
+import express from 'express';
+import mongoose from 'mongoose';
 
-import Crowi from '../../crowi';
+import type Crowi from '../../crowi';
 import { invitedRules, invitedValidation } from '../../middlewares/invited-form-validator';
 
-import { ApiV3Response } from './interfaces/apiv3-response';
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
 type InvitedFormRequest = Request & { form: any, user: any };
 
 module.exports = (crowi: Crowi): Router => {
   const applicationInstalled = require('../../middlewares/application-installed')(crowi);
   const debug = require('debug')('growi:routes:login');
-  const User = crowi.model('User');
   const router = express.Router();
 
   router.post('/', applicationInstalled, invitedRules(), invitedValidation, async(req: InvitedFormRequest, res: ApiV3Response) => {
@@ -22,6 +24,9 @@ module.exports = (crowi: Crowi): Router => {
       return res.apiv3Err(req.form.errors, 400);
     }
 
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const User = mongoose.model<IUser, any>('User');
+
     const user = req.user;
     const invitedForm = req.form.invitedForm || {};
     const username = invitedForm.username;

+ 1 - 1
apps/app/src/server/routes/apiv3/notification-setting.js

@@ -1,7 +1,7 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
-import { GlobalNotificationSettingType } from '~/server/models';
+import { GlobalNotificationSettingType } from '~/server/models/GlobalNotificationSetting';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 

+ 0 - 32
apps/app/src/server/routes/apiv3/overwrite-params/attachmentFiles.chunks.js

@@ -1,32 +0,0 @@
-const { Binary } = require('mongodb');
-const { ObjectId } = require('mongoose').Types;
-
-class AttachmentFilesChunksOverwriteParamsFactory {
-
-  /**
-   * generate overwrite params object
-   * @param {string} operatorUserId
-   * @param {ImportOptionForPages} option
-   * @return object
-   *  key: property name
-   *  value: any value or a function `(value, { document, schema, propertyName }) => { return newValue }`
-   */
-  static generate(operatorUserId, option) {
-    const params = {};
-
-    // Date
-    params.files_id = (value, { document, schema, propertyName }) => {
-      return ObjectId(value);
-    };
-
-    // Binary
-    params.data = (value, { document, schema, propertyName }) => {
-      return Binary(value);
-    };
-
-    return params;
-  }
-
-}
-
-module.exports = (operatorUserId, option) => AttachmentFilesChunksOverwriteParamsFactory.generate(operatorUserId, option);

+ 0 - 62
apps/app/src/server/routes/apiv3/overwrite-params/pages.js

@@ -1,62 +0,0 @@
-import { PageGrant } from '@growi/core';
-
-const mongoose = require('mongoose');
-
-// eslint-disable-next-line no-unused-vars
-const ImportOptionForPages = require('~/models/admin/import-option-for-pages');
-
-const { ObjectId } = mongoose.Types;
-
-class PageOverwriteParamsFactory {
-
-  /**
-   * generate overwrite params object
-   * @param {string} operatorUserId
-   * @param {ImportOptionForPages} option
-   * @return object
-   *  key: property name
-   *  value: any value or a function `(value, { document, schema, propertyName }) => { return newValue }`
-   */
-  static generate(operatorUserId, option) {
-    const params = {};
-
-    if (option.isOverwriteAuthorWithCurrentUser) {
-      const userId = ObjectId(operatorUserId);
-      params.creator = userId;
-      params.lastUpdateUser = userId;
-    }
-
-    params.grant = (value, { document, schema, propertyName }) => {
-      if (option.makePublicForGrant2 && value === 2) {
-        return PageGrant.GRANT_PUBLIC;
-      }
-      if (option.makePublicForGrant4 && value === 4) {
-        return PageGrant.GRANT_PUBLIC;
-      }
-      if (option.makePublicForGrant5 && value === 5) {
-        return PageGrant.GRANT_PUBLIC;
-      }
-      return value;
-    };
-
-    params.parent = (value, { document, schema, propertyName }) => {
-      return null;
-    };
-
-    params.descendantCount = (value, { document, schema, propertyName }) => {
-      return 0;
-    };
-
-    if (option.initPageMetadatas) {
-      params.liker = [];
-      params.seenUsers = [];
-      params.commentCount = 0;
-      params.extended = {};
-    }
-
-    return params;
-  }
-
-}
-
-module.exports = (operatorUserId, option) => PageOverwriteParamsFactory.generate(operatorUserId, option);

+ 0 - 31
apps/app/src/server/routes/apiv3/overwrite-params/revisions.js

@@ -1,31 +0,0 @@
-const mongoose = require('mongoose');
-
-// eslint-disable-next-line no-unused-vars
-const ImportOptionForPages = require('~/models/admin/import-option-for-pages');
-
-const { ObjectId } = mongoose.Types;
-
-class RevisionOverwriteParamsFactory {
-
-  /**
-   * generate overwrite params object
-   * @param {string} operatorUserId
-   * @param {ImportOptionForPages} option
-   * @return object
-   *  key: property name
-   *  value: any value or a function `(value, { document, schema, propertyName }) => { return newValue }`
-   */
-  static generate(operatorUserId, option) {
-    const params = {};
-
-    if (option.isOverwriteAuthorWithCurrentUser) {
-      const userId = ObjectId(operatorUserId);
-      params.author = userId;
-    }
-
-    return params;
-  }
-
-}
-
-module.exports = (operatorUserId, option) => RevisionOverwriteParamsFactory.generate(operatorUserId, option);

+ 10 - 9
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,20 +1,20 @@
 import type {
-  IPageInfoForListing, IPageInfo,
+  IPageInfoForListing, IPageInfo, IPage,
 } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, Router } from 'express';
 import express from 'express';
 import { query, oneOf } from 'express-validator';
+import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
-
 import type { IPageGrantService } from '~/server/service/page-grant';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import type { PageModel } from '../../models/page';
+import type { PageDocument, PageModel } from '../../models/page';
 
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
@@ -65,7 +65,7 @@ const routerFactory = (crowi: Crowi): Router => {
 
 
   router.get('/root', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const Page: PageModel = crowi.model('Page');
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     let rootPage;
     try {
@@ -122,8 +122,9 @@ const routerFactory = (crowi: Crowi): Router => {
     const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
     const attachShortBody: boolean = attachShortBodyParam === 'true';
 
-    const Page = mongoose.model('Page') as unknown as PageModel;
-    const Bookmark = crowi.model('Bookmark');
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const Bookmark = mongoose.model<any, any>('Bookmark');
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const pageService = crowi.pageService;
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -171,11 +172,11 @@ const routerFactory = (crowi: Crowi): Router => {
           : {
             ...basicPageInfo,
             isAbleToDeleteCompletely: canDeleteCompletely,
-            bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
-            revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
+            bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id.toString()] : undefined,
+            revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id.toString()] : undefined,
           } as IPageInfoForListing;
 
-        idToPageInfoMap[page._id] = pageInfo;
+        idToPageInfoMap[page._id.toString()] = pageInfo;
       }
 
       return res.apiv3(idToPageInfoMap);

+ 2 - 3
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -16,11 +16,10 @@ import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import type { IOptionsForCreate } from '~/interfaces/page';
 import type Crowi from '~/server/crowi';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
-import {
-  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely,
-} from '~/server/models';
+import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import PageTagRelation from '~/server/models/page-tag-relation';
+import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
 import { configManager } from '~/server/service/config-manager';
 import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';

+ 3 - 2
apps/app/src/server/routes/apiv3/page/index.ts

@@ -8,6 +8,7 @@ import {
 import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
+import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import sanitize from 'sanitize-filename';
 
@@ -747,9 +748,9 @@ module.exports = (crowi) => {
     let revision;
     let pagePath;
 
-    const Page = mongoose.model<PageDocument, PageModel>('Page');
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
 
-    let page: PageDocument;
+    let page: HydratedDocument<PageDocument> | null;
 
     try {
       page = await Page.findByIdAndViewer(pageId, req.user);

+ 7 - 3
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -3,6 +3,7 @@ import type {
   IPage, IRevisionHasId, IUserHasId,
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
@@ -13,10 +14,9 @@ import { type IApiv3PageUpdateParams, PageUpdateErrorCode } from '~/interfaces/a
 import type { IOptionsForUpdate } from '~/interfaces/page';
 import type Crowi from '~/server/crowi';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
-import {
-  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely, serializeUserSecurely,
-} from '~/server/models';
+import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
+import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import { getYjsService } from '~/server/service/yjs';
@@ -132,6 +132,10 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
 
       // check revision
       const currentPage = await Page.findByIdAndViewer(pageId, req.user);
+      // check page existence (for type safety)
+      if (currentPage == null) {
+        return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
+      }
 
       if (currentPage != null) {
         // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'

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

@@ -1,6 +1,7 @@
 
 import { PageGrant } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { isCreatablePage, isTrashPage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
 import { normalizePath, addHeadingSlash } from '@growi/core/dist/utils/path-utils';
 import express from 'express';
@@ -8,7 +9,7 @@ import { body, query } from 'express-validator';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
-import { GlobalNotificationSettingEvent } from '~/server/models';
+import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
@@ -17,7 +18,6 @@ import { generateAddActivityMiddleware } from '../../../middlewares/add-activity
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
 import { serializePageSecurely } from '../../../models/serializers/page-serializer';
-import { serializeUserSecurely } from '../../../models/serializers/user-serializer';
 import { isV5ConversionError } from '../../../models/vo/v5-conversion-error';
 
 

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

@@ -1,4 +1,6 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import express from 'express';
 
 import { Revision } from '~/server/models/revision';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
@@ -8,11 +10,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:pages');
 
-const express = require('express');
 const { query, param } = require('express-validator');
 
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-
 const router = express.Router();
 
 /**

+ 2 - 3
apps/app/src/server/routes/apiv3/user-group-relation.js

@@ -1,15 +1,14 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import express from 'express';
 
+import { serializeUserGroupRelationSecurely } from '~/server/models/serializers';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
 
-const express = require('express');
 const { query } = require('express-validator');
 
-const { serializeUserGroupRelationSecurely } = require('../../models/serializers/user-group-relation-serializer');
-
 const router = express.Router();
 
 const validator = {};

Некоторые файлы не были показаны из-за большого количества измененных файлов