Преглед изворни кода

Merge branch 'master' into feat/page-bulk-export

Futa Arai пре 1 година
родитељ
комит
a9680d5470
77 измењених фајлова са 803 додато и 340 уклоњено
  1. 5 3
      .github/workflows/auto-labeling.yml
  2. 1 0
      .github/workflows/ci-app.yml
  3. 23 1
      CHANGELOG.md
  4. 1 1
      apps/app/docker/README.md
  5. 3 2
      apps/app/package.json
  6. 6 6
      apps/app/src/client/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  7. 5 6
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  8. 1 1
      apps/app/src/client/components/InstallerForm.tsx
  9. 4 3
      apps/app/src/client/components/Me/ProfileImageSettings.tsx
  10. 6 3
      apps/app/src/client/components/PageComment.tsx
  11. 9 5
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  12. 9 4
      apps/app/src/client/components/PageControls/PageControls.tsx
  13. 12 7
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  14. 4 11
      apps/app/src/client/components/SearchPage/SearchResultContent.tsx
  15. 3 1
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  16. 3 1
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  17. 1 8
      apps/app/src/client/services/update-page/index.ts
  18. 7 0
      apps/app/src/client/services/update-page/update-page.ts
  19. 25 0
      apps/app/src/client/services/update-page/use-update-page.tsx
  20. 1 1
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  21. 2 2
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js
  22. 29 7
      apps/app/src/pages/_app.page.tsx
  23. 2 12
      apps/app/src/pages/_document.page.tsx
  24. 15 6
      apps/app/src/pages/utils/commons.ts
  25. 0 47
      apps/app/src/server/console.js
  26. 6 8
      apps/app/src/server/events/user.ts
  27. 2 2
      apps/app/src/server/interfaces/mongoose-utils.ts
  28. 2 2
      apps/app/src/server/models/attachment.ts
  29. 3 3
      apps/app/src/server/models/external-account.ts
  30. 6 6
      apps/app/src/server/models/named-query.ts
  31. 28 28
      apps/app/src/server/models/page.ts
  32. 1 3
      apps/app/src/server/models/password-reset-order.ts
  33. 15 6
      apps/app/src/server/models/revision.ts
  34. 1 2
      apps/app/src/server/models/share-link.ts
  35. 3 4
      apps/app/src/server/models/update-post.ts
  36. 4 5
      apps/app/src/server/models/user-group-relation.ts
  37. 1 3
      apps/app/src/server/models/user-group.ts
  38. 1 3
      apps/app/src/server/models/user.js
  39. 39 0
      apps/app/src/server/repl.ts
  40. 1 1
      apps/app/src/server/routes/apiv3/customize-setting.js
  41. 6 5
      apps/app/src/server/routes/apiv3/page-listing.ts
  42. 26 7
      apps/app/src/server/routes/apiv3/page/index.ts
  43. 15 0
      apps/app/src/server/routes/apiv3/page/update-page.ts
  44. 9 0
      apps/app/src/server/routes/apiv3/revisions.js
  45. 2 2
      apps/app/src/server/routes/attachment/get.ts
  46. 11 4
      apps/app/src/server/service/normalize-data/convert-revision-page-id-to-objectid.ts
  47. 2 2
      apps/app/src/server/service/normalize-data/index.ts
  48. 3 2
      apps/app/src/server/service/page-grant.ts
  49. 3 2
      apps/app/src/server/service/page/delete-completely-user-home-by-system.ts
  50. 35 24
      apps/app/src/server/service/page/index.ts
  51. 3 3
      apps/app/src/server/service/page/page-service.ts
  52. 15 10
      apps/app/src/server/service/passport.ts
  53. 124 0
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts
  54. 38 0
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts
  55. 3 3
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  56. 4 0
      apps/app/src/server/service/yjs/sync-ydoc.ts
  57. 3 0
      apps/app/src/server/service/yjs/yjs.integ.ts
  58. 4 0
      apps/app/src/server/service/yjs/yjs.ts
  59. 1 1
      apps/app/src/server/util/granted-group.ts
  60. 1 1
      apps/app/src/styles/_fonts.scss
  61. 1 1
      apps/app/test/integration/service/page.test.js
  62. 8 8
      apps/app/test/integration/service/v5.non-public-page.test.ts
  63. 1 1
      apps/slackbot-proxy/package.json
  64. 1 1
      package.json
  65. 9 0
      packages/core/CHANGELOG.md
  66. 2 1
      packages/core/package.json
  67. 110 0
      packages/core/src/interfaces/common.spec.ts
  68. 13 5
      packages/core/src/interfaces/common.ts
  69. 1 0
      packages/core/src/interfaces/index.ts
  70. 8 0
      packages/core/src/interfaces/locale.ts
  71. 1 1
      packages/core/src/interfaces/user.ts
  72. 5 7
      packages/editor/src/client/components-internal/CodeMirrorEditor/CodeMirrorEditor.tsx
  73. 7 2
      packages/editor/src/client/components-internal/playground/Playground.tsx
  74. 15 2
      packages/editor/src/client/components/CodeMirrorEditorMain.tsx
  75. 18 22
      packages/editor/src/client/services/use-codemirror-editor/use-codemirror-editor.ts
  76. 4 8
      packages/editor/src/client/stores/codemirror-editor.ts
  77. 1 1
      yarn.lock

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

