Jelajahi Sumber

Merge branch 'imprv/148445-upgrade-remark-growi-directive' into imprv/148445-15079-update-dependencies

reiji-h 1 tahun lalu
induk
melakukan
eded44b44f
100 mengubah file dengan 743 tambahan dan 452 penghapusan
  1. 5 0
      .changeset/metal-donkeys-collect.md
  2. 5 3
      .github/workflows/auto-labeling.yml
  3. 1 0
      .github/workflows/ci-app.yml
  4. 3 1
      .github/workflows/reusable-app-prod.yml
  5. 23 1
      CHANGELOG.md
  6. 1 1
      apps/app/docker/README.md
  7. 4 3
      apps/app/package.json
  8. 1 1
      apps/app/src/client/components/Admin/AuditLog/ActivityTable.tsx
  9. 6 6
      apps/app/src/client/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  10. 5 6
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  11. 1 1
      apps/app/src/client/components/InstallerForm.tsx
  12. 4 3
      apps/app/src/client/components/Me/ProfileImageSettings.tsx
  13. 6 3
      apps/app/src/client/components/PageComment.tsx
  14. 9 5
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  15. 9 4
      apps/app/src/client/components/PageControls/PageControls.tsx
  16. 12 7
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  17. 4 11
      apps/app/src/client/components/SearchPage/SearchResultContent.tsx
  18. 3 1
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  19. 3 1
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  20. 1 8
      apps/app/src/client/services/update-page/index.ts
  21. 7 0
      apps/app/src/client/services/update-page/update-page.ts
  22. 25 0
      apps/app/src/client/services/update-page/use-update-page.tsx
  23. 1 1
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  24. 5 5
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  25. 2 2
      apps/app/src/interfaces/crowi-request.ts
  26. 2 2
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js
  27. 29 7
      apps/app/src/pages/_app.page.tsx
  28. 2 12
      apps/app/src/pages/_document.page.tsx
  29. 15 6
      apps/app/src/pages/utils/commons.ts
  30. 0 47
      apps/app/src/server/console.js
  31. 6 8
      apps/app/src/server/events/user.ts
  32. 2 2
      apps/app/src/server/interfaces/mongoose-utils.ts
  33. 2 1
      apps/app/src/server/middlewares/access-token-parser.js
  34. 3 3
      apps/app/src/server/models/attachment.ts
  35. 3 3
      apps/app/src/server/models/external-account.ts
  36. 6 6
      apps/app/src/server/models/named-query.ts
  37. 28 28
      apps/app/src/server/models/page.ts
  38. 1 3
      apps/app/src/server/models/password-reset-order.ts
  39. 15 6
      apps/app/src/server/models/revision.ts
  40. 1 1
      apps/app/src/server/models/serializers/bookmark-serializer.js
  41. 0 1
      apps/app/src/server/models/serializers/index.ts
  42. 1 1
      apps/app/src/server/models/serializers/page-serializer.js
  43. 1 1
      apps/app/src/server/models/serializers/revision-serializer.js
  44. 1 1
      apps/app/src/server/models/serializers/user-group-relation-serializer.js
  45. 0 35
      apps/app/src/server/models/serializers/user-serializer.js
  46. 1 2
      apps/app/src/server/models/share-link.ts
  47. 3 4
      apps/app/src/server/models/update-post.ts
  48. 4 5
      apps/app/src/server/models/user-group-relation.ts
  49. 1 3
      apps/app/src/server/models/user-group.ts
  50. 2 5
      apps/app/src/server/models/user.js
  51. 39 0
      apps/app/src/server/repl.ts
  52. 12 10
      apps/app/src/server/routes/apiv3/activity.ts
  53. 4 5
      apps/app/src/server/routes/apiv3/attachment.js
  54. 2 1
      apps/app/src/server/routes/apiv3/bookmarks.js
  55. 2 2
      apps/app/src/server/routes/apiv3/customize-setting.js
  56. 1 1
      apps/app/src/server/routes/apiv3/forgot-password.js
  57. 8 5
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  58. 31 17
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  59. 9 4
      apps/app/src/server/routes/apiv3/invited.ts
  60. 1 1
      apps/app/src/server/routes/apiv3/notification-setting.js
  61. 10 9
      apps/app/src/server/routes/apiv3/page-listing.ts
  62. 2 3
      apps/app/src/server/routes/apiv3/page/create-page.ts
  63. 26 7
      apps/app/src/server/routes/apiv3/page/index.ts
  64. 18 3
      apps/app/src/server/routes/apiv3/page/update-page.ts
  65. 2 2
      apps/app/src/server/routes/apiv3/pages/index.js
  66. 11 3
      apps/app/src/server/routes/apiv3/revisions.js
  67. 2 3
      apps/app/src/server/routes/apiv3/user-group-relation.js
  68. 6 9
      apps/app/src/server/routes/apiv3/user-group.js
  69. 7 9
      apps/app/src/server/routes/apiv3/users.js
  70. 1 3
      apps/app/src/server/routes/attachment/api.js
  71. 1 1
      apps/app/src/server/routes/attachment/get-brand-logo.ts
  72. 3 3
      apps/app/src/server/routes/attachment/get.ts
  73. 3 2
      apps/app/src/server/routes/comment.js
  74. 4 3
      apps/app/src/server/routes/ogp.ts
  75. 1 1
      apps/app/src/server/routes/page.js
  76. 1 1
      apps/app/src/server/service/attachment.js
  77. 1 1
      apps/app/src/server/service/file-uploader/aws.ts
  78. 1 1
      apps/app/src/server/service/file-uploader/azure.ts
  79. 1 1
      apps/app/src/server/service/file-uploader/file-uploader.ts
  80. 1 1
      apps/app/src/server/service/file-uploader/gcs.ts
  81. 1 1
      apps/app/src/server/service/file-uploader/gridfs.ts
  82. 1 1
      apps/app/src/server/service/file-uploader/local.ts
  83. 1 1
      apps/app/src/server/service/file-uploader/utils/headers.ts
  84. 6 3
      apps/app/src/server/service/g2g-transfer.ts
  85. 1 1
      apps/app/src/server/service/global-notification/global-notification-mail.js
  86. 1 1
      apps/app/src/server/service/global-notification/global-notification-slack.js
  87. 11 4
      apps/app/src/server/service/normalize-data/convert-revision-page-id-to-objectid.ts
  88. 2 2
      apps/app/src/server/service/normalize-data/index.ts
  89. 3 2
      apps/app/src/server/service/page-grant.ts
  90. 3 2
      apps/app/src/server/service/page/delete-completely-user-home-by-system.ts
  91. 35 24
      apps/app/src/server/service/page/index.ts
  92. 3 3
      apps/app/src/server/service/page/page-service.ts
  93. 15 10
      apps/app/src/server/service/passport.ts
  94. 1 1
      apps/app/src/server/service/pre-notify.ts
  95. 124 0
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts
  96. 38 0
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts
  97. 3 3
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  98. 6 12
      apps/app/src/server/service/search-delegator/private-legacy-pages.ts
  99. 1 1
      apps/app/src/server/service/search.ts
  100. 1 1
      apps/app/src/server/service/slack-command-handler/keep.js

