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

Merge branch 'master' into fix/import-service

Yuki Takei 1 год назад
Родитель
Сommit
7acec79ae7
100 измененных файлов с 1118 добавлено и 375 удалено
  1. 1 0
      .devcontainer/devcontainer.json
  2. 5 3
      .github/workflows/auto-labeling.yml
  3. 1 0
      .github/workflows/ci-app.yml
  4. 1 1
      .github/workflows/reusable-app-prod.yml
  5. 78 1
      CHANGELOG.md
  6. 1 1
      apps/app/docker/README.md
  7. 1 1
      apps/app/package.json
  8. 0 0
      apps/app/playwright/23-editor/assets/example.txt
  9. 50 0
      apps/app/playwright/23-editor/saving.spec.ts
  10. 113 0
      apps/app/playwright/23-editor/with-navigation.spec.ts
  11. 8 0
      apps/app/public/static/locales/en_US/translation.json
  12. 8 0
      apps/app/public/static/locales/fr_FR/translation.json
  13. 8 0
      apps/app/public/static/locales/ja_JP/translation.json
  14. 8 0
      apps/app/public/static/locales/zh_CN/translation.json
  15. 2 2
      apps/app/resource/locales/ja_JP/welcome.md
  16. 6 5
      apps/app/src/client/components/Admin/App/V5PageMigration.tsx
  17. 1 3
      apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx
  18. 2 15
      apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  19. 6 6
      apps/app/src/client/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  20. 20 0
      apps/app/src/client/components/Admin/UserGroup/UserGroupTable.module.scss
  21. 23 6
      apps/app/src/client/components/Admin/UserGroup/UserGroupTable.tsx
  22. 5 6
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  23. 3 3
      apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx
  24. 1 0
      apps/app/src/client/components/Bookmarks/BookmarkFolderTree.module.scss
  25. 0 1
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  26. 5 0
      apps/app/src/client/components/InAppNotification/PageNotification/ModelNotification.module.scss
  27. 4 2
      apps/app/src/client/components/InAppNotification/PageNotification/ModelNotification.tsx
  28. 1 1
      apps/app/src/client/components/InstallerForm.tsx
  29. 4 3
      apps/app/src/client/components/Me/ProfileImageSettings.tsx
  30. 41 1
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  31. 6 3
      apps/app/src/client/components/PageComment.tsx
  32. 9 5
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  33. 9 5
      apps/app/src/client/components/PageControls/PageControls.tsx
  34. 12 7
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  35. 1 1
      apps/app/src/client/components/PageList/PageListItemL.tsx
  36. 1 0
      apps/app/src/client/components/PageList/PageListItemS.module.scss
  37. 10 1
      apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  38. 1 1
      apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx
  39. 9 1
      apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx
  40. 4 11
      apps/app/src/client/components/SearchPage/SearchResultContent.tsx
  41. 1 1
      apps/app/src/client/components/Sidebar/Bookmarks.tsx
  42. 2 2
      apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx
  43. 2 2
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx
  44. 1 1
      apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx
  45. 4 0
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.module.scss
  46. 1 1
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChanges.tsx
  47. 4 0
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss
  48. 2 2
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  49. 1 1
      apps/app/src/client/components/Sidebar/Sidebar.module.scss
  50. 1 1
      apps/app/src/client/components/Sidebar/SidebarNav/SecondaryItems.tsx
  51. 2 2
      apps/app/src/client/components/Sidebar/Tag.tsx
  52. 1 1
      apps/app/src/client/components/TreeItem/TreeItemLayout.tsx
  53. 6 0
      apps/app/src/client/services/page-operation.ts
  54. 3 1
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  55. 3 1
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  56. 1 8
      apps/app/src/client/services/update-page/index.ts
  57. 7 0
      apps/app/src/client/services/update-page/update-page.ts
  58. 25 0
      apps/app/src/client/services/update-page/use-update-page.tsx
  59. 1 1
      apps/app/src/components/PageView/PageAlerts/PageGrantAlert.tsx
  60. 1 1
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  61. 5 0
      apps/app/src/interfaces/yjs.ts
  62. 2 2
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js
  63. 2 4
      apps/app/src/server/crowi/index.js
  64. 6 8
      apps/app/src/server/events/user.ts
  65. 2 2
      apps/app/src/server/interfaces/mongoose-utils.ts
  66. 2 2
      apps/app/src/server/models/attachment.ts
  67. 3 3
      apps/app/src/server/models/external-account.ts
  68. 6 6
      apps/app/src/server/models/named-query.ts
  69. 28 28
      apps/app/src/server/models/page.ts
  70. 1 3
      apps/app/src/server/models/password-reset-order.ts
  71. 17 6
      apps/app/src/server/models/revision.ts
  72. 1 2
      apps/app/src/server/models/share-link.ts
  73. 3 4
      apps/app/src/server/models/update-post.ts
  74. 4 5
      apps/app/src/server/models/user-group-relation.ts
  75. 1 3
      apps/app/src/server/models/user-group.ts
  76. 1 3
      apps/app/src/server/models/user.js
  77. 1 1
      apps/app/src/server/routes/apiv3/customize-setting.js
  78. 7 5
      apps/app/src/server/routes/apiv3/page-listing.ts
  79. 29 7
      apps/app/src/server/routes/apiv3/page/index.ts
  80. 64 0
      apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts
  81. 15 0
      apps/app/src/server/routes/apiv3/page/update-page.ts
  82. 9 0
      apps/app/src/server/routes/apiv3/revisions.js
  83. 2 2
      apps/app/src/server/routes/attachment/get.ts
  84. 1 1
      apps/app/src/server/service/config-manager.ts
  85. 28 23
      apps/app/src/server/service/file-uploader/aws.ts
  86. 25 20
      apps/app/src/server/service/file-uploader/azure.ts
  87. 4 0
      apps/app/src/server/service/file-uploader/file-uploader.ts
  88. 24 19
      apps/app/src/server/service/file-uploader/gcs.ts
  89. 30 24
      apps/app/src/server/service/file-uploader/gridfs.ts
  90. 9 1
      apps/app/src/server/service/file-uploader/local.ts
  91. 21 24
      apps/app/src/server/service/g2g-transfer.ts
  92. 2 1
      apps/app/src/server/service/in-app-notification.ts
  93. 36 0
      apps/app/src/server/service/normalize-data/convert-revision-page-id-to-objectid.ts
  94. 2 0
      apps/app/src/server/service/normalize-data/index.ts
  95. 3 2
      apps/app/src/server/service/page-grant.ts
  96. 3 2
      apps/app/src/server/service/page/delete-completely-user-home-by-system.ts
  97. 38 26
      apps/app/src/server/service/page/index.ts
  98. 4 4
      apps/app/src/server/service/page/page-service.ts
  99. 15 10
      apps/app/src/server/service/passport.ts
  100. 124 0
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts

+ 1 - 0
.devcontainer/devcontainer.json

@@ -19,6 +19,7 @@
     "eamodio.gitlens",
     "github.vscode-pull-request-github",
     "cschleiden.vscode-github-actions",
+    "cweijan.vscode-database-client2",
     "mongodb.mongodb-vscode",
     "msjsdiag.debugger-for-chrome",
     "firefox-devtools.vscode-firefox-debug",

+ 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*

+ 1 - 1
.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', '23', '30', '50']
+        spec-group: ['21', '30', '50']
 
     services:
       mongodb:

+ 78 - 1
CHANGELOG.md