+ 23 - 1
CHANGELOG.md

@@ -1,9 +1,31 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.15...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

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

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.0.15`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.15/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)

+ 3 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.16-RC.0",
+  "version": "7.0.17-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -44,7 +44,8 @@
     "vitest:run:components": "vitest run -c vitest.config.components.ts src --coverage",
     "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
-    "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
+    "console": "yarn repl",
+    "repl": "yarn cross-env NODE_ENV=development yarn ts-node src/server/repl.ts",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",

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

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

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

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

+ 1 - 1
apps/app/src/client/components/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);

+ 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 - 4
apps/app/src/client/components/PageControls/PageControls.tsx

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

+ 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

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

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

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

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

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

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

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

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

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

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

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

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

+ 1 - 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>({

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

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

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

+ 2 - 12
apps/app/src/pages/_document.page.tsx

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

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

@@ -1,11 +1,10 @@
 import type { ColorScheme, IUserHasId } from '@growi/core';
-import { Lang, AllLang } from '@growi/core';
+import { Lang, AllLang, Locale } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { isServer } from '@growi/core/dist/utils';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { SSRConfig, UserConfig } from 'next-i18next';
 
-
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
 import { type SupportedActionType } from '~/interfaces/activity';
@@ -106,13 +105,23 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   return { props };
 };
 
+export type LangMap = {
+  readonly [key in Lang]: Locale;
+};
+
+export const langMap: LangMap = {
+  [Lang.ja_JP]: Locale['ja-JP'],
+  [Lang.en_US]: Locale['en-US'],
+  [Lang.zh_CN]: Locale['zh-CN'],
+  [Lang.fr_FR]: Locale['fr-FR'],
+};
 
-export const getLocateAtServerSide = (req: CrowiRequest): Lang => {
+export const getLocaleAtServerSide = (req: CrowiRequest): Locale => {
   const { user, headers } = req;
   const { configManager } = req.crowi;
 
-  return user == null ? detectLocaleFromBrowserAcceptLanguage(headers)
-    : (user.lang ?? configManager.getConfig('crowi', 'app:globalLang') as Lang ?? Lang.en_US);
+  return langMap[user == null ? detectLocaleFromBrowserAcceptLanguage(headers)
+    : (user.lang ?? configManager.getConfig('crowi', 'app:globalLang') as Lang ?? Lang.en_US) ?? Lang.en_US];
 };
 
 export const getNextI18NextConfig = async(
@@ -126,7 +135,7 @@ export const getNextI18NextConfig = async(
 
   // determine language
   const req: CrowiRequest = context.req as CrowiRequest;
-  const locale = getLocateAtServerSide(req);
+  const locale = getLocaleAtServerSide(req);
 
   const namespaces = ['commons'];
   if (namespacesRequired != null) {

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

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

+ 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

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

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

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

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

@@ -15,8 +15,6 @@ const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
 const { omitInsecureAttributes } = require('./serializers/user-serializer');
 
 const logger = loggerFactory('growi:models:user');
@@ -44,7 +42,7 @@ module.exports = function(crowi) {
   const userSchema = new mongoose.Schema({
     userId: String,
     image: String,
-    imageAttachment: { type: ObjectId, ref: 'Attachment' },
+    imageAttachment: { type: mongoose.Schema.Types.ObjectId, ref: 'Attachment' },
     imageUrlCached: String,
     isGravatarEnabled: { type: Boolean, default: false },
     isEmailPublished: { type: Boolean, default: true },

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

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

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

+ 6 - 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';
 
 
@@ -14,7 +15,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';
 
@@ -122,7 +123,7 @@ const routerFactory = (crowi: Crowi): Router => {
     const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
     const attachShortBody: boolean = attachShortBodyParam === 'true';
 
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
     const Bookmark = crowi.model('Bookmark');
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const pageService = crowi.pageService;
@@ -171,11 +172,11 @@ const routerFactory = (crowi: Crowi): Router => {
           : {
             ...basicPageInfo,
             isAbleToDeleteCompletely: canDeleteCompletely,
-            bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
-            revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
+            bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id.toString()] : undefined,
+            revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id.toString()] : undefined,
           } as IPageInfoForListing;
 
-        idToPageInfoMap[page._id] = pageInfo;
+        idToPageInfoMap[page._id.toString()] = pageInfo;
       }
 
       return res.apiv3(idToPageInfoMap);

+ 26 - 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,13 +18,14 @@ 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 { exportService } from '~/server/service/export';
 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';
@@ -662,7 +664,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);
         }
@@ -742,14 +744,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;
@@ -759,8 +764,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);
+    }
+
+    // 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');
+    }
 
-      const revisionIdForFind = revisionId || page.revision;
+    try {
+      const revisionIdForFind = revisionId ?? page.revision;
 
       revision = await Revision.findById(revisionIdForFind);
       pagePath = page.path;
@@ -771,7 +790,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);
     }
 

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

@@ -18,6 +18,7 @@ import {
 } from '~/server/models';
 import type { PageDocument, PageModel } from '~/server/models/page';
 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';
@@ -131,6 +132,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;

+ 11 - 4
apps/app/src/server/service/normalize-data/convert-revision-page-id-to-string.ts → apps/app/src/server/service/normalize-data/convert-revision-page-id-to-objectid.ts

@@ -1,22 +1,29 @@
 // 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 convertRevisionPageIdToString = async(): Promise<void> => {
+export const convertRevisionPageIdToObjectId = async(): Promise<void> => {
   const Revision = mongoose.model<IRevisionHasId, IRevisionModel>('Revision');
 
-  const filter = { pageId: { $type: 'objectId' } };
-  const update = [
+  const filter: FilterQuery<IRevisionDocument> = { pageId: { $type: 'string' } };
+
+  const update: UpdateQuery<IRevisionDocument> = [
     {
       $set: {
         pageId: {
-          $toString: '$pageId',
+          $convert: {
+            input: '$pageId',
+            to: 'objectId',
+            onError: '$pageId',
+          },
         },
       },
     },

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

@@ -1,13 +1,13 @@
 import loggerFactory from '~/utils/logger';
 
-import { convertRevisionPageIdToString } from './convert-revision-page-id-to-string';
+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 convertRevisionPageIdToString();
+  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) {

+ 35 - 24
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);
@@ -2579,7 +2585,7 @@ class PageService implements IPageService {
     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;
 
@@ -3591,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);
@@ -3618,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);
@@ -4036,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);
 
@@ -4322,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;
@@ -4337,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()
@@ -4413,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> {
 

+ 3 - 3
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,12 +17,12 @@ 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>>,
+  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(

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

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

@@ -0,0 +1,38 @@
+import type { HydratedDocument, Types } from 'mongoose';
+import mongoose from 'mongoose';
+
+import type { PageDocument, PageModel } from '~/server/models/page';
+import { Revision } from '~/server/models/revision';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:service:revision:normalize-latest-revision');
+
+/**
+ * Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+ *
+ * @ref https://github.com/weseek/growi/pull/8998
+ */
+export const normalizeLatestRevisionIfBroken = async(pageId: string | Types.ObjectId): Promise<void> => {
+
+  if (await Revision.exists({ pageId: { $eq: pageId } })) {
+    return;
+  }
+
+  logger.info(`The page ('${pageId}') does not have any revisions. Normalization of the latest revision will be started.`);
+
+  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+  const page = await Page.findOne({ _id: { $eq: pageId } }, { revision: 1 }).exec();
+
+  if (page == null) {
+    logger.warn(`Normalization has been canceled since the page ('${pageId}') could not be found.`);
+    return;
+  }
+  if (page.revision == null || !(await Revision.exists({ _id: page.revision }))) {
+    logger.warn(`Normalization has been canceled since the Page.revision of the page ('${pageId}') could not be found.`);
+    return;
+  }
+
+  // update Revision.pageId
+  await Revision.updateOne({ _id: page.revision }, { $set: { pageId } }).exec();
+};

+ 3 - 3
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -1,7 +1,7 @@
 import { Writable, Transform } from 'stream';
 import { URL } from 'url';
 
-import { getIdForRef, type IPage } from '@growi/core';
+import { getIdStringForRef, type IPage } from '@growi/core';
 import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
@@ -357,8 +357,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
    * generate object that is related to page.grant*
    */
   generateDocContentsRelatedToRestriction(page: AggregatedPage) {
-    const grantedUserIds = page.grantedUsers.map(user => getIdForRef(user));
-    const grantedGroupIds = page.grantedGroups.map(group => getIdForRef(group.item));
+    const grantedUserIds = page.grantedUsers.map(user => getIdStringForRef(user));
+    const grantedGroupIds = page.grantedGroups.map(group => getIdStringForRef(group.item));
 
     return {
       grant: page.grant,

+ 4 - 0
apps/app/src/server/service/yjs/sync-ydoc.ts

@@ -4,6 +4,7 @@ import type { Document } from 'y-socket.io/dist/server';
 import loggerFactory from '~/utils/logger';
 
 import { Revision } from '../../models/revision';
+import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
 
 import type { MongodbPersistence } from './extended/mongodb-persistence';
 
@@ -26,6 +27,9 @@ type Context = {
 export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: true | Context): Promise<void> => {
   const pageId = doc.name;
 
+  // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+  await normalizeLatestRevisionIfBroken(pageId);
+
   const revision = await Revision
     .findOne(
       // filter

+ 3 - 0
apps/app/src/server/service/yjs/yjs.integ.ts

@@ -17,6 +17,9 @@ vi.mock('y-socket.io/dist/server', () => {
   return { YSocketIO };
 });
 
+vi.mock('../revision/normalize-latest-revision-if-broken', () => ({
+  normalizeLatestRevisionIfBroken: vi.fn(),
+}));
 
 const ObjectId = Types.ObjectId;
 

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

@@ -15,6 +15,7 @@ import loggerFactory from '~/utils/logger';
 
 import type { PageModel } from '../../models/page';
 import { Revision } from '../../models/revision';
+import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
 
 import { createIndexes } from './create-indexes';
 import { createMongoDBPersistence } from './create-mongodb-persistence';
@@ -141,6 +142,9 @@ class YjsService implements IYjsService {
       logger.debug(`getYDocStatus('${pageId}') detected '${status}'`, args ?? {});
     };
 
+    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+    await normalizeLatestRevisionIfBroken(pageId);
+
     // get the latest revision createdAt
     const result = await Revision
       .findOne(

+ 1 - 1
apps/app/src/server/util/granted-group.ts

@@ -1,6 +1,6 @@
 import { type IGrantedGroup, GroupType } from '@growi/core';
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 
 export const divideByType = (grantedGroups: IGrantedGroup[] | null): {
   grantedUserGroups: ObjectIdLike[];

+ 1 - 1
apps/app/src/styles/_fonts.scss

@@ -1,7 +1,7 @@
 :root {
   --font-family-sans-serif: var(--grw-font-family-lato), -apple-system, blinkmacsystemfont, 'Hiragino Kaku Gothic ProN', meiryo, sans-serif;
   --font-family-serif: georgia, 'Times New Roman', times, serif;
-  --font-family-monospace: monospace, var(--grw-font-family-source-han-code-jp-subset-main), var(--grw-font-family-source-han-code-jp-subset-jis2);
+  --font-family-monospace: Menlo, Consolas, DejaVu Sans Mono, var(--grw-font-family-source-han-code-jp-subset-main), var(--grw-font-family-source-han-code-jp-subset-jis2), monospace;
 }
 
 .material-symbols-outlined {

+ 1 - 1
apps/app/test/integration/service/page.test.js

@@ -671,7 +671,7 @@ describe('PageService', () => {
       expect(insertedPage.lastUpdateUser).toEqual(testUser2._id);
 
       expect([insertedRevision]).not.toBeNull();
-      expect(insertedRevision.pageId).toEqual(insertedPage._id.toString());
+      expect(insertedRevision.pageId).toEqual(insertedPage._id);
       expect(insertedRevision._id).not.toEqual(childForDuplicateRevision._id);
       expect(insertedRevision.body).toEqual(childForDuplicateRevision.body);
 

+ 8 - 8
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -1289,8 +1289,8 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedPage2.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision1.body).toBe(_revision1.body);
       expect(duplicatedRevision2.body).toBe(_revision2.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
-      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id.toString());
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id);
     });
     test('Should duplicate multiple pages. Page with GRANT_RESTRICTED should NOT be duplicated', async() => {
       const _path1 = '/np_duplicate4';
@@ -1329,8 +1329,8 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedPage3.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision1.body).toBe(baseRevision1.body);
       expect(duplicatedRevision3.body).toBe(baseRevision3.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
-      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id.toString());
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
     });
     test('Should duplicate only user related pages and granted groups when onlyDuplicateUserRelatedResources is true', async() => {
       const _path1 = '/np_duplicate7';
@@ -1364,7 +1364,7 @@ describe('PageService page operations with non-public pages', () => {
       ]);
       expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
       expect(duplicatedRevision1.body).toBe(_revision1.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
     });
     test('Should duplicate all pages and granted groups when onlyDuplicateUserRelatedResources is false', async() => {
       const _path1 = '/np_duplicate7';
@@ -1410,18 +1410,18 @@ describe('PageService page operations with non-public pages', () => {
       ]);
       expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
       expect(duplicatedRevision1.body).toBe(_revision1.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
       expect(normalizeGrantedGroups(duplicatedPage2.grantedGroups)).toStrictEqual([
         { item: groupIdC, type: GroupType.userGroup },
         { item: externalGroupIdC, type: GroupType.externalUserGroup },
       ]);
       expect(duplicatedPage2.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision2.body).toBe(_revision2.body);
-      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id.toString());
+      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id);
       expect(duplicatedPage3.grantedUsers).toStrictEqual([npDummyUser2._id]);
       expect(duplicatedPage3.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision3.body).toBe(_revision3.body);
-      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id.toString());
+      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
     });
 
   });

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.0.16-slackbot-proxy.0",
+  "version": "7.0.17-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.0.16-RC.0",
+  "version": "7.0.17-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

+ 9 - 0
packages/core/CHANGELOG.md

@@ -1,5 +1,14 @@
 # @growi/core
 
+## 1.1.0
+
+### Minor Changes
+
+- [#9010](https://github.com/weseek/growi/pull/9010) [`c2d4766`](https://github.com/weseek/growi/commit/c2d476677574dfb9cd3fb9e18cc8073b30dad842) Thanks [@yuki-takei](https://github.com/yuki-takei)! - - `Ref<T>` is modified - includes ObjectId
+  - `isPopulated()` is updated
+  - `getIdForRef()` is updated
+  - `getIdStringForRef()` is newly added
+
 ## 1.0.0
 
 ### Major Changes

+ 2 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "1.0.0",
+  "version": "1.1.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [
@@ -70,6 +70,7 @@
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",
+    "mongoose": "^6.11.3",
     "swr": "^2.2.2"
   }
 }

+ 110 - 0
packages/core/src/interfaces/common.spec.ts

@@ -0,0 +1,110 @@
+import type { HydratedDocument } from 'mongoose';
+import { Types } from 'mongoose';
+import { mock } from 'vitest-mock-extended';
+
+import { getIdForRef, isPopulated } from './common';
+import type { IPageHasId } from './page';
+import { type IPage } from './page';
+
+describe('isPopulated', () => {
+
+  it('should return true when the argument implements HasObjectId', () => {
+    // Arrange
+    const ref = mock<IPageHasId>();
+
+    // Act
+    const result = isPopulated(ref);
+
+    // Assert
+    expect(result).toBe(true);
+  });
+
+  it('should return true when the argument is a mongoose Document', () => {
+    // Arrange
+    const ref = mock<HydratedDocument<IPage>>();
+
+    // Act
+    const result = isPopulated(ref);
+
+    // Assert
+    expect(result).toBe(true);
+  });
+
+  it('should return false when the argument is string', () => {
+    // Arrange
+    const ref = new Types.ObjectId().toString();
+
+    // Act
+    const result = isPopulated(ref);
+
+    // Assert
+    expect(result).toBe(false);
+  });
+
+  it('should return false when the argument is ObjectId', () => {
+    // Arrange
+    const ref = new Types.ObjectId();
+
+    // Act
+    const result = isPopulated(ref);
+
+    // Assert
+    expect(result).toBe(false);
+  });
+
+});
+
+
+describe('getIdForRef', () => {
+
+  it('should return the id string when the argument is populated', () => {
+    // Arrange
+    const id = new Types.ObjectId();
+    const ref = mock<IPageHasId>({
+      _id: id.toString(),
+    });
+
+    // Act
+    const result = getIdForRef(ref);
+
+    // Assert
+    expect(result).toStrictEqual(id.toString());
+  });
+
+  it('should return the ObjectId when the argument is a mongoose Document', () => {
+    // Arrange
+    const id = new Types.ObjectId();
+    const ref = mock<HydratedDocument<IPage>>({
+      _id: id,
+    });
+
+    // Act
+    const result = getIdForRef(ref);
+
+    // Assert
+    expect(result).toStrictEqual(id);
+  });
+
+  it('should return the id string as is when the argument is ObjectId', () => {
+    // Arrange
+    const ref = new Types.ObjectId();
+
+    // Act
+    const result = getIdForRef(ref);
+
+    // Assert
+    expect(result).toStrictEqual(ref);
+  });
+
+  it('should return the ObjectId as is when the argument is string', () => {
+    // Arrange
+    const ref = new Types.ObjectId().toString();
+
+    // Act
+    const result = getIdForRef(ref);
+
+    // Assert
+    expect(result).toStrictEqual(ref);
+  });
+
+});

+ 13 - 5
packages/core/src/interfaces/common.ts

@@ -2,20 +2,28 @@
  * Common types and interfaces
  */
 
-import type { HasObjectId } from './has-object-id';
 
+import type { Types } from 'mongoose';
+
+type ObjectId = Types.ObjectId;
 
 // Foreign key field
-export type Ref<T> = string | T & HasObjectId;
+export type Ref<T> = string | ObjectId | T & { _id: string | ObjectId };
 
 export type Nullable<T> = T | null | undefined;
 
-export const isPopulated = <T>(ref: T & HasObjectId | Ref<T>): ref is T & HasObjectId => {
-  return !(typeof ref === 'string');
+export const isPopulated = <T>(ref: Ref<T>): ref is T & { _id: string | ObjectId } => {
+  return ref != null
+    && typeof ref !== 'string'
+    && !('_bsontype' in ref && ref._bsontype === 'ObjectID');
 };
 
-export const getIdForRef = <T>(ref: T & HasObjectId | Ref<T>): string => {
+export const getIdForRef = <T>(ref: Ref<T>): string | ObjectId => {
   return isPopulated(ref)
     ? ref._id
     : ref;
 };
+
+export const getIdStringForRef = <T>(ref: Ref<T>): string => {
+  return getIdForRef(ref).toString();
+};

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

@@ -7,6 +7,7 @@ export * from './growi-facade';
 export * from './growi-theme-metadata';
 export * from './has-object-id';
 export * from './lang';
+export * from './locale';
 export * from './page';
 export * from './revision';
 export * from './subscription';

+ 8 - 0
packages/core/src/interfaces/locale.ts

@@ -0,0 +1,8 @@
+export const Locale = {
+  'en-US': 'en-US',
+  'ja-JP': 'ja-JP',
+  'zh-CN': 'zh-CN',
+  'fr-FR': 'fr-FR',
+} as const;
+export const AllLocale = Object.values(Locale);
+export type Locale = typeof Locale[keyof typeof Locale];

+ 1 - 1
packages/core/src/interfaces/user.ts

@@ -37,7 +37,7 @@ export type IUserGroup = {
   name: string;
   createdAt: Date;
   description: string;
-  parent: Ref<IUserGroupHasId> | null;
+  parent: Ref<IUserGroup> | null;
 }
 
 export const USER_STATUS = {

+ 5 - 7
packages/editor/src/client/components-internal/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -34,10 +34,13 @@ const CodeMirrorEditorContainer = forwardRef<HTMLDivElement, DetailedHTMLProps<R
 );
 
 export type CodeMirrorEditorProps = {
+  /**
+   * Specity the props for the react-codemirror component. **This must be a memolized object.**
+   */
+  cmProps?: ReactCodeMirrorProps,
   acceptedUploadFileType?: AcceptedUploadFileType,
   indentSize?: number,
   editorSettings?: EditorSettings,
-  onChange?: (value: string) => void,
   onSave?: () => void,
   onUpload?: (files: File[]) => void,
   onScroll?: () => void,
@@ -53,10 +56,10 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     editorKey,
     hideToolbar,
 
+    cmProps,
     acceptedUploadFileType = AcceptedUploadFileType.NONE,
     indentSize,
     editorSettings,
-    onChange,
     onSave,
     onUpload,
     onScroll,
@@ -64,11 +67,6 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   const containerRef = useRef(null);
 
-  const cmProps = useMemo<ReactCodeMirrorProps>(() => {
-    return {
-      onChange,
-    };
-  }, [onChange]);
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey, containerRef.current, cmProps);
 
   useDefaultExtensions(codeMirrorEditor);

+ 7 - 2
packages/editor/src/client/components-internal/playground/Playground.tsx

@@ -1,8 +1,9 @@
 import {
-  useCallback, useEffect, useState,
+  useCallback, useEffect, useMemo, useState,
 } from 'react';
 
 import { AcceptedUploadFileType } from '@growi/core';
+import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import { toast } from 'react-toastify';
 
 import { GlobalCodeMirrorEditorKey } from '../../../consts';
@@ -62,6 +63,10 @@ export const Playground = (): JSX.Element => {
 
   }, [codeMirrorEditor]);
 
+  const cmProps = useMemo<ReactCodeMirrorProps>(() => ({
+    onChange: setMarkdownToPreview,
+  }), []);
+
   return (
     <div className="d-flex flex-column vw-100 flex-expand-vh-100">
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '83px' }}>
@@ -72,11 +77,11 @@ export const Playground = (): JSX.Element => {
           <CodeMirrorEditorMain
             isEditorMode
             onSave={saveHandler}
-            onChange={setMarkdownToPreview}
             onUpload={uploadHandler}
             indentSize={4}
             acceptedUploadFileType={AcceptedUploadFileType.ALL}
             editorSettings={editorSettings}
+            cmProps={cmProps}
           />
         </div>
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">

+ 15 - 2
packages/editor/src/client/components/CodeMirrorEditorMain.tsx

@@ -1,8 +1,10 @@
-import { useEffect } from 'react';
+import { useEffect, useMemo } from 'react';
 
 import { type Extension } from '@codemirror/state';
 import { keymap, scrollPastEnd } from '@codemirror/view';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
+import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
+import deepmerge from 'ts-deepmerge';
 
 import { GlobalCodeMirrorEditorKey } from '../../consts';
 import { CodeMirrorEditor, type CodeMirrorEditorProps } from '../components-internal/CodeMirrorEditor';
@@ -28,7 +30,7 @@ type Props = CodeMirrorEditorProps & {
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
-    user, pageId, initialValue, isEditorMode,
+    user, pageId, initialValue, isEditorMode, cmProps,
     onSave, onEditorsUpdated, ...otherProps
   } = props;
 
@@ -66,10 +68,21 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
     return cleanupFunction;
   }, [codeMirrorEditor, onSave]);
 
+  const cmPropsOverride = useMemo<ReactCodeMirrorProps>(() => deepmerge(
+    cmProps ?? {},
+    {
+      // Disable the basic history configuration since this component uses Y.UndoManager instead
+      basicSetup: {
+        history: false,
+      },
+    },
+  ), [cmProps]);
+
   return (
     <CodeMirrorEditor
       editorKey={GlobalCodeMirrorEditorKey.MAIN}
       onSave={onSave}
+      cmProps={cmPropsOverride}
       {...otherProps}
     />
   );

+ 18 - 22
packages/editor/src/client/services/use-codemirror-editor/use-codemirror-editor.ts

@@ -40,29 +40,25 @@ export type UseCodeMirrorEditor = {
 
 export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor => {
 
-  const mergedProps = useMemo(() => {
-    return deepmerge(
-      props ?? {},
-      {
-        // Reset settings of react-codemirror.
-        // Extensions are defined first will be used if they have the same priority.
-        // If extensions conflict, disable them here.
-        // And add them to defaultExtensions: Extension[] with a lower priority.
-        // ref: https://codemirror.net/examples/config/
-        // ------- Start -------
-        indentWithTab: false,
-        basicSetup: {
-          defaultKeymap: false,
-          dropCursor: false,
-          highlightActiveLine: false,
-          highlightActiveLineGutter: false,
-          // Disabled react-codemirror history for Y.UndoManager
-          history: false,
-        },
-        // ------- End -------
+  const mergedProps = useMemo(() => deepmerge(
+    {
+      // Reset settings of react-codemirror.
+      // Extensions are defined first will be used if they have the same priority.
+      // If extensions conflict, disable them here.
+      // And add them to defaultExtensions: Extension[] with a lower priority.
+      // ref: https://codemirror.net/examples/config/
+      // ------- Start -------
+      indentWithTab: false,
+      basicSetup: {
+        defaultKeymap: false,
+        dropCursor: false,
+        highlightActiveLine: false,
+        highlightActiveLineGutter: false,
       },
-    );
-  }, [props]);
+      // ------- End -------
+    },
+    props ?? {},
+  ), [props]);
 
   const { state, view } = useCodeMirror(mergedProps);
 

+ 4 - 8
packages/editor/src/client/stores/codemirror-editor.ts

@@ -26,14 +26,10 @@ export const useCodeMirrorEditorIsolated = (
   const currentData = ref.current;
 
   const swrKey = key != null ? `codeMirrorEditor_${key}` : null;
-  const mergedProps = useMemo<UseCodeMirror>(() => {
-    return deepmerge(
-      props ?? {},
-      {
-        container,
-      },
-    );
-  }, [container, props]);
+  const mergedProps = useMemo<UseCodeMirror>(() => deepmerge(
+    { container },
+    props ?? {},
+  ), [container, props]);
 
   const newData = useCodeMirrorEditor(mergedProps);
 

+ 1 - 1
yarn.lock

@@ -2137,7 +2137,7 @@
   version "1.0.0"
 
 "@growi/core@link:packages/core":
-  version "1.0.0"
+  version "1.1.0"
   dependencies:
     bson-objectid "^2.0.4"
     escape-string-regexp "^4.0.0"