+ 5 - 0
.changeset/metal-donkeys-collect.md

@@ -0,0 +1,5 @@
+---
+'@growi/core': minor
+---
+
+Transplant and re-implement serializers for User and Attachment

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

+ 3 - 1
.github/workflows/reusable-app-prod.yml

@@ -348,7 +348,9 @@ jobs:
 
     runs-on: ubuntu-latest
     container:
-      image: mcr.microsoft.com/playwright:latest
+      # Match the Playwright version
+      # https://github.com/microsoft/playwright/issues/20010
+      image: mcr.microsoft.com/playwright:v1.46.0-jammy
 
     strategy:
       fail-fast: false

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

+ 4 - 3
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\"",
@@ -208,7 +209,7 @@
     "usehooks-ts": "^2.6.0",
     "validator": "^13.7.0",
     "ws": "^8.17.1",
-    "xss": "^1.0.14",
+    "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",
     "y-socket.io": "^1.1.3",
     "yjs": "^13.6.18"

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

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

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

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

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

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

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

+ 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 - 1
apps/app/src/server/middlewares/access-token-parser.js

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

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

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

+ 3 - 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 - 1
apps/app/src/server/models/serializers/bookmark-serializer.js

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,4 +1,5 @@
 /* eslint-disable no-use-before-define */