@@ -1,9 +1,86 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.12...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.16...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.0.16](https://github.com/weseek/growi/compare/v7.0.15...v7.0.16) - 2024-07-31
+
+### 💎 Features
+
+* feat: Automatically repair corrupted data, at least for the latest revision (#9002) @yuki-takei
+
+### 🚀 Improvement
+
+* imprv: User group link in admin page (#8855) @kazutoweseek
+* imprv: Sidebar header text size (#8986) @satof3
+* imprv: Replace possition usericon (#8991) @satof3
+
+### 🐛 Bug Fixes
+
+* fix: Undo in the comment editor (#9005) @yuki-takei
+* fix: Some OIDC authentication settings not being applied (#9000) @WNomunomu
+* fix: font-family for monospace (#9004) @yuki-takei
+* fix: Pointer cursor for the create button in the installer (#9003) @yuki-takei
+* fix: Migration script (20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js) (#8998) @miya
+* fix: Non-admin user gets 500 error when opening history modal (#9001) @miya
+* fix: Enable page creation under GRANT_RESTRICTED pages (#8996) @arafubeatbox
+
+## [v7.0.15](https://github.com/weseek/growi/compare/v7.0.14...v7.0.15) - 2024-07-23
+
+### 🐛 Bug Fixes
+
+* fix: The $size query when aggregation to rebuild the index (#8987) @yuki-takei
+* fix: Regaining lost backward compatibility for MongoDB 4.4 (#8985) @yuki-takei
+* fix: Activate express-session middlewares for all sockets in SocketIoService (#8981) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Chage text size in sidebar (#8965) @satof3
+
+## [v7.0.14](https://github.com/weseek/growi/compare/v7.0.13...v7.0.14) - 2024-07-19
+
+### 🐛 Bug Fixes
+
+### 💎 Features
+
+* feat: Alerts when trying to sync with latest revision when yjs data is corrupt (#8971) @miya
+
+### 🚀 Improvement
+
+* imprv: Restrict use of the editing UI from View if there is at least one user currently editing (#8966) @miya
+
+### 🐛 Bug Fixes
+
+* fix: Handle error when folding drawio blocks (#8977) @yuki-takei
+* fix: Sync the editor text with the latest revision menu (1) (#8975) @yuki-takei
+* fix: Sync the editor text with the latest revision menu (2) (#8978) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Normalize Revision.pageId (for #8954) (#8973) @miya
+
+## [v7.0.13](https://github.com/weseek/growi/compare/v7.0.12...v7.0.13) - 2024-07-16
+
+### 💎 Features
+
+* feat: Sync latest revision body to Yjs draft (#8939) @miya
+
+### 🚀 Improvement
+
+* imprv: Better synchronizing between YDoc and the latest revision (#8959) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Revision model (#8967) @yuki-takei
+* fix: Healthcheck with checkServices=mongo (#8961) @yuki-takei
+* fix: Enable  # next to headline in view (#8826) @reiji-h
+
+### 🧰 Maintenance
+
+* ci(deps): bump nodemailer from 6.6.2 to 6.9.14 (#8928) @dependabot
+* support: Update favicon (#8957) @satof3
+
 ## [v7.0.12](https://github.com/weseek/growi/compare/v7.0.11...v7.0.12) - 2024-07-10
 
 ### 🚀 Improvement

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

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.0.12`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.12/apps/app/docker/Dockerfile)
+* [`7.0.16`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.16/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.13-RC.0",
+  "version": "7.0.17-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 0 - 0
apps/app/test/cypress/e2e/23-editor/assets/example.txt → apps/app/playwright/23-editor/assets/example.txt


+ 50 - 0
apps/app/playwright/23-editor/saving.spec.ts

@@ -0,0 +1,50 @@
+import path from 'path';
+
+import { test, expect, type Page } from '@playwright/test';
+
+const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+  await page.locator('.cm-content').fill(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+};
+
+
+test('Successfully create page under specific path', async({ page }) => {
+  const newPagePath = '/child';
+  const openPageCreateModalShortcutKey = 'c';
+
+  await page.goto('/Sandbox');
+
+  await page.keyboard.press(openPageCreateModalShortcutKey);
+  await expect(page.getByTestId('page-create-modal')).toBeVisible();
+  page.getByTestId('page-create-modal').locator('.rbt-input-main').fill(newPagePath);
+  page.getByTestId('btn-create-page-under-below').click();
+  await page.getByTestId('view-button').click();
+
+  const createdPageId = path.basename(page.url());
+  expect(createdPageId.length).toBe(24);
+});
+
+
+test('Successfully updating a page using a shortcut on a previously created page', async({ page }) => {
+  const body1 = 'hello';
+  const body2 = ' world!';
+  const savePageShortcutKey = 'Control+s';
+
+  await page.goto('/Sandbox/child');
+
+  // 1st
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+  await appendTextToEditorUntilContains(page, body1);
+  await page.keyboard.press(savePageShortcutKey);
+  await page.getByTestId('view-button').click();
+  await expect(page.locator('.main')).toContainText(body1);
+
+  // 2nd
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+  await appendTextToEditorUntilContains(page, body1 + body2);
+  await page.keyboard.press(savePageShortcutKey);
+  await page.getByTestId('view-button').click();
+  await expect(page.locator('.main')).toContainText(body1 + body2);
+});

+ 113 - 0
apps/app/playwright/23-editor/with-navigation.spec.ts

@@ -0,0 +1,113 @@
+import { readFileSync } from 'fs';
+import path from 'path';
+
+import { test, expect, type Page } from '@playwright/test';
+
+/**
+ * for the issues:
+ * @see https://redmine.weseek.co.jp/issues/122040
+ * @see https://redmine.weseek.co.jp/issues/124281
+ */
+test('should not be cleared and should prevent GrantSelector from modified', async({ page }) => {
+  await page.goto('/Sandbox/for-122040');
+
+  // Open Editor
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // Open GrantSelector and select "only me"
+  await page.getByTestId('grw-grant-selector').click();
+  const dropdownMenu = page.getByTestId('grw-grant-selector-dropdown-menu');
+  await expect(dropdownMenu).toBeVisible();
+  await dropdownMenu.locator('.dropdown-item').nth(2).click();
+  await expect(page.getByTestId('grw-grant-selector')).toContainText('Only me');
+
+  // Upload attachment
+  const filePath = path.resolve(__dirname, '../23-editor/assets/example.txt');
+  const buffer = readFileSync(filePath).toString('base64');
+  const dataTransfer = await page.evaluateHandle(
+    async({ bufferData, localFileName, localFileType }) => {
+      const dt = new DataTransfer();
+
+      const blobData = await fetch(bufferData).then(res => res.blob());
+
+      const file = new File([blobData], localFileName, {
+        type: localFileType,
+      });
+      dt.items.add(file);
+      return dt;
+    },
+    {
+      bufferData: `data:application/octet-stream;base64,${buffer}`,
+      localFileName: 'sample.tst',
+      localFileType: 'application/octet-stream',
+    },
+  );
+  await page.locator('.dropzone').first().dispatchEvent('drop', { dataTransfer });
+  await expect(page.getByTestId('page-editor-preview-body').getByTestId('rich-attachment')).toBeVisible();
+
+  // Save page
+  await page.getByTestId('save-page-btn').click();
+
+  // Expect grant not to be reset after uploading an attachment
+  await expect(page.getByTestId('page-grant-alert')).toContainText('Browsing of this page is restricted');
+});
+
+const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+  await page.locator('.cm-content').fill(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+};
+
+/**
+ * for the issue:
+ * @see https://redmine.weseek.co.jp/issues/115285
+ */
+test('Successfully updating the page body', async({ page }) => {
+  const page1Path = '/Sandbox/for-115285/page1';
+  const page2Path = '/Sandbox/for-115285/page2';
+
+  const page1Body = 'Hello';
+  const page2Body = 'World';
+
+
+  await page.goto(page1Path);
+
+  // Open Editor (page1)
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // Append text
+  await appendTextToEditorUntilContains(page, page1Body);
+
+  // Save page
+  await page.getByTestId('save-page-btn').click();
+
+  await expect(page.locator('.main')).toContainText(page1Body);
+
+  // Duplicate page1
+  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+  await page.getByTestId('open-page-duplicate-modal-btn').click();
+  await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
+  await page.locator('.form-control').fill(page2Path);
+  await page.getByTestId('btn-duplicate').click();
+
+  // Open Editor (page2)
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // Expect to see the text from which you are duplicating
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
+
+  // Append text
+  await appendTextToEditorUntilContains(page, page1Body + page2Body);
+
+
+  await page.goto(page1Path);
+
+  // Open Editor (page1)
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
+
+});

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

@@ -849,5 +849,13 @@
   },
   "create_page": {
     "untitled": "Untitled"
+  },
+  "sync-latest-revision-body": {
+    "menuitem": "Sync the editor text with the latest revision body",
+    "confirm": "Delete the draft data being entered into the editor and synchronize the latest text. Are you sure you want to run it?",
+    "alert": "The latest text may not have been synchronized. Please reload and check again.",
+    "success-toaster": "Latest text synchronized",
+    "skipped-toaster": "Skipped synchronizing since the editor is not activated. Please open the editor and try again.",
+    "error-toaster": "Synchronization of the latest text failed"
   }
 }

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

@@ -840,5 +840,13 @@
     "show_wip_page": "Voir brouillon",
     "size_s": "Taille: P",
     "size_l": "Taille: G"
+  },
+  "sync-latest-revision-body": {
+    "menuitem": "Synchroniser le texte de l'éditeur avec le corps de la dernière révision",
+    "confirm": "Delete the draft data being entered into the editor and synchronize the latest text. Are you sure you want to run it?",
+    "alert": "Il se peut que le texte le plus récent n'ait pas été synchronisé. Veuillez recharger et vérifier à nouveau.",
+    "success-toaster": "Dernier texte synchronisé",
+    "skipped-toaster": "Synchronisation ignorée car l'éditeur n'est pas activé. Ouvrir l'éditeur et réessayer.",
+    "error-toaster": "La synchronisation du dernier texte a échoué"
   }
 }

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

@@ -882,5 +882,13 @@
   },
   "create_page": {
     "untitled": "無題のページ"
+  },
+  "sync-latest-revision-body": {
+    "menuitem": "最新のリビジョンの本文とエディタのテキストを同期",
+    "confirm": "エディターに入力中のドラフトデータを削除して最新の本文を同期します。実行しますか?",
+    "alert": "最新の本文が同期されていない可能性があります。リロードして再度ご確認ください。",
+    "success-toaster": "最新の本文を同期しました",
+    "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
+    "error-toaster": "最新の本文の同期に失敗しました"
   }
 }

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

@@ -852,5 +852,13 @@
   },
   "create_page": {
     "untitled": "Untitled"
+  },
+  "sync-latest-revision-body": {
+    "menuitem": "同步编辑器文本与最新修订正文",
+    "confirm": "删除输入编辑器的草稿数据,同步最新文本。 您真的想运行它吗?",
+    "alert": "最新文本可能尚未同步。 请重新加载并再次检查。",
+    "success-toaster": "同步最新文本",
+    "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
+    "error-toaster": "同步最新文本失败"
   }
 }

+ 2 - 2
apps/app/resource/locales/ja_JP/welcome.md

@@ -43,8 +43,8 @@ GROWI は法人・個人向けの wiki | ナレッジベースツールです。
     - [GROWI に新しいメンバーを追加・招待する](https://docs.growi.org/ja/admin-guide/management-cookbook/user-management.html#%E6%96%B0%E8%A6%8F%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E4%BB%AE%E7%99%BA%E8%A1%8C)
 
 ### :arrow_right: GROWI の見た目はこのままで満足ですか?
-- :heavy_check_mark: GROWI の見た目をカスタマイズしましょう!
-    - [GROWI のテーマをカスタマイズする](/admin/customize)
+- :heavy_check_mark: GROWI の見た目をカスタマイズしましょう!
+    - [GROWI のテーマをカスタマイズする](/admin/customize)
 
 ### :arrow_right: GROWI のセキュリティ設定は完了していますか?
 - :heavy_check_mark: GROWI のセキュリティ設定を更新しましょう!

+ 6 - 5
apps/app/src/client/components/Admin/App/V5PageMigration.tsx

@@ -1,12 +1,14 @@
-import React, {
-  FC, useCallback, useEffect, useState,
-} from 'react';
+import type { FC } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import type {
+  PMStartedData, PMMigratingData, PMErrorCountData, PMEndedData,
+} from '~/interfaces/websocket';
 import {
-  SocketEventName, PMStartedData, PMMigratingData, PMErrorCountData, PMEndedData,
+  SocketEventName,
 } from '~/interfaces/websocket';
 import { useGlobalAdminSocket } from '~/stores/websocket';
 
@@ -64,7 +66,6 @@ const V5PageMigration: FC<Props> = (props: Props) => {
         <LabeledProgressBar
           header={t('admin:v5_page_migration.header_upgrading_progress')}
           currentCount={current}
-          errorsCount={skip}
           totalCount={total}
           isInProgress={isInProgress}
         />

+ 1 - 3
apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx

@@ -6,13 +6,12 @@ type Props = {
   header: string,
   currentCount: number,
   totalCount: number,
-  errorsCount?: number,
   isInProgress?: boolean,
 }
 
 const LabeledProgressBar = (props: Props): JSX.Element => {
   const {
-    header, currentCount, totalCount, errorsCount, isInProgress,
+    header, currentCount, totalCount, isInProgress,
   } = props;
 
   const progressingColor = isInProgress ? 'info' : 'success';
@@ -25,7 +24,6 @@ const LabeledProgressBar = (props: Props): JSX.Element => {
       </h6>
       <Progress multi>
         <Progress bar max={totalCount} color={progressingColor} striped={isInProgress} animated={isInProgress} value={currentCount} />
-        <Progress bar max={totalCount} color="danger" striped={isInProgress} animated={isInProgress} value={errorsCount} />
       </Progress>
     </>
   );

+ 2 - 15
apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -16,7 +16,6 @@ class RebuildIndexControls extends React.Component {
     this.state = {
       total: 0,
       current: 0,
-      skip: 0,
     };
   }
 
@@ -32,7 +31,6 @@ class RebuildIndexControls extends React.Component {
         this.setState({
           total: data.totalCount,
           current: data.count,
-          skip: data.skipped,
         });
       });
 
@@ -40,7 +38,6 @@ class RebuildIndexControls extends React.Component {
         this.setState({
           total: data.totalCount,
           current: data.count,
-          skip: data.skipped,
         });
       });
     }
@@ -51,7 +48,7 @@ class RebuildIndexControls extends React.Component {
       isRebuildingProcessing, isRebuildingCompleted,
     } = this.props;
     const {
-      total, current, skip,
+      total, current,
     } = this.state;
     const showProgressBar = isRebuildingProcessing || isRebuildingCompleted;
 
@@ -59,23 +56,13 @@ class RebuildIndexControls extends React.Component {
       return null;
     }
 
-    function getCompletedLabel() {
-      const completedLabel = skip === 0 ? 'Completed' : `Done (${skip} skips)`;
-      return completedLabel;
-    }
-
-    function getSkipLabel() {
-      return `Processing.. (${skip} skips)`;
-    }
-
-    const header = isRebuildingCompleted ? getCompletedLabel() : getSkipLabel();
+    const header = isRebuildingCompleted ? 'Completed' : 'Processing..';
 
     return (
       <div className="mb-3">
         <LabeledProgressBar
           header={header}
           currentCount={current}
-          errorsCount={skip}
           totalCount={total}
         />
       </div>

+ 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>

+ 20 - 0
apps/app/src/client/components/Admin/UserGroup/UserGroupTable.module.scss

@@ -0,0 +1,20 @@
+.user-group-edit-link {
+  text-decoration: underline;
+}
+
+// switch visibility of the edit icon
+.user-group-edit-link {
+  :global {
+    .grw-edit-icon {
+      visibility: hidden;
+    }
+  }
+
+  &:global {
+    &:hover {
+      .grw-edit-icon {
+        visibility: visible;
+      }
+    }
+  }
+}

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

@@ -9,6 +9,11 @@ import Link from 'next/link';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 
 
+import styles from './UserGroupTable.module.scss';
+
+const userGroupEditLinkStyle = styles['user-group-edit-link'] ?? '';
+
+
 type Props = {
   headerLabel?: string,
   userGroups: IUserGroupHasId[],
@@ -54,6 +59,23 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
   return map;
 };
 
+type UserGroupEditLinkProps = {
+  group:IUserGroupHasId,
+  isExternalGroup:boolean,
+}
+
+const UserGroupEditLink = (props: UserGroupEditLinkProps): JSX.Element => {
+  return (
+    <Link
+      href={`/admin/user-group-detail/${props.group._id}?isExternalGroup=${props.isExternalGroup}`}
+      className={`${userGroupEditLinkStyle} link-secondary link-offset-2 link-underline-opacity-25 link-underline-opacity-100-hover`}
+    >
+      <span className="material-symbols-outlined pe-2 pt-2">group</span>
+      <span>{props.group.name}</span>
+      <span className="grw-edit-icon material-symbols-outlined px-2 py-0">edit</span>
+    </Link>
+  );
+};
 
 export const UserGroupTable: FC<Props> = ({
   headerLabel,
@@ -163,12 +185,7 @@ export const UserGroupTable: FC<Props> = ({
                 {isAclEnabled
                   ? (
                     <td>
-                      <Link
-                        className="link-opacity-75-hover"
-                        href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}
-                      >
-                        {group.name}
-                      </Link>
+                      <UserGroupEditLink group={group} isExternalGroup={isExternalGroup} />
                     </td>
                   )
                   : (

+ 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, {

+ 3 - 3
apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx

@@ -4,11 +4,11 @@ import { useCallback, useState } from 'react';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { DropdownToggle } from 'reactstrap';
 
+import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import {
   addBookmarkToFolder, addNewFolder, hasChildren, updateBookmarkFolder,
 } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
-import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
@@ -250,7 +250,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               onClick={loadChildFolder}
             >
               <div className="d-flex justify-content-center">
-                <span className="material-symbols-outlined">arrow_right</span>
+                <span className="material-symbols-outlined fs-5">arrow_right</span>
               </div>
             </button>
           </div>
@@ -268,7 +268,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
           ) : (
             <>
               <div className="grw-foldertree-title-anchor ps-1">
-                <p className="text-truncate m-auto ">{name}</p>
+                <p className="text-truncate m-auto">{name}</p>
               </div>
             </>
           )}

+ 1 - 0
apps/app/src/client/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -51,6 +51,7 @@ $grw-bookmark-item-padding-left: 35px;
     .grw-foldertree-title-anchor {
       width: 100%;
       overflow: hidden;
+      font-size: 14px;
       text-decoration: none;
     }
   }

+ 0 - 1
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -108,7 +108,6 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickRevertMenuItem(pageId);
   }, [onClickRevertMenuItem, pageId]);
 
-
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
     if (pageInfo == null || onClickDeleteMenuItem == null) {

+ 5 - 0
apps/app/src/client/components/InAppNotification/PageNotification/ModelNotification.module.scss

@@ -0,0 +1,5 @@
+.modal-notification :global {
+  .page-title {
+    font-size: 14px;
+  }
+}

+ 4 - 2
apps/app/src/client/components/InAppNotification/PageNotification/ModelNotification.tsx

@@ -8,6 +8,8 @@ import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 import FormattedDistanceDate from '../../FormattedDistanceDate';
 
+import styles from './ModelNotification.module.scss';
+
 type Props = {
   notification: IInAppNotification & HasObjectId
   actionMsg: string
@@ -21,8 +23,8 @@ export const ModelNotification: FC<Props> = (props) => {
   } = props;
 
   return (
-    <div className="p-2 overflow-hidden">
-      <div className="text-truncate">
+    <div className={`${styles['modal-notification']} p-2 overflow-hidden`}>
+      <div className="text-truncate page-title">
         <b>{actionUsers}</b>
         {actionMsg}
         <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />

+ 1 - 1
apps/app/src/client/components/InstallerForm.tsx

@@ -251,7 +251,7 @@ const InstallerForm = memo((props: Props): JSX.Element => {
                   <span className="material-symbols-outlined">person_add</span>
                 )}
               </span>
-              <label className="flex-grow-1">{ t('Create') }</label>
+              <span className="flex-grow-1">{ t('Create') }</span>
             </button>
           </div>
 

+ 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);

+ 41 - 1
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -7,6 +7,8 @@ import type {
   IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
 } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
@@ -14,7 +16,8 @@ import { useRouter } from 'next/router';
 import Sticky from 'react-stickynode';
 import { DropdownItem } from 'reactstrap';
 
-import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
+import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
+import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
@@ -76,8 +79,45 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
 
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+
+  const syncLatestRevisionBodyHandler = useCallback(async() => {
+    // eslint-disable-next-line no-alert
+    const answer = window.confirm(t('sync-latest-revision-body.confirm'));
+    if (answer) {
+      try {
+        const editingMarkdownLength = codeMirrorEditor?.getDoc().length;
+        const res = await syncLatestRevisionBody(pageId, editingMarkdownLength);
+
+        if (!res.synced) {
+          toastWarning(t('sync-latest-revision-body.skipped-toaster'));
+          return;
+        }
+
+        if (res?.isYjsDataBroken) {
+          // eslint-disable-next-line no-alert
+          window.alert(t('sync-latest-revision-body.alert'));
+          return;
+        }
+
+        toastSuccess(t('sync-latest-revision-body.success-toaster'));
+      }
+      catch {
+        toastError(t('sync-latest-revision-body.error-toaster'));
+      }
+    }
+  }, [codeMirrorEditor, pageId, t]);
+
   return (
     <>
+      <DropdownItem
+        onClick={() => syncLatestRevisionBodyHandler()}
+        className="grw-page-control-dropdown-item"
+      >
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">sync</span>
+        {t('sync-latest-revision-body.menuitem')}
+      </DropdownItem>
+
       {/* Presentation */}
       <DropdownItem
         onClick={() => openPresentationModal()}

+ 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 - 5
apps/app/src/client/components/PageComment/CommentEditor.tsx

@@ -9,6 +9,7 @@ import { CodeMirrorEditorComment } from '@growi/editor/dist/client/components/Co
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useResolvedThemeForEditor } from '@growi/editor/dist/client/stores/use-resolved-theme';
 import { UserPicture } from '@growi/ui/dist/components';
+import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import {
@@ -208,10 +209,13 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     });
   }, [codeMirrorEditor, pageId]);
 
-  const onChangeHandler = useCallback(async(value: string) => {
-    const dirtyNum = await evaluateEditorDirtyMap(editorKey, value);
-    mutateIsEnabledUnsavedWarning(dirtyNum > 0);
-  }, [editorKey, evaluateEditorDirtyMap, mutateIsEnabledUnsavedWarning]);
+  const cmProps = useMemo<ReactCodeMirrorProps>(() => ({
+    onChange: async(value: string) => {
+      const dirtyNum = await evaluateEditorDirtyMap(editorKey, value);
+      mutateIsEnabledUnsavedWarning(dirtyNum > 0);
+    },
+  }), [editorKey, evaluateEditorDirtyMap, mutateIsEnabledUnsavedWarning]);
+
 
   // initialize CodeMirrorEditor
   useEffect(() => {
@@ -260,10 +264,10 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
             <CodeMirrorEditorComment
               editorKey={editorKey}
               acceptedUploadFileType={acceptedUploadFileType}
-              onChange={onChangeHandler}
               onSave={postCommentHandler}
               onUpload={uploadHandler}
               editorSettings={editorSettings}
+              cmProps={cmProps}
             />
           </TabPane>
           <TabPane tabId="comment_preview">

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

@@ -1,4 +1,3 @@
-import type { MouseEventHandler } from 'react';
 import React, {
   memo, useCallback, useEffect, useMemo, useRef,
 } from 'react';
@@ -226,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,
       });
@@ -251,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 <></>;

+ 12 - 7
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -3,7 +3,6 @@ import React, {
   useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
 } from 'react';
 
-
 import type EventEmitter from 'events';
 import nodePath from 'path';
 
@@ -14,12 +13,13 @@ import { CodeMirrorEditorMain } from '@growi/editor/dist/client/components/CodeM
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useResolvedThemeForEditor } from '@growi/editor/dist/client/stores/use-resolved-theme';
 import { useRect } from '@growi/ui/dist/utils';
+import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import detectIndent from 'detect-indent';
 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();
@@ -159,10 +160,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     setMarkdownToPreview(value);
   })), []);
 
-  const markdownChangedHandler = useCallback((value: string) => {
-    setMarkdownPreviewWithDebounce(value);
-  }, [setMarkdownPreviewWithDebounce]);
-
 
   const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(GlobalCodeMirrorEditorKey.MAIN, previewRef);
 
@@ -267,6 +264,14 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     });
   }, [codeMirrorEditor, pageId]);
 
+
+  const cmProps = useMemo<ReactCodeMirrorProps>(() => ({
+    onChange: (value: string) => {
+      setMarkdownPreviewWithDebounce(value);
+    },
+  }), [setMarkdownPreviewWithDebounce]);
+
+
   // set handler to save and return to View
   useEffect(() => {
     globalEmitter.on('saveAndReturnToView', saveAndReturnToViewHandler);
@@ -363,7 +368,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
         <div className="page-editor-editor-container flex-expand-vert border-end">
           <CodeMirrorEditorMain
             isEditorMode={editorMode === EditorMode.Editor}
-            onChange={markdownChangedHandler}
             onSave={saveWithShortcut}
             onUpload={uploadHandler}
             acceptedUploadFileType={acceptedUploadFileType}
@@ -374,6 +378,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             initialValue={initialValue}
             editorSettings={editorSettings}
             onEditorsUpdated={onEditorsUpdated}
+            cmProps={cmProps}
           />
         </div>
         <div

+ 1 - 1
apps/app/src/client/components/PageList/PageListItemL.tsx

@@ -31,10 +31,10 @@ import {
 } from '~/stores/modal';
 import { useIsDeviceLargerThanLg } from '~/stores/ui';
 
+import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';
 import { useSWRMUTxPageInfo, useSWRxPageInfo } from '../../../stores/page';
 import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageItemControl } from '../Common/Dropdown/PageItemControl';
-import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';
 
 type Props = {
   page: IPageWithSearchMeta | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,

+ 1 - 0
apps/app/src/client/components/PageList/PageListItemS.module.scss

@@ -1,4 +1,5 @@
 .page-title {
   flex: 1;
+  font-size: 14px;
   line-height: 1.2;
 }

+ 10 - 1
apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx

@@ -13,6 +13,7 @@ import {
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
 import { useIsRevisionOutdated } from '~/stores/page';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 
 import '@growi/remark-drawio/dist/style.css';
 import styles from './DrawioViewerWithEditButton.module.scss';
@@ -34,6 +35,7 @@ export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps):
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
   const { data: isRevisionOutdated } = useIsRevisionOutdated();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
   const [isRendered, setRendered] = useState(false);
   const [mxfile, setMxfile] = useState('');
@@ -57,7 +59,14 @@ export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps):
     }
   }, []);
 
-  const showEditButton = !isRevisionOutdated && isRendered && !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
+  const isNoEditingUsers = currentPageYjsData?.awarenessStateSize == null || currentPageYjsData?.awarenessStateSize === 0;
+  const showEditButton = isNoEditingUsers
+     && !isRevisionOutdated
+     && isRendered
+     && !isGuestUser
+     && !isReadOnlyUser
+     && !isSharedUser
+     && shareLinkId == null;
 
   return (
     <div className={`drawio-viewer-with-edit-button ${styles['drawio-viewer-with-edit-button']}`}>

+ 1 - 1
apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx

@@ -53,7 +53,7 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   }
 
   return (
-    <div className={`${styles.attachment} d-inline-block`}>
+    <div data-testid="rich-attachment" className={`${styles.attachment} d-inline-block`}>
       <div className="my-2 p-2 card">
         <div className="p-1 card-body d-flex align-items-center">
           <div className="me-2 px-0 d-flex align-items-center justify-content-center">

+ 9 - 1
apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx

@@ -8,6 +8,7 @@ import {
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
 import { useIsRevisionOutdated } from '~/stores/page';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 
 import styles from './TableWithEditButton.module.scss';
 
@@ -31,6 +32,7 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
   const { data: isRevisionOutdated } = useIsRevisionOutdated();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
   const bol = node.position?.start.line;
   const eol = node.position?.end.line;
@@ -39,7 +41,13 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
     globalEmitter.emit('launchHandsonTableModal', bol, eol);
   }, [bol, eol]);
 
-  const showEditButton = !isRevisionOutdated && !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
+  const isNoEditingUsers = currentPageYjsData?.awarenessStateSize == null || currentPageYjsData?.awarenessStateSize === 0;
+  const showEditButton = isNoEditingUsers
+    && !isRevisionOutdated
+    && !isGuestUser
+    && !isReadOnlyUser
+    && !isSharedUser
+    && shareLinkId == null;
 
   return (
     <div className={`editable-with-handsontable ${styles['editable-with-handsontable']}`}>

+ 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 : '';
 

+ 1 - 1
apps/app/src/client/components/Sidebar/Bookmarks.tsx

@@ -14,7 +14,7 @@ export const Bookmarks = () : JSX.Element => {
   return (
     <div className="px-3">
       <div className="grw-sidebar-content-header">
-        <h4 className="mb-0 py-4">{t('Bookmarks')}</h4>
+        <h3 className="fs-6 fw-bold mb-0 py-4">{t('Bookmarks')}</h3>
       </div>
       {isGuestUser ? (
         <h4 className="fs-6">

+ 2 - 2
apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx

@@ -20,10 +20,10 @@ export const CustomSidebar = (): JSX.Element => {
   return (
     <div className="pt-4 pb-3 px-3">
       <div className="grw-sidebar-content-header d-flex">
-        <h4 className="mb-0">
+        <h3 className="fs-6 fw-bold mb-0">
           {t('CustomSidebar')}
           { !isLoading && <Link href="/Sidebar#edit" className="h6 ms-2"><span className="material-symbols-outlined">edit</span></Link> }
-        </h4>
+        </h3>
         { !isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} /> }
       </div>
 

+ 2 - 2
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx

@@ -17,9 +17,9 @@ export const InAppNotification = (): JSX.Element => {
   return (
     <div className="px-3">
       <div className="grw-sidebar-content-header py-4 d-flex">
-        <h4 className="mb-0">
+        <h3 className="fs-6 fw-bold mb-0">
           {t('In-App Notification')}
-        </h4>
+        </h3>
       </div>
 
       <InAppNotificationForms

+ 1 - 1
apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx

@@ -23,7 +23,7 @@ export const PageTree = (): JSX.Element => {
   return (
     <div className="pt-4 pb-3 px-3">
       <div className="grw-sidebar-content-header d-flex">
-        <h4 className="mb-0">{t('Page Tree')}</h4>
+        <h3 className="fs-6 fw-bold mb-0">{t('Page Tree')}</h3>
         <Suspense>
           <PageTreeHeader
             isWipPageShown={isWipPageShown}

+ 4 - 0
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.module.scss

@@ -8,6 +8,10 @@
   }
 }
 
+// font size
+.page-tree-item :global {
+    font-size: 14px;
+}
 
 // == Colors
 

+ 1 - 1
apps/app/src/client/components/Sidebar/RecentChanges/RecentChanges.tsx

@@ -21,7 +21,7 @@ export const RecentChanges = (): JSX.Element => {
   return (
     <div className="px-3" data-testid="grw-recent-changes">
       <div className="grw-sidebar-content-header py-4 d-flex">
-        <h4 className="mb-0 text-nowrap">{t('Recent Changes')}</h4>
+        <h3 className="fs-6 fw-bold mb-0 text-nowrap">{t('Recent Changes')}</h3>
         <Suspense>
           <RecentChangesHeader
             isSmall={isSmall}

+ 4 - 0
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss

@@ -8,6 +8,10 @@
 .list-group-item :global {
   font-size: 12px;
 
+  h6 {
+    font-size: 14px;
+  }
+
   .grw-recent-changes-skeleton-small {
     @include grw-skeleton-text($font-size: 14px, $line-height: 16px);
 

+ 2 - 2
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -9,10 +9,10 @@ import { DevidedPagePath } from '@growi/core/dist/models';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'react-i18next';
 
-import { useKeywordManager } from '~/client/services/search-operation';
-import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
 import FormattedDistanceDate from '~/client/components/FormattedDistanceDate';
 import InfiniteScroll from '~/client/components/InfiniteScroll';
+import { useKeywordManager } from '~/client/services/search-operation';
+import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
 import LinkedPagePath from '~/models/linked-page-path';
 import { useSWRINFxRecentlyUpdated } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';

+ 1 - 1
apps/app/src/client/components/Sidebar/Sidebar.module.scss

@@ -11,7 +11,7 @@
 .grw-sidebar :global {
   .grw-sidebar-content-header {
     .grw-btn-reload {
-      font-size: 18px;
+      font-size: 16px;
     }
   }
 }

+ 1 - 1
apps/app/src/client/components/Sidebar/SidebarNav/SecondaryItems.tsx

@@ -47,10 +47,10 @@ export const SecondaryItems: FC = memo(() => {
 
   return (
     <div className={styles['grw-secondary-items']}>
-      {!isGuestUser && <PersonalDropdown />}
       <SecondaryItem label="Help" iconName="help" href={growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org'} isBlank />
       {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
       <SecondaryItem label="Trash" href="/trash" iconName="delete" />
+      {!isGuestUser && <PersonalDropdown />}
     </div>
   );
 });

+ 2 - 2
apps/app/src/client/components/Sidebar/Tag.tsx

@@ -44,8 +44,8 @@ const Tag: FC = () => {
   // todo: adjust design by XD
   return (
     <div className="container-lg px-3 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
-      <div className="grw-sidebar-content-header py-3 d-flex">
-        <h4 className="mb-0">{t('Tags')}</h4>
+      <div className="grw-sidebar-content-header pt-4 pb-3 d-flex">
+        <h3 className="fs-6 fw-bold mb-0">{t('Tags')}</h3>
         <SidebarHeaderReloadButton onClick={() => onReload()} />
       </div>
 

+ 1 - 1
apps/app/src/client/components/TreeItem/TreeItemLayout.tsx

@@ -152,7 +152,7 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
               onClick={onClickLoadChildren}
             >
               <div className="d-flex justify-content-center">
-                <span className="material-symbols-outlined">arrow_right</span>
+                <span className="material-symbols-outlined fs-5">arrow_right</span>
               </div>
             </button>
           )}

+ 6 - 0
apps/app/src/client/services/page-operation.ts

@@ -4,6 +4,7 @@ import type { IPageHasId } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 
+import type { SyncLatestRevisionBody } from '~/interfaces/yjs';
 import { useEditingMarkdown, usePageTagsForEditors } from '~/stores/editor';
 import {
   useCurrentPageId, useSWRMUTxCurrentPage, useSWRxApplicableGrant, useSWRxTagsInfo,
@@ -174,3 +175,8 @@ export const unpublish = async(pageId: string): Promise<IPageHasId> => {
   const res = await apiv3Put(`/page/${pageId}/unpublish`);
   return res.data;
 };
+
+export const syncLatestRevisionBody = async(pageId: string, editingMarkdownLength?: number): Promise<SyncLatestRevisionBody> => {
+  const res = await apiv3Put(`/page/${pageId}/sync-latest-revision-body-to-yjs-draft`, { editingMarkdownLength });
+  return res.data;
+};

+ 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 - 1
apps/app/src/components/PageView/PageAlerts/PageGrantAlert.tsx

@@ -55,7 +55,7 @@ export const PageGrantAlert = (): JSX.Element => {
 
 
   return (
-    <p className="alert alert-primary py-3 px-4">
+    <p data-testid="page-grant-alert" className="alert alert-primary py-3 px-4">
       {renderAlertContent()}
     </p>
   );

+ 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 - 0
apps/app/src/interfaces/yjs.ts

@@ -2,3 +2,8 @@ export type CurrentPageYjsData = {
   hasYdocsNewerThanLatestRevision?: boolean,
   awarenessStateSize?: number,
 }
+
+export type SyncLatestRevisionBody = {
+  synced: boolean,
+  isYjsDataBroken?: boolean,
+}

+ 2 - 2
apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js

@@ -47,7 +47,7 @@ module.exports = {
           };
         });
 
-        await Revision.bulkWrite(updateManyOperations);
+        await Revision.bulkWrite(updateManyOperations, { strict: false });
 
         callback();
       },
@@ -98,7 +98,7 @@ module.exports = {
           };
         });
 
-        await Revision.bulkWrite(updateManyOperations);
+        await Revision.bulkWrite(updateManyOperations, { strict: false });
 
         callback();
       },

+ 2 - 4
apps/app/src/server/crowi/index.js

@@ -34,6 +34,7 @@ import PageOperationService from '../service/page-operation';
 import PassportService from '../service/passport';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
+import { SocketIoService } from '../service/socket-io';
 import UserGroupService from '../service/user-group';
 import { UserNotificationService } from '../service/user-notification';
 import { initializeYjsService } from '../service/yjs';
@@ -302,10 +303,7 @@ Crowi.prototype.setupS2sMessagingService = async function() {
 };
 
 Crowi.prototype.setupSocketIoService = async function() {
-  const SocketIoService = require('../service/socket-io');
-  if (this.socketIoService == null) {
-    this.socketIoService = new SocketIoService(this);
-  }
+  this.socketIoService = new SocketIoService(this);
 };
 
 Crowi.prototype.setupCron = function() {

+ 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 - 2
apps/app/src/server/models/attachment.ts

@@ -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 - 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 },
 });

+ 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 },
 }, {

+ 17 - 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;
@@ -34,8 +36,10 @@ export interface IRevisionModel extends Model<IRevisionDocument> {
 Schema.Types.String.checkRequired(v => typeof v === 'string');
 
 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: Types.ObjectId, ref: 'Page', required: true, index: true,
+    type: Schema.Types.ObjectId, ref: 'Page', required: true, index: true,
   },
   body: {
     type: String,
@@ -47,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 },
 }, {
@@ -56,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 - 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,

+ 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,

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

@@ -16,8 +16,6 @@ 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 factory = (crowi) => {
@@ -43,7 +41,7 @@ const factory = (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 },

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

@@ -272,7 +272,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.get('/theme', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/theme', loginRequiredStrictly, async(req, res) => {
 
     try {
       const currentTheme = await crowi.configManager.getConfig('crowi', 'customize:theme');

+ 7 - 5
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -6,6 +6,7 @@ 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';
@@ -13,7 +14,7 @@ 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';
 
@@ -121,9 +122,10 @@ const routerFactory = (crowi: Crowi): Router => {
     const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
     const attachShortBody: boolean = attachShortBodyParam === 'true';
 
-    const Page = mongoose.model<IPage, PageModel>('Page');
+    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
     const pageGrantService: IPageGrantService = crowi.pageGrantService!;
@@ -170,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);

+ 29 - 7
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';
 
@@ -17,12 +18,13 @@ import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
-import type { PageModel } from '~/server/models/page';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
 import Subscription from '~/server/models/subscription';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
 import { preNotifyService } from '~/server/service/pre-notify';
+import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import loggerFactory from '~/utils/logger';
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
@@ -31,6 +33,7 @@ import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
+import { syncLatestRevisionBodyToYjsDraftHandlerFactory } from './sync-latest-revision-body-to-yjs-draft';
 import { unpublishPageHandlersFactory } from './unpublish-page';
 import { updatePageHandlersFactory } from './update-page';
 
@@ -660,7 +663,7 @@ module.exports = (crowi) => {
         }
 
         const userRelatedGroups = await pageGrantService.getUserRelatedGroups(user);
-        const isUserGrantedPageAccess = await pageGrantService.isUserGrantedPageAccess(page, user, userRelatedGroups);
+        const isUserGrantedPageAccess = await pageGrantService.isUserGrantedPageAccess(page, user, userRelatedGroups, true);
         if (!isUserGrantedPageAccess) {
           return res.apiv3Err(new ErrorV3('Cannot access page or ancestor.', 'cannot_access_page'), 403);
         }
@@ -740,14 +743,17 @@ module.exports = (crowi) => {
   *            description: Return page's markdown
   */
   router.get('/export/:pageId', loginRequiredStrictly, validator.export, async(req, res) => {
-    const { pageId } = req.params;
+    const pageId: string = req.params.pageId;
     const { format, revisionId = null } = req.query;
     let revision;
     let pagePath;
 
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+    let page: HydratedDocument<PageDocument> | null;
+
     try {
-      const Page = crowi.model('Page');
-      const page = await Page.findByIdAndViewer(pageId, req.user);
+      page = await Page.findByIdAndViewer(pageId, req.user);
 
       if (page == null) {
         const isPageExist = await Page.count({ _id: pageId }) > 0;
@@ -757,8 +763,22 @@ module.exports = (crowi) => {
         }
         return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404);
       }
+    }
+    catch (err) {
+      logger.error('Failed to get page data', err);
+      return res.apiv3Err(err, 500);
+    }
 
-      const revisionIdForFind = revisionId || page.revision;
+    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+    try {
+      await normalizeLatestRevisionIfBroken(pageId);
+    }
+    catch (err) {
+      logger.error('Error occurred in normalizing the latest revision');
+    }
+
+    try {
+      const revisionIdForFind = revisionId ?? page.revision;
 
       revision = await Revision.findById(revisionIdForFind);
       pagePath = page.path;
@@ -769,7 +789,7 @@ module.exports = (crowi) => {
       }
     }
     catch (err) {
-      logger.error('Failed to get page data', err);
+      logger.error('Failed to get revision data', err);
       return res.apiv3Err(err, 500);
     }
 
@@ -953,5 +973,7 @@ module.exports = (crowi) => {
 
   router.get('/:pageId/yjs-data', getYjsDataHandlerFactory(crowi));
 
+  router.put('/:pageId/sync-latest-revision-body-to-yjs-draft', syncLatestRevisionBodyToYjsDraftHandlerFactory(crowi));
+
   return router;
 };

+ 64 - 0
apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts

@@ -0,0 +1,64 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { param, body } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import type { PageModel } from '~/server/models/page';
+import { getYjsService } from '~/server/service/yjs';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:sync-latest-revision-body-to-yjs-draft');
+
+type SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = {
+  pageId: string,
+}
+type ReqBody = {
+  editingMarkdownLength?: number,
+}
+interface Req extends Request<ReqParams, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  // define validators for req.params
+  const validator: ValidationChain[] = [
+    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+    body('editingMarkdownLength').optional().isInt().withMessage('The body "editingMarkdownLength" must be integer'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { pageId } = req.params;
+      const { editingMarkdownLength } = req.body;
+
+      // check whether accessible
+      if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+      }
+
+      try {
+        const yjsService = getYjsService();
+        const result = await yjsService.syncWithTheLatestRevisionForce(pageId, editingMarkdownLength);
+        return res.apiv3(result);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 15 - 0
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -17,6 +17,7 @@ import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificati
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { serializePageSecurely, serializeRevisionSecurely, serializeUserSecurely } 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';
 import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
@@ -130,6 +131,20 @@ 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'
+        try {
+          await normalizeLatestRevisionIfBroken(pageId);
+        }
+        catch (err) {
+          logger.error('Error occurred in normalizing the latest revision');
+        }
+      }
 
       if (currentPage != null && !await currentPage.isUpdatable(sanitizeRevisionId, origin)) {
         const latestRevision = await Revision.findById(currentPage.revision).populate('author');

+ 9 - 0
apps/app/src/server/routes/apiv3/revisions.js

@@ -1,6 +1,7 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { Revision } from '~/server/models/revision';
+import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -121,6 +122,14 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
     }
 
+    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+    try {
+      await normalizeLatestRevisionIfBroken(pageId);
+    }
+    catch (err) {
+      logger.error('Error occurred in normalizing the latest revision');
+    }
+
     try {
       const page = await Page.findOne({ _id: pageId });
       const queryOpts = {

+ 2 - 2
apps/app/src/server/routes/attachment/get.ts

@@ -1,5 +1,5 @@
 import {
-  getIdForRef, type IPage, type IUser,
+  getIdStringForRef, type IPage, type IUser,
 } from '@growi/core';
 import express from 'express';
 import type {
@@ -59,7 +59,7 @@ export const retrieveAttachmentFromIdParam = async(
   // check viewer has permission
   if (user != null && attachment.page != null) {
     const Page = mongoose.model<IPage, PageModel>('Page');
-    const isAccessible = await Page.isAccessiblePageByViewer(getIdForRef(attachment.page), user);
+    const isAccessible = await Page.isAccessiblePageByViewer(getIdStringForRef(attachment.page), user);
     if (!isAccessible) {
       res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'. This attachment might belong to other pages.`));
       return;

+ 1 - 1
apps/app/src/server/service/config-manager.ts

@@ -214,7 +214,7 @@ class ConfigManagerImpl implements ConfigManager, S2sMessageHandlable {
     }
   }
 
-  async removeConfigsInTheSameNamespace(namespace, configKeys: string[], withoutPublishingS2sMessage?) {
+  async removeConfigsInTheSameNamespace(namespace, configKeys: readonly string[], withoutPublishingS2sMessage?) {
     const queries: any[] = [];
     for (const key of configKeys) {
       queries.push({

+ 28 - 23
apps/app/src/server/service/file-uploader/aws.ts

@@ -1,3 +1,5 @@
+import type { ReadStream } from 'fs';
+
 import type { GetObjectCommandInput, HeadObjectCommandInput } from '@aws-sdk/client-s3';
 import {
   S3Client,
@@ -142,6 +144,32 @@ class AwsFileUploader extends AbstractFileUploader {
       : ResponseMode.REDIRECT;
   }
 
+  /**
+   * @inheritdoc
+   */
+  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('AWS is not configured.');
+    }
+
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
+
+    const s3 = S3Factory();
+
+    const filePath = getFilePathOnStorage(attachment);
+    const contentHeaders = new ContentHeaders(attachment);
+
+    await s3.send(new PutObjectCommand({
+      Bucket: getS3Bucket(),
+      Key: filePath,
+      Body: readStream,
+      ACL: getS3PutObjectCannedAcl(),
+      // put type and the file name for reference information when uploading
+      ContentType: contentHeaders.contentType?.value.toString(),
+      ContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+    }));
+  }
+
   /**
    * @inheritdoc
    */
@@ -280,29 +308,6 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectCommand(params));
   };
 
-  (lib as any).uploadAttachment = async function(fileStream, attachment) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('AWS is not configured.');
-    }
-
-    logger.debug(`File uploading: fileName=${attachment.fileName}`);
-
-    const s3 = S3Factory();
-
-    const filePath = getFilePathOnStorage(attachment);
-    const contentHeaders = new ContentHeaders(attachment);
-
-    return s3.send(new PutObjectCommand({
-      Bucket: getS3Bucket(),
-      Key: filePath,
-      Body: fileStream,
-      ACL: getS3PutObjectCannedAcl(),
-      // put type and the file name for reference information when uploading
-      ContentType: contentHeaders.contentType?.value.toString(),
-      ContentDisposition: contentHeaders.contentDisposition?.value.toString(),
-    }));
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
     const s3 = S3Factory();
 

+ 25 - 20
apps/app/src/server/service/file-uploader/azure.ts

@@ -1,3 +1,5 @@
+import type { ReadStream } from 'fs';
+
 import type { TokenCredential } from '@azure/identity';
 import { ClientSecretCredential } from '@azure/identity';
 import type {
@@ -97,6 +99,29 @@ class AzureFileUploader extends AbstractFileUploader {
     throw new Error('Method not implemented.');
   }
 
+  /**
+   * @inheritdoc
+   */
+  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('Azure is not configured.');
+    }
+
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
+    const filePath = getFilePathOnStorage(attachment);
+    const containerClient = await getContainerClient();
+    const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
+    const contentHeaders = new ContentHeaders(attachment);
+
+    await blockBlobClient.uploadStream(readStream, undefined, undefined, {
+      blobHTTPHeaders: {
+        // put type and the file name for reference information when uploading
+        blobContentType: contentHeaders.contentType?.value.toString(),
+        blobContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+      },
+    });
+  }
+
   /**
    * @inheritdoc
    */
@@ -221,26 +246,6 @@ module.exports = (crowi) => {
     }
   };
 
-  (lib as any).uploadAttachment = async function(readStream, attachment) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('Azure is not configured.');
-    }
-
-    logger.debug(`File uploading: fileName=${attachment.fileName}`);
-    const filePath = getFilePathOnStorage(attachment);
-    const containerClient = await getContainerClient();
-    const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
-    const contentHeaders = new ContentHeaders(attachment);
-
-    return blockBlobClient.uploadStream(readStream, undefined, undefined, {
-      blobHTTPHeaders: {
-        // put type and the file name for reference information when uploading
-        blobContentType: contentHeaders.contentType?.value.toString(),
-        blobContentDisposition: contentHeaders.contentDisposition?.value.toString(),
-      },
-    });
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
     const containerClient = await getContainerClient();
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);

+ 4 - 0
apps/app/src/server/service/file-uploader/file-uploader.ts

@@ -1,4 +1,5 @@
 import { randomUUID } from 'crypto';
+import type { ReadStream } from 'fs';
 
 import type { Response } from 'express';
 
@@ -36,6 +37,7 @@ export interface FileUploader {
   getTotalFileSize(): Promise<number>,
   doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult>,
   determineResponseMode(): ResponseMode,
+  uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void>,
   respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
   findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>,
   generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl>,
@@ -151,6 +153,8 @@ export abstract class AbstractFileUploader implements FileUploader {
     return ResponseMode.RELAY;
   }
 
+ abstract uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void>;
+
   /**
    * Respond to the HTTP request.
    */

+ 24 - 19
apps/app/src/server/service/file-uploader/gcs.ts

@@ -1,3 +1,5 @@
+import type { ReadStream } from 'fs';
+
 import { Storage } from '@google-cloud/storage';
 import urljoin from 'url-join';
 
@@ -94,6 +96,28 @@ class GcsFileUploader extends AbstractFileUploader {
       : ResponseMode.REDIRECT;
   }
 
+  /**
+   * @inheritdoc
+   */
+  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('GCS is not configured.');
+    }
+
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
+
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+    const filePath = getFilePathOnStorage(attachment);
+    const contentHeaders = new ContentHeaders(attachment);
+
+    await myBucket.upload(readStream.path.toString(), {
+      destination: filePath,
+      // put type and the file name for reference information when uploading
+      contentType: contentHeaders.contentType?.value.toString(),
+    });
+  }
+
   /**
    * @inheritdoc
    */
@@ -201,25 +225,6 @@ module.exports = function(crowi: Crowi) {
     });
   };
 
-  (lib as any).uploadAttachment = function(fileStream, attachment) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('GCS is not configured.');
-    }
-
-    logger.debug(`File uploading: fileName=${attachment.fileName}`);
-
-    const gcs = getGcsInstance();
-    const myBucket = gcs.bucket(getGcsBucket());
-    const filePath = getFilePathOnStorage(attachment);
-    const contentHeaders = new ContentHeaders(attachment);
-
-    return myBucket.upload(fileStream.path, {
-      destination: filePath,
-      // put type and the file name for reference information when uploading
-      contentType: contentHeaders.contentType?.value.toString(),
-    });
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());

+ 30 - 24
apps/app/src/server/service/file-uploader/gridfs.ts

@@ -1,3 +1,4 @@
+import type { ReadStream } from 'fs';
 import { Readable } from 'stream';
 import util from 'util';
 
@@ -16,6 +17,17 @@ import { ContentHeaders } from './utils';
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 
 
+const COLLECTION_NAME = 'attachmentFiles';
+const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
+
+// instantiate mongoose-gridfs
+const AttachmentFile = createModel({
+  modelName: COLLECTION_NAME,
+  bucketName: COLLECTION_NAME,
+  connection: mongoose.connection,
+});
+
+
 // TODO: rewrite this module to be a type-safe implementation
 class GridfsFileUploader extends AbstractFileUploader {
 
@@ -47,6 +59,24 @@ class GridfsFileUploader extends AbstractFileUploader {
     throw new Error('Method not implemented.');
   }
 
+  /**
+   * @inheritdoc
+   */
+  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
+
+    const contentHeaders = new ContentHeaders(attachment);
+
+    return AttachmentFile.promisifiedWrite(
+      {
+        // put type and the file name for reference information when uploading
+        filename: attachment.fileName,
+        contentType: contentHeaders.contentType?.value.toString(),
+      },
+      readStream,
+    );
+  }
+
   /**
    * @inheritdoc
    */
@@ -73,15 +103,6 @@ class GridfsFileUploader extends AbstractFileUploader {
 
 module.exports = function(crowi) {
   const lib = new GridfsFileUploader(crowi);
-  const COLLECTION_NAME = 'attachmentFiles';
-  const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
-
-  // instantiate mongoose-gridfs
-  const AttachmentFile = createModel({
-    modelName: COLLECTION_NAME,
-    bucketName: COLLECTION_NAME,
-    connection: mongoose.connection,
-  });
 
   // get Collection instance of chunk
   const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
@@ -150,21 +171,6 @@ module.exports = function(crowi) {
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
 
-  (lib as any).uploadAttachment = async function(fileStream, attachment) {
-    logger.debug(`File uploading: fileName=${attachment.fileName}`);
-
-    const contentHeaders = new ContentHeaders(attachment);
-
-    return AttachmentFile.promisifiedWrite(
-      {
-        // put type and the file name for reference information when uploading
-        filename: attachment.fileName,
-        contentType: contentHeaders.contentType?.value.toString(),
-      },
-      fileStream,
-    );
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
     const readable = new Readable();
     readable.push(data);

+ 9 - 1
apps/app/src/server/service/file-uploader/local.ts

@@ -1,3 +1,4 @@
+import type { ReadStream } from 'fs';
 import { Readable } from 'stream';
 
 import type { Response } from 'express';
@@ -71,6 +72,13 @@ class LocalFileUploader extends AbstractFileUploader {
       : ResponseMode.RELAY;
   }
 
+  /**
+   * @inheritdoc
+   */
+  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+    throw new Error('Method not implemented.');
+  }
+
   /**
    * @inheritdoc
    */
@@ -146,7 +154,7 @@ module.exports = function(crowi) {
     return fs.unlinkSync(filePath);
   };
 
-  (lib as any).uploadAttachment = async function(fileStream, attachment) {
+  lib.uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
     const filePath = getFilePathOnStorage(attachment);

+ 21 - 24
apps/app/src/server/service/g2g-transfer.ts

@@ -203,10 +203,10 @@ interface Receiver {
   updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void>
   /**
    * Upload attachment file
-   * @param {Readable} content Pushed attachment data from source GROWI
+   * @param {ReadStream} content Pushed attachment data from source GROWI
    * @param {any} attachmentMap Map-ped Attachment instance
    */
-  receiveAttachment(content: Readable, attachmentMap: any): Promise<void>
+  receiveAttachment(content: ReadStream, attachmentMap: any): Promise<void>
 }
 
 /**
@@ -329,7 +329,7 @@ export class G2GTransferPusherService implements Pusher {
   public async transferAttachments(tk: TransferKey): Promise<void> {
     const BATCH_SIZE = 100;
     const { fileUploadService, socketIoService } = this.crowi;
-    const socket = socketIoService.getAdminSocket();
+    const socket = socketIoService?.getAdminSocket();
     const filesFromSrcGROWI = await this.listFilesInStorage(tk);
 
     /**
@@ -394,7 +394,7 @@ export class G2GTransferPusherService implements Pusher {
         }
         catch (err) {
           logger.warn(`Error occured when getting Attachment(ID=${attachment.id}), skipping: `, err);
-          socket.emit('admin:g2gError', {
+          socket?.emit('admin:g2gError', {
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
             // TODO: emit error with params
@@ -408,7 +408,7 @@ export class G2GTransferPusherService implements Pusher {
         }
         catch (err) {
           logger.error(`Error occured when uploading attachment(ID=${attachment.id})`, err);
-          socket.emit('admin:g2gError', {
+          socket?.emit('admin:g2gError', {
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
             // TODO: emit error with params
@@ -421,9 +421,9 @@ export class G2GTransferPusherService implements Pusher {
 
   // eslint-disable-next-line max-len
   public async startTransfer(tk: TransferKey, user: any, collections: string[], optionsMap: any, destGROWIInfo: IDataGROWIInfo): Promise<void> {
-    const socket = this.crowi.socketIoService.getAdminSocket();
+    const socket = this.crowi.socketIoService?.getAdminSocket();
 
-    socket.emit('admin:g2gProgress', {
+    socket?.emit('admin:g2gProgress', {
       mongo: G2G_PROGRESS_STATUS.IN_PROGRESS,
       attachments: G2G_PROGRESS_STATUS.PENDING,
     });
@@ -443,11 +443,11 @@ export class G2GTransferPusherService implements Pusher {
     }
     catch (err) {
       logger.error(err);
-      socket.emit('admin:g2gProgress', {
+      socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.ERROR,
         attachments: G2G_PROGRESS_STATUS.PENDING,
       });
-      socket.emit('admin:g2gError', { message: 'Failed to generate GROWI archive file', key: 'admin:g2g:error_generate_growi_archive' });
+      socket?.emit('admin:g2gError', { message: 'Failed to generate GROWI archive file', key: 'admin:g2g:error_generate_growi_archive' });
       throw err;
     }
 
@@ -466,15 +466,15 @@ export class G2GTransferPusherService implements Pusher {
     }
     catch (err) {
       logger.error(err);
-      socket.emit('admin:g2gProgress', {
+      socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.ERROR,
         attachments: G2G_PROGRESS_STATUS.PENDING,
       });
-      socket.emit('admin:g2gError', { message: 'Failed to send GROWI archive file to the destination GROWI', key: 'admin:g2g:error_send_growi_archive' });
+      socket?.emit('admin:g2gError', { message: 'Failed to send GROWI archive file to the destination GROWI', key: 'admin:g2g:error_send_growi_archive' });
       throw err;
     }
 
-    socket.emit('admin:g2gProgress', {
+    socket?.emit('admin:g2gProgress', {
       mongo: G2G_PROGRESS_STATUS.COMPLETED,
       attachments: G2G_PROGRESS_STATUS.IN_PROGRESS,
     });
@@ -484,15 +484,15 @@ export class G2GTransferPusherService implements Pusher {
     }
     catch (err) {
       logger.error(err);
-      socket.emit('admin:g2gProgress', {
+      socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.COMPLETED,
         attachments: G2G_PROGRESS_STATUS.ERROR,
       });
-      socket.emit('admin:g2gError', { message: 'Failed to transfer attachments', key: 'admin:g2g:error_upload_attachment' });
+      socket?.emit('admin:g2gError', { message: 'Failed to transfer attachments', key: 'admin:g2g:error_upload_attachment' });
       throw err;
     }
 
-    socket.emit('admin:g2gProgress', {
+    socket?.emit('admin:g2gProgress', {
       mongo: G2G_PROGRESS_STATUS.COMPLETED,
       attachments: G2G_PROGRESS_STATUS.COMPLETED,
     });
@@ -520,9 +520,9 @@ export class G2GTransferPusherService implements Pusher {
  */
 export class G2GTransferReceiverService implements Receiver {
 
-  crowi: any;
+  crowi: Crowi;
 
-  constructor(crowi: any) {
+  constructor(crowi: Crowi) {
     this.crowi = crowi;
   }
 
@@ -543,7 +543,7 @@ export class G2GTransferReceiverService implements Receiver {
   }
 
   public async answerGROWIInfo(): Promise<IDataGROWIInfo> {
-    const { version, configManager, fileUploadService } = this.crowi;
+    const { version, fileUploadService } = this.crowi;
     const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
     const fileUploadDisabled = configManager.getConfig('crowi', 'app:fileUploadDisabled');
     const fileUploadTotalLimit = fileUploadService.getFileUploadTotalLimit();
@@ -640,9 +640,7 @@ export class G2GTransferReceiverService implements Receiver {
       importSettingsMap: { [key: string]: ImportSettings; },
       sourceGROWIUploadConfigs: FileUploadConfigs,
   ): Promise<void> {
-    const { configManager, appService } = this.crowi;
-    const importService = getImportService();
-
+    const { configManager, importService, appService } = this.crowi;
     /** whether to keep current file upload configs */
     const shouldKeepUploadConfigs = configManager.getConfig('crowi', 'app:fileUploadType') !== 'none';
 
@@ -670,7 +668,6 @@ export class G2GTransferReceiverService implements Receiver {
   }
 
   public async getFileUploadConfigs(): Promise<FileUploadConfigs> {
-    const { configManager } = this.crowi;
     const fileUploadConfigs = Object.fromEntries(UPLOAD_CONFIG_KEYS.map((key) => {
       return [key, configManager.getConfigFromDB('crowi', key)];
     })) as FileUploadConfigs;
@@ -679,7 +676,7 @@ export class G2GTransferReceiverService implements Receiver {
   }
 
   public async updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void> {
-    const { appService, configManager } = this.crowi;
+    const { appService } = this.crowi;
 
     await configManager.removeConfigsInTheSameNamespace('crowi', Object.keys(fileUploadConfigs));
     await configManager.updateConfigsInTheSameNamespace('crowi', fileUploadConfigs);
@@ -687,7 +684,7 @@ export class G2GTransferReceiverService implements Receiver {
     await appService.setupAfterInstall();
   }
 
-  public async receiveAttachment(content: Readable, attachmentMap): Promise<void> {
+  public async receiveAttachment(content: ReadStream, attachmentMap): Promise<void> {
     const { fileUploadService } = this.crowi;
     return fileUploadService.uploadAttachment(content, attachmentMap);
   }

+ 2 - 1
apps/app/src/server/service/in-app-notification.ts

@@ -18,10 +18,11 @@ import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
-import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
+
 
 import { generateSnapshot } from './in-app-notification/in-app-notification-utils';
 import { preNotifyService, type PreNotify } from './pre-notify';
+import { RoomPrefix, getRoomNameWithId } from './socket-io/helper';
 
 
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;

+ 36 - 0
apps/app/src/server/service/normalize-data/convert-revision-page-id-to-objectid.ts

@@ -0,0 +1,36 @@
+// see: https://redmine.weseek.co.jp/issues/150649
+
+import { type IRevisionHasId } from '@growi/core';
+import type { FilterQuery, UpdateQuery } from 'mongoose';
+import mongoose from 'mongoose';
+
+import type { IRevisionDocument } from '~/server/models/revision';
+import { type IRevisionModel } from '~/server/models/revision';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:NormalizeData:convert-revision-page-id-to-string');
+
+export const convertRevisionPageIdToObjectId = async(): Promise<void> => {
+  const Revision = mongoose.model<IRevisionHasId, IRevisionModel>('Revision');
+
+  const filter: FilterQuery<IRevisionDocument> = { pageId: { $type: 'string' } };
+
+  const update: UpdateQuery<IRevisionDocument> = [
+    {
+      $set: {
+        pageId: {
+          $convert: {
+            input: '$pageId',
+            to: 'objectId',
+            onError: '$pageId',
+          },
+        },
+      },
+    },
+  ];
+
+  await Revision.updateMany(filter, update);
+
+  const explain = await Revision.updateMany(filter, update).explain('queryPlanner');
+  logger.debug(explain);
+};

+ 2 - 0
apps/app/src/server/service/normalize-data/index.ts

@@ -1,11 +1,13 @@
 import loggerFactory from '~/utils/logger';
 
+import { convertRevisionPageIdToObjectId } from './convert-revision-page-id-to-objectid';
 import { renameDuplicateRootPages } from './rename-duplicate-root-pages';
 
 const logger = loggerFactory('growi:service:NormalizeData');
 
 export const normalizeData = async(): Promise<void> => {
   await renameDuplicateRootPages();
+  await convertRevisionPageIdToObjectId();
 
   logger.info('normalizeData has been executed');
   return;

+ 3 - 2
apps/app/src/server/service/page-grant.ts

@@ -106,7 +106,7 @@ export interface IPageGrantService {
   getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
   getNonUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
-  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
+  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[], allowAnyoneWithTheLink?: boolean) => boolean,
   getPageGroupGrantData: (page: PageDocument, user) => Promise<GroupGrantData>,
   calcApplicableGrantData: (page, user) => Promise<IRecordApplicableGrant>
 }
@@ -789,8 +789,9 @@ class PageGrantService implements IPageGrantService {
   /**
    * Check if user is granted access to page
    */
-  isUserGrantedPageAccess(page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]): boolean {
+  isUserGrantedPageAccess(page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[], allowAnyoneWithTheLink = false): boolean {
     if (page.grant === PageGrant.GRANT_PUBLIC) return true;
+    if (page.grant === PageGrant.GRANT_RESTRICTED && allowAnyoneWithTheLink) return true;
     if (page.grant === PageGrant.GRANT_OWNER) return page.grantedUsers?.includes(user._id.toString()) ?? false;
     if (page.grant === PageGrant.GRANT_USER_GROUP) return this.getUserRelatedGrantedGroupsSyncronously(userRelatedGroups, page).length > 0;
     return false;

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

@@ -3,10 +3,11 @@ import { Writable } from 'stream';
 import { getIdForRef } from '@growi/core';
 import type { IPage, Ref } from '@growi/core';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
+import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
-import type { PageModel } from '~/server/models/page';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 
@@ -38,7 +39,7 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
     throw new Error(msg);
   }
 
-  const Page = mongoose.model<IPage, PageModel>('Page');
+  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
   const userHomepage = await Page.findByPath(userHomepagePath, true);
 
   if (userHomepage == null) {

+ 38 - 26
apps/app/src/server/service/page/index.ts

@@ -4,16 +4,18 @@ import { Readable, Writable } from 'stream';
 
 import type {
   Ref, HasObjectId, IUserHasId, IUser,
-  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup, IRevisionHasId,
+  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IGrantedGroup, IRevisionHasId,
+  IDataWithMeta,
 } from '@growi/core';
 import {
   PageGrant, PageStatus, YDocStatus, getIdForRef,
+  getIdStringForRef,
 } from '@growi/core';
 import {
   pagePathUtils, pathUtils,
 } from '@growi/core/dist/utils';
 import escapeStringRegexp from 'escape-string-regexp';
-import type { ObjectId, Cursor } from 'mongoose';
+import type { Cursor, HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
@@ -53,6 +55,7 @@ import { PathAlreadyExistsError } from '../../models/errors';
 import type { PageOperationDocument } from '../../models/page-operation';
 import PageOperation from '../../models/page-operation';
 import PageRedirect from '../../models/page-redirect';
+import type { IRevisionDocument } from '../../models/revision';
 import { Revision } from '../../models/revision';
 import { serializePageSecurely } from '../../models/serializers/page-serializer';
 import ShareLink from '../../models/share-link';
@@ -405,11 +408,11 @@ class PageService implements IPageService {
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   async findPageAndMetaDataByViewer(
       pageId: string, path: string, user: IUserHasId, includeEmpty = false, isSharedPage = false,
-  ): Promise<IPageWithMeta<IPageInfoAll>|null> {
+  ): Promise<IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoAll>|null> {
 
-    const Page = this.crowi.model('Page') as PageModel;
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
 
-    let page: PageDocument & HasObjectId | null;
+    let page: HydratedDocument<PageDocument> | null;
     if (pageId != null) { // prioritized
       page = await Page.findByIdAndViewer(pageId, user, null, includeEmpty);
     }
@@ -832,7 +835,7 @@ class PageService implements IPageService {
   }
 
   private async renamePageV4(page, newPagePath, user, options) {
-    const Page = this.crowi.model('Page');
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
     const {
       isRecursively = false,
       createRedirectPage = false,
@@ -858,6 +861,9 @@ class PageService implements IPageService {
     const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
 
     // update Rivisions
+    if (renamedPage == null) {
+      throw new Error('Failed to rename page');
+    }
     await Revision.updateRevisionListByPageId(renamedPage._id, { pageId: renamedPage._id });
 
     if (createRedirectPage) {
@@ -1347,15 +1353,15 @@ class PageService implements IPageService {
       return this.duplicateDescendantsV4(pages, user, oldPagePathPrefix, newPagePathPrefix);
     }
 
-    const Page = this.crowi.model('Page');
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
 
     const pageIds = pages.map(page => page._id);
     const revisions = await Revision.find({ pageId: { $in: pageIds } });
 
     // Mapping to set to the body of the new revision
-    const pageIdRevisionMapping = {};
+    const pageIdRevisionMapping: Record<string, IRevisionDocument> = {};
     revisions.forEach((revision) => {
-      pageIdRevisionMapping[getIdForRef(revision.pageId)] = revision;
+      pageIdRevisionMapping[getIdStringForRef(revision.pageId)] = revision;
     });
 
     // key: oldPageId, value: newPageId
@@ -1390,7 +1396,7 @@ class PageService implements IPageService {
           revision: revisionId,
         };
         newRevisions.push({
-          _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
+          _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id.toString()].body, author: user._id, format: 'markdown',
         });
         newPages.push(newPage);
       }
@@ -1408,9 +1414,9 @@ class PageService implements IPageService {
     const revisions = await Revision.find({ pageId: { $in: pageIds } });
 
     // Mapping to set to the body of the new revision
-    const pageIdRevisionMapping = {};
+    const pageIdRevisionMapping: Record<string, IRevisionDocument> = {};
     revisions.forEach((revision) => {
-      pageIdRevisionMapping[getIdForRef(revision.pageId)] = revision;
+      pageIdRevisionMapping[getIdStringForRef(revision.pageId)] = revision;
     });
 
     // key: oldPageId, value: newPageId
@@ -1436,7 +1442,7 @@ class PageService implements IPageService {
       });
 
       newRevisions.push({
-        _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
+        _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id.toString()].body, author: user._id, format: 'markdown',
       });
 
     });
@@ -1705,8 +1711,8 @@ class PageService implements IPageService {
     // no sub operation available
   }
 
-  private async deletePageV4(page, user, options = {}, isRecursively = false) {
-    const Page = mongoose.model('Page') as PageModel;
+  private async deletePageV4(page: HydratedDocument<PageDocument>, user, options = {}, isRecursively = false) {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
 
     const newPath = Page.getDeletedPageName(page.path);
     const isTrashed = isTrashPage(page.path);
@@ -2542,7 +2548,7 @@ class PageService implements IPageService {
     });
   }
 
-  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
+  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | Omit<IPageInfoForEntity, 'bookmarkCount'> {
     const isMovable = isGuestUser ? false : isMovablePage(page.path);
     const isDeletable = !(isGuestUser || isTopPage(page.path) || isUsersTopPage(page.path));
 
@@ -2560,7 +2566,7 @@ class PageService implements IPageService {
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
 
-    return {
+    const infoForEntity: Omit<IPageInfoForEntity, 'bookmarkCount'> = {
       isV5Compatible: isTopPage(page.path) || page.parent != null,
       isEmpty: false,
       sumOfLikers: page.liker.length,
@@ -2576,9 +2582,10 @@ class PageService implements IPageService {
       commentCount: page.commentCount,
     };
 
+    return infoForEntity;
   }
 
-  async shortBodiesMapByPageIds(pageIds: ObjectId[] = [], user?): Promise<Record<string, string | null>> {
+  async shortBodiesMapByPageIds(pageIds: ObjectIdLike[] = [], user?): Promise<Record<string, string | null>> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const MAX_LENGTH = 350;
 
@@ -3590,8 +3597,8 @@ class PageService implements IPageService {
    * @param path string
    * @returns Promise<PageDocument>
    */
-  async getParentAndFillAncestorsByUser(user, path: string): Promise<PageDocument> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+  async getParentAndFillAncestorsByUser(user, path: string): Promise<HydratedDocument<PageDocument>> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
 
     // Find parent
     const parent = await Page.findParentByPath(path);
@@ -3617,8 +3624,8 @@ class PageService implements IPageService {
     return createdParent;
   }
 
-  async getParentAndFillAncestorsBySystem(path: string): Promise<PageDocument> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+  async getParentAndFillAncestorsBySystem(path: string): Promise<HydratedDocument<PageDocument>> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
 
     // Find parent
     const parent = await Page.findParentByPath(path);
@@ -4035,6 +4042,10 @@ class PageService implements IPageService {
     const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser);
     savedPage = await pushRevision(savedPage, newRevision, dummyUser);
 
+    if (savedPage._id == null) {
+      throw new Error('Something went wrong: _id is null');
+    }
+
     // Update descendantCount
     await this.updateDescendantCountOfAncestors(savedPage._id, 1, false);
 
@@ -4321,8 +4332,9 @@ class PageService implements IPageService {
   /*
    * Find all children by parent's path or id. Using id should be prioritized
    */
-  async findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups = null): Promise<PageDocument[]> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+  async findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups = null)
+      : Promise<(HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[]> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
     let queryBuilder: PageQueryBuilder;
     if (hasSlash(parentPathOrId)) {
       const path = parentPathOrId;
@@ -4336,7 +4348,7 @@ class PageService implements IPageService {
     }
     await queryBuilder.addViewerCondition(user, userGroups);
 
-    const pages = await queryBuilder
+    const pages: HydratedDocument<PageDocument>[] = await queryBuilder
       .addConditionToSortPagesByAscPath()
       .query
       .lean()
@@ -4412,7 +4424,7 @@ class PageService implements IPageService {
    * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
    */
   private async injectProcessDataIntoPagesByActionTypes(
-      pages: (PageDocument & { processData?: IPageOperationProcessData })[],
+      pages: (HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[],
       actionTypes: PageActionType[],
   ): Promise<void> {
 

+ 4 - 4
apps/app/src/server/service/page/page-service.ts

@@ -4,7 +4,7 @@ import type {
   HasObjectId,
   IPageInfo, IPageInfoForEntity, IUser,
 } from '@growi/core';
-import type { ObjectId } from 'mongoose';
+import type { Types } from 'mongoose';
 
 import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
@@ -17,13 +17,13 @@ export interface IPageService {
   forceCreateBySystem(path: string, body: string, options: IOptionsForCreate): Promise<PageDocument>,
   updatePage(pageData: PageDocument, body: string | null, previousBody: string | null, user: IUser, options: IOptionsForUpdate,): Promise<PageDocument>,
   updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
-  deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
+  deleteCompletelyOperation: (pageIds: ObjectIdLike[], pagePaths: string[]) => Promise<void>,
   getEventEmitter: () => EventEmitter,
   deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
-  shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
-  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
+  shortBodiesMapByPageIds(pageIds?: Types.ObjectId[], user?): Promise<Record<string, string | null>>,
+  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | Omit<IPageInfoForEntity, 'bookmarkCount'>,
   canDelete(page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean): boolean,
   canDeleteCompletely(
     page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]

+ 15 - 10
apps/app/src/server/service/passport.ts

@@ -571,43 +571,48 @@ class PassportService implements S2sMessageHandlable {
     // Prevent request timeout error on app init
     const oidcIssuer = await this.getOIDCIssuerInstance(issuerHost);
     if (oidcIssuer != null) {
+      const oidcIssuerMetadata = oidcIssuer.metadata;
+
       logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
       const authorizationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint');
       if (authorizationEndpoint) {
-        oidcIssuer.metadata.authorization_endpoint = authorizationEndpoint;
+        oidcIssuerMetadata.authorization_endpoint = authorizationEndpoint;
       }
       const tokenEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint');
       if (tokenEndpoint) {
-        oidcIssuer.metadata.token_endpoint = tokenEndpoint;
+        oidcIssuerMetadata.token_endpoint = tokenEndpoint;
       }
       const revocationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint');
       if (revocationEndpoint) {
-        oidcIssuer.metadata.revocation_endpoint = revocationEndpoint;
+        oidcIssuerMetadata.revocation_endpoint = revocationEndpoint;
       }
       const introspectionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint');
       if (introspectionEndpoint) {
-        oidcIssuer.metadata.introspection_endpoint = introspectionEndpoint;
+        oidcIssuerMetadata.introspection_endpoint = introspectionEndpoint;
       }
       const userInfoEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint');
       if (userInfoEndpoint) {
-        oidcIssuer.metadata.userinfo_endpoint = userInfoEndpoint;
+        oidcIssuerMetadata.userinfo_endpoint = userInfoEndpoint;
       }
       const endSessionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint');
       if (endSessionEndpoint) {
-        oidcIssuer.metadata.end_session_endpoint = endSessionEndpoint;
+        oidcIssuerMetadata.end_session_endpoint = endSessionEndpoint;
       }
       const registrationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint');
       if (registrationEndpoint) {
-        oidcIssuer.metadata.registration_endpoint = registrationEndpoint;
+        oidcIssuerMetadata.registration_endpoint = registrationEndpoint;
       }
       const jwksUri = configManager.getConfig('crowi', 'security:passport-oidc:jwksUri');
       if (jwksUri) {
-        oidcIssuer.metadata.jwks_uri = jwksUri;
+        oidcIssuerMetadata.jwks_uri = jwksUri;
       }
-      logger.debug('Configured issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
-      const client = new oidcIssuer.Client({
+      const newOidcIssuer = new OIDCIssuer(oidcIssuerMetadata);
+
+      logger.debug('Configured issuer %s %O', newOidcIssuer.issuer, newOidcIssuer.metadata);
+
+      const client = new newOidcIssuer.Client({
         client_id: clientId,
         client_secret: clientSecret,
         redirect_uris: [redirectUri],

+ 124 - 0
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts

@@ -0,0 +1,124 @@
+import { getIdStringForRef } from '@growi/core';
+import type { HydratedDocument } from 'mongoose';
+import mongoose, { Types } from 'mongoose';
+
+import type { PageDocument, PageModel } from '~/server/models/page';
+import PageModelFactory from '~/server/models/page';
+import { Revision } from '~/server/models/revision';
+
+import { normalizeLatestRevisionIfBroken } from './normalize-latest-revision-if-broken';
+
+describe('normalizeLatestRevisionIfBroken', () => {
+
+  beforeAll(async() => {
+    await PageModelFactory(null);
+  });
+
+
+  test('should update the latest revision', async() => {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+    // == Arrange
+    const page = await Page.create({ path: '/foo' });
+    const revision = await Revision.create({ pageId: page._id, body: '' });
+    // connect the page and the revision
+    page.revision = revision._id;
+    await page.save();
+    // break the revision
+    await Revision.updateOne({ _id: revision._id }, { pageId: new Types.ObjectId() });
+
+    // spy
+    const updateOneSpy = vi.spyOn(Revision, 'updateOne');
+
+    // == Act
+    await normalizeLatestRevisionIfBroken(page._id);
+
+    // == Assert
+    // assert spy
+    expect(updateOneSpy).toHaveBeenCalled();
+
+    // assert revision
+    const revisionById = await Revision.findById(revision._id);
+    const revisionByPageId = await Revision.findOne({ pageId: page._id });
+    expect(revisionById).not.toBeNull();
+    expect(revisionByPageId).not.toBeNull();
+    assert(revisionById != null);
+    assert(revisionByPageId != null);
+    expect(revisionById._id).toEqual(revisionByPageId._id);
+    expect(getIdStringForRef(revisionById.pageId)).toEqual(page._id.toString());
+  });
+
+
+  describe('should returns without any operation', () => {
+    test('when the page has revisions at least one', async() => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+      // Arrange
+      const page = await Page.create({ path: '/foo' });
+      await Revision.create({ pageId: page._id, body: '' });
+      // spy
+      const updateOneSpy = vi.spyOn(Revision, 'updateOne');
+
+      // Act
+      await normalizeLatestRevisionIfBroken(page._id);
+
+      // Assert
+      expect(updateOneSpy).not.toHaveBeenCalled();
+    });
+
+    test('when the page is not found', async() => {
+      // Arrange
+      const pageIdOfRevision = new Types.ObjectId();
+      // create an orphan revision
+      await Revision.create({ pageId: pageIdOfRevision, body: '' });
+
+      // spy
+      const updateOneSpy = vi.spyOn(Revision, 'updateOne');
+
+      // Act
+      await normalizeLatestRevisionIfBroken(pageIdOfRevision);
+
+      // Assert
+      expect(updateOneSpy).not.toHaveBeenCalled();
+    });
+
+    test('when the page.revision is null', async() => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+      // Arrange
+      const page = await Page.create({ path: '/foo' });
+      // create an orphan revision
+      await Revision.create({ pageId: page._id, body: '' });
+
+      // spy
+      const updateOneSpy = vi.spyOn(Revision, 'updateOne');
+
+      // Act
+      await normalizeLatestRevisionIfBroken(page._id);
+
+      // Assert
+      expect(updateOneSpy).not.toHaveBeenCalled();
+    });
+
+    test('when the page.revision does not exist', async() => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+      // Arrange
+      const revisionNonExistent = new Types.ObjectId();
+      const page = await Page.create({ path: '/foo', revision: revisionNonExistent });
+      // create an orphan revision
+      await Revision.create({ pageId: page._id, body: '' });
+
+      // spy
+      const updateOneSpy = vi.spyOn(Revision, 'updateOne');
+
+      // Act
+      await normalizeLatestRevisionIfBroken(page._id);
+
+      // Assert
+      expect(updateOneSpy).not.toHaveBeenCalled();
+    });
+
+  });
+
+});

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