+import { omitInsecureAttributes } from '@growi/core/dist/models/serializers';
 import { pagePathUtils } from '@growi/core/dist/utils';
 
 import { i18n } from '^/config/next-i18next.config';
@@ -15,10 +16,6 @@ const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-const { omitInsecureAttributes } = require('./serializers/user-serializer');
-
 const logger = loggerFactory('growi:models:user');
 
 module.exports = function(crowi) {
@@ -44,7 +41,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();

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

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

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

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

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

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

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

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

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

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

+ 8 - 5
apps/app/src/server/routes/apiv3/g2g-transfer.ts

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

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

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

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

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

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

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

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

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

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

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

+ 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,12 +18,13 @@ import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
-import type { PageModel } from '~/server/models/page';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
 import Subscription from '~/server/models/subscription';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
 import { preNotifyService } from '~/server/service/pre-notify';
+import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import loggerFactory from '~/utils/logger';
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
@@ -661,7 +663,7 @@ module.exports = (crowi) => {
         }
 
         const userRelatedGroups = await pageGrantService.getUserRelatedGroups(user);
-        const isUserGrantedPageAccess = await pageGrantService.isUserGrantedPageAccess(page, user, userRelatedGroups);
+        const isUserGrantedPageAccess = await pageGrantService.isUserGrantedPageAccess(page, user, userRelatedGroups, true);
         if (!isUserGrantedPageAccess) {
           return res.apiv3Err(new ErrorV3('Cannot access page or ancestor.', 'cannot_access_page'), 403);
         }
@@ -741,14 +743,17 @@ module.exports = (crowi) => {
   *            description: Return page's markdown
   */
   router.get('/export/:pageId', loginRequiredStrictly, validator.export, async(req, res) => {
-    const { pageId } = req.params;
+    const pageId: string = req.params.pageId;
     const { format, revisionId = null } = req.query;
     let revision;
     let pagePath;
 
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+    let page: HydratedDocument<PageDocument> | null;
+
     try {
-      const Page = crowi.model('Page');
-      const page = await Page.findByIdAndViewer(pageId, req.user);
+      page = await Page.findByIdAndViewer(pageId, req.user);
 
       if (page == null) {
         const isPageExist = await Page.count({ _id: pageId }) > 0;
@@ -758,8 +763,22 @@ module.exports = (crowi) => {
         }
         return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404);
       }
+    }
+    catch (err) {
+      logger.error('Failed to get page data', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    // 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;
@@ -770,7 +789,7 @@ module.exports = (crowi) => {
       }
     }
     catch (err) {
-      logger.error('Failed to get page data', err);
+      logger.error('Failed to get revision data', err);
       return res.apiv3Err(err, 500);
     }
 

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

@@ -3,6 +3,7 @@ import type {
   IPage, IRevisionHasId, IUserHasId,
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
@@ -13,11 +14,11 @@ import { type IApiv3PageUpdateParams, PageUpdateErrorCode } from '~/interfaces/a
 import type { IOptionsForUpdate } from '~/interfaces/page';
 import type Crowi from '~/server/crowi';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
-import {
-  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely, serializeUserSecurely,
-} from '~/server/models';
+import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
+import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
 import { preNotifyService } from '~/server/service/pre-notify';
+import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import { getYjsService } from '~/server/service/yjs';
 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');

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

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

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

@@ -1,17 +1,17 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import express from 'express';
 
 import { Revision } from '~/server/models/revision';
+import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:pages');
 
-const express = require('express');
 const { query, param } = require('express-validator');
 
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-
 const router = express.Router();
 
 /**
@@ -121,6 +121,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 - 3
apps/app/src/server/routes/apiv3/user-group-relation.js

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

+ 6 - 9
apps/app/src/server/routes/apiv3/user-group.js

@@ -1,12 +1,17 @@
 import { GroupType } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
-
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import express from 'express';
+import {
+  body, param, query, sanitizeQuery,
+} from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import UserGroup from '~/server/models/user-group';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import { toPagingLimit, toPagingOffset } from '~/server/util/express-validator/sanitizer';
 import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
 
@@ -16,16 +21,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 
-const express = require('express');
-
 const router = express.Router();
 
-const { body, param, query } = require('express-validator');
-const { sanitizeQuery } = require('express-validator');
-
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
-
 
 /**
  * @swagger

+ 7 - 9
apps/app/src/server/routes/apiv3/users.js

@@ -1,11 +1,18 @@
 
+import path from 'path';
+
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
+import express from 'express';
+import { body, query } from 'express-validator';
+import { isEmail } from 'validator';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import ExternalAccount from '~/server/models/external-account';
+import { serializePageSecurely } from '~/server/models/serializers';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import { configManager } from '~/server/service/config-manager';
 import { deleteCompletelyUserHomeBySystem } from '~/server/service/page/delete-completely-user-home-by-system';
@@ -16,17 +23,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:users');
 
-const path = require('path');
-
-const express = require('express');
-
 const router = express.Router();
 
-const { body, query } = require('express-validator');
-const { isEmail } = require('validator');
-
-const { serializePageSecurely } = require('../../models/serializers/page-serializer');
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const PAGE_ITEMS = 50;
 

+ 1 - 3
apps/app/src/server/routes/attachment/api.js

@@ -1,11 +1,9 @@
-import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
-import { Attachment, serializePageSecurely, serializeRevisionSecurely } from '../../models';
-
+import { Attachment } from '../../models/attachment';
 /* eslint-disable no-use-before-define */
 
 

+ 1 - 1
apps/app/src/server/routes/attachment/get-brand-logo.ts

@@ -9,7 +9,7 @@ import loggerFactory from '~/utils/logger';
 import type Crowi from '../../crowi';
 import { AttachmentType } from '../../interfaces/attachment';
 import { generateCertifyBrandLogoMiddleware } from '../../middlewares/certify-brand-logo';
-import { Attachment } from '../../models';
+import { Attachment } from '../../models/attachment';
 import ApiResponse from '../../util/apiResponse';
 
 import { getActionFactory } from './get';

+ 3 - 3
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 {
@@ -17,7 +17,7 @@ import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
 import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
-import { Attachment, type IAttachmentDocument } from '../../models';
+import { Attachment, type IAttachmentDocument } from '../../models/attachment';
 import ApiResponse from '../../util/apiResponse';
 
 
@@ -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;

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

@@ -1,9 +1,11 @@
 
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+
 import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
 import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
-import { GlobalNotificationSettingEvent } from '../models';
+import { GlobalNotificationSettingEvent } from '../models/GlobalNotificationSetting';
 import { preNotifyService } from '../service/pre-notify';
 
 /**
@@ -12,7 +14,6 @@ import { preNotifyService } from '../service/pre-notify';
  *    name: Comments
  */
 
-const { serializeUserSecurely } = require('../models/serializers/user-serializer');
 
 /**
  * @swagger

+ 4 - 3
apps/app/src/server/routes/ogp.ts

@@ -4,15 +4,16 @@ import path from 'path';
 import { DevidedPagePath } from '@growi/core/dist/models';
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
-import {
+import type {
   Request, Response, NextFunction,
 } from 'express';
-import { param, validationResult, ValidationError } from 'express-validator';
+import type { ValidationError } from 'express-validator';
+import { param, validationResult } from 'express-validator';
 
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
-import { Attachment } from '../models';
+import { Attachment } from '../models/attachment';
 import { convertStreamToBuffer } from '../util/stream';
 
 const logger = loggerFactory('growi:routes:ogp');

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

@@ -3,7 +3,7 @@ import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
 
-import { GlobalNotificationSettingEvent } from '../models';
+import { GlobalNotificationSettingEvent } from '../models/GlobalNotificationSetting';
 import { PathAlreadyExistsError } from '../models/errors';
 import PageTagRelation from '../models/page-tag-relation';
 import UpdatePost from '../models/update-post';

+ 1 - 1
apps/app/src/server/service/attachment.js

@@ -1,7 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
 import { AttachmentType } from '../interfaces/attachment';
-import { Attachment } from '../models';
+import { Attachment } from '../models/attachment';
 
 const fs = require('fs');
 

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

@@ -15,7 +15,7 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import urljoin from 'url-join';
 
 import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
-import type { IAttachmentDocument } from '~/server/models';
+import type { IAttachmentDocument } from '~/server/models/attachment';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';

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

@@ -19,7 +19,7 @@ import {
 } from '@azure/storage-blob';
 
 import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
-import type { IAttachmentDocument } from '~/server/models';
+import type { IAttachmentDocument } from '~/server/models/attachment';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';

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

@@ -5,7 +5,7 @@ import type { Response } from 'express';
 
 import type { ICheckLimitResult } from '~/interfaces/attachment';
 import { type RespondOptions, ResponseMode } from '~/server/interfaces/attachment';
-import { Attachment, type IAttachmentDocument } from '~/server/models';
+import { Attachment, type IAttachmentDocument } from '~/server/models/attachment';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';

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

@@ -5,7 +5,7 @@ import urljoin from 'url-join';
 
 import type Crowi from '~/server/crowi';
 import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
-import type { IAttachmentDocument } from '~/server/models';
+import type { IAttachmentDocument } from '~/server/models/attachment';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';

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

@@ -6,7 +6,7 @@ import mongoose from 'mongoose';
 import { createModel } from 'mongoose-gridfs';
 
 import type { RespondOptions } from '~/server/interfaces/attachment';
-import type { IAttachmentDocument } from '~/server/models';
+import type { IAttachmentDocument } from '~/server/models/attachment';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';

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

@@ -4,7 +4,7 @@ import { Readable } from 'stream';
 import type { Response } from 'express';
 
 import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
-import type { IAttachmentDocument } from '~/server/models';
+import type { IAttachmentDocument } from '~/server/models/attachment';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';

+ 1 - 1
apps/app/src/server/service/file-uploader/utils/headers.ts

@@ -1,7 +1,7 @@
 import type { Response } from 'express';
 
 import type { ExpressHttpHeader, IContentHeaders } from '~/server/interfaces/attachment';
-import type { IAttachmentDocument } from '~/server/models';
+import type { IAttachmentDocument } from '~/server/models/attachment';
 
 
 export class ContentHeaders implements IContentHeaders {

+ 6 - 3
apps/app/src/server/service/g2g-transfer.ts

@@ -4,9 +4,10 @@ import { basename } from 'path';
 import type { Readable } from 'stream';
 
 // eslint-disable-next-line no-restricted-imports
+import type { IUser } from '@growi/core';
 import rawAxios, { type AxiosRequestConfig } from 'axios';
 import FormData from 'form-data';
-import { Types as MongooseTypes } from 'mongoose';
+import mongoose, { Types as MongooseTypes } from 'mongoose';
 
 import { G2G_PROGRESS_STATUS } from '~/interfaces/g2g-transfer';
 import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
@@ -19,7 +20,7 @@ import loggerFactory from '~/utils/logger';
 import { TransferKey } from '~/utils/vo/transfer-key';
 
 import type Crowi from '../crowi';
-import { Attachment } from '../models';
+import { Attachment } from '../models/attachment';
 import { G2GTransferError, G2GTransferErrorCode } from '../models/vo/g2g-transfer-error';
 
 import { configManager } from './config-manager';
@@ -257,7 +258,9 @@ export class G2GTransferPusherService implements Pusher {
       };
     }
 
-    const activeUserCount = await this.crowi.model('User').countActiveUsers();
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const User = mongoose.model<IUser, any>('User');
+    const activeUserCount = await User.countActiveUsers();
     if ((destGROWIInfo.userUpperLimit ?? Infinity) < activeUserCount) {
       return {
         canTransfer: false,

+ 1 - 1
apps/app/src/server/service/global-notification/global-notification-mail.js

@@ -1,6 +1,6 @@
 import nodePath from 'path';
 
-import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models';
+import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models/GlobalNotificationSetting';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/server/service/global-notification/global-notification-slack.js

@@ -1,6 +1,6 @@
 import { pagePathUtils } from '@growi/core/dist/utils';
 
-import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models';
+import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models/GlobalNotificationSetting';
 import loggerFactory from '~/utils/logger';
 
 import {

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

+ 1 - 1
apps/app/src/server/service/pre-notify.ts

@@ -2,7 +2,7 @@ import type {
   IPage, IUser, Ref,
 } from '@growi/core';
 
-import { ActivityDocument } from '../models/activity';
+import type { ActivityDocument } from '../models/activity';
 import Subscription from '../models/subscription';
 import { getModelSafely } from '../util/mongoose-utils';
 

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

+ 6 - 12
apps/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -2,14 +2,14 @@ import type { IPage } from '@growi/core';
 import mongoose from 'mongoose';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import { ISearchResult } from '~/interfaces/search';
-import { PageModel, PageDocument, PageQueryBuilder } from '~/server/models/page';
+import type { ISearchResult } from '~/interfaces/search';
+import type { PageModel, PageDocument, PageQueryBuilder } from '~/server/models/page';
+import { serializePageSecurely } from '~/server/models/serializers';
 
-import {
+import type {
   QueryTerms, MongoTermsKey,
   SearchableData, SearchDelegator, UnavailableTermsKey, MongoQueryTerms,
 } from '../../interfaces/search';
-import { serializeUserSecurely } from '../../models/serializers/user-serializer';
 
 
 const AVAILABLE_KEYS = ['match', 'not_match', 'prefix', 'not_prefix'];
@@ -47,21 +47,15 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKe
 
     const total = await countQueryBuilder.query.count();
 
-    const _pages: PageDocument[] = await findQueryBuilder
+    const pages: PageDocument[] = await findQueryBuilder
       .addConditionToPagenate(offset, limit)
       .query
       .populate('creator')
       .populate('lastUpdateUser')
       .exec();
 
-    const pages = _pages.map((page) => {
-      page.creator = serializeUserSecurely(page.creator);
-      page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
-      return page;
-    });
-
     return {
-      data: pages,
+      data: pages.map(page => serializePageSecurely(page)),
       meta: {
         total,
         hitsCount: pages.length,

+ 1 - 1
apps/app/src/server/service/search.ts

@@ -1,4 +1,5 @@
 import type { IPageHasId } from '@growi/core';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import mongoose from 'mongoose';
 import { FilterXSS } from 'xss';
 
@@ -13,7 +14,6 @@ import type {
 } from '../interfaces/search';
 import NamedQuery from '../models/named-query';
 import type { PageModel } from '../models/page';
-import { serializeUserSecurely } from '../models/serializers/user-serializer';
 import { SearchError } from '../models/vo/search-error';
 import { hasIntersection } from '../util/compare-objectId';
 

+ 1 - 1
apps/app/src/server/service/slack-command-handler/keep.js

@@ -4,11 +4,11 @@ import {
 import { format } from 'date-fns/format';
 import { parse } from 'date-fns/parse';
 
+import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:SlackBotService:keep');
 
-const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
 
 module.exports = (crowi) => {
   const CreatePageService = require('./create-page-service');

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini