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

Merge remote-tracking branch 'origin/master' into dev/7.4.x

Yuki Takei пре 4 месеци
родитељ
комит
84a5714f30
52 измењених фајлова са 2150 додато и 759 уклоњено
  1. 3 1
      .github/workflows/release-pdf-converter.yml
  2. 16 1
      CHANGELOG.md
  3. 3 0
      apps/app/.eslintrc.js
  4. 2 2
      apps/app/package.json
  5. 12 1
      apps/app/public/static/locales/en_US/translation.json
  6. 12 1
      apps/app/public/static/locales/fr_FR/translation.json
  7. 12 1
      apps/app/public/static/locales/ja_JP/translation.json
  8. 12 1
      apps/app/public/static/locales/ko_KR/translation.json
  9. 12 1
      apps/app/public/static/locales/zh_CN/translation.json
  10. 3 0
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  11. 0 8
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss
  12. 6 1
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx
  13. 0 3
      apps/app/src/client/components/PageSideContents/PageSideContents.module.scss
  14. 1 3
      apps/app/src/client/components/PageSideContents/PageSideContents.tsx
  15. 81 0
      apps/app/src/client/components/RecentActivity/ActivityListItem.tsx
  16. 83 0
      apps/app/src/client/components/RecentActivity/RecentActivity.tsx
  17. 9 0
      apps/app/src/client/components/UsersHomepageFooter.tsx
  18. 28 0
      apps/app/src/client/services/use-print-mode.ts
  19. 4 6
      apps/app/src/components/Common/PagePathNavTitle/PagePathNavTitle.tsx
  20. 2 3
      apps/app/src/components/PageView/PageContentFooter.tsx
  21. 6 2
      apps/app/src/components/PageView/PageViewLayout.tsx
  22. 32 2
      apps/app/src/interfaces/activity.ts
  23. 75 44
      apps/app/src/server/routes/admin.js
  24. 1 0
      apps/app/src/server/routes/apiv3/index.js
  25. 300 0
      apps/app/src/server/routes/apiv3/user-activities.ts
  26. 34 19
      apps/app/src/server/routes/attachment/api.js
  27. 14 12
      apps/app/src/server/routes/attachment/download.ts
  28. 26 19
      apps/app/src/server/routes/attachment/get-brand-logo.ts
  29. 77 50
      apps/app/src/server/routes/attachment/get.ts
  30. 5 2
      apps/app/src/server/routes/attachment/image-content-type-validator.spec.ts
  31. 8 3
      apps/app/src/server/routes/attachment/image-content-type-validator.ts
  32. 1 1
      apps/app/src/server/routes/attachment/index.ts
  33. 1 3
      apps/app/src/server/routes/avoid-session-routes.js
  34. 80 42
      apps/app/src/server/routes/comment.js
  35. 39 19
      apps/app/src/server/routes/forgot-password.ts
  36. 342 75
      apps/app/src/server/routes/index.js
  37. 256 106
      apps/app/src/server/routes/login-passport.js
  38. 54 34
      apps/app/src/server/routes/login.js
  39. 10 10
      apps/app/src/server/routes/next.ts
  40. 53 42
      apps/app/src/server/routes/ogp.ts
  41. 119 48
      apps/app/src/server/routes/page.js
  42. 48 22
      apps/app/src/server/routes/search.ts
  43. 27 17
      apps/app/src/server/routes/tag.js
  44. 11 6
      apps/app/src/server/routes/user-activation.ts
  45. 3 5
      apps/app/src/server/routes/user.js
  46. 39 0
      apps/app/src/server/util/locale-utils.ts
  47. 32 0
      apps/app/src/stores/recent-activity.ts
  48. 5 0
      apps/app/src/styles/_layout.scss
  49. 1 1
      apps/pdf-converter/package.json
  50. 1 1
      apps/slackbot-proxy/package.json
  51. 2 1
      biome.json
  52. 147 140
      pnpm-lock.yaml

+ 3 - 1
.github/workflows/release-pdf-converter.yml

@@ -28,7 +28,9 @@ jobs:
         images: growilabs/pdf-converter
         images: growilabs/pdf-converter
         tags: |
         tags: |
           type=raw,value=latest
           type=raw,value=latest
-          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}.{{minor}}.{{patch}}
 
 
     - name: Login to docker.io registry
     - name: Login to docker.io registry
       run: |
       run: |

+ 16 - 1
CHANGELOG.md

@@ -1,9 +1,24 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/growilabs/compare/v7.3.4...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.3.5...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v7.3.5](https://github.com/growilabs/compare/v7.3.4...v7.3.5) - 2025-11-10
+
+### 💎 Features
+
+* feat: Activity Log on the user page for viewing recent activity (#10487) @arvid-e
+
+### 🐛 Bug Fixes
+
+* fix: PDF-converter major/minor tags not updated on release (#10476) @arafubeatbox
+
+### 🧰 Maintenance
+
+* support: Configure biome for app/src/server/models dir (#10419) @arafubeatbox
+* support: Playwright tests biome migration (#10248) @arafubeatbox
+
 ## [v7.3.4](https://github.com/growilabs/compare/v7.3.3...v7.3.4) - 2025-11-04
 ## [v7.3.4](https://github.com/growilabs/compare/v7.3.3...v7.3.4) - 2025-11-04
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 3 - 0
apps/app/.eslintrc.js

@@ -61,6 +61,9 @@ module.exports = {
     'src/server/app.ts',
     'src/server/app.ts',
     'src/server/repl.ts',
     'src/server/repl.ts',
     'src/server/middlewares/**',
     'src/server/middlewares/**',
+    'src/server/routes/*.js',
+    'src/server/routes/*.ts',
+    'src/server/routes/attachment/**',
   ],
   ],
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript

+ 2 - 2
apps/app/package.json

@@ -28,7 +28,7 @@
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
     "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
-    "lint:biome": "biome check",
+    "lint:biome": "biome check --diagnostic-level=error",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
@@ -150,7 +150,7 @@
     "jotai": "^2.12.3",
     "jotai": "^2.12.3",
     "js-cookie": "^3.0.5",
     "js-cookie": "^3.0.5",
     "js-tiktoken": "^1.0.15",
     "js-tiktoken": "^1.0.15",
-    "js-yaml": "^4.1.0",
+    "js-yaml": "^4.1.1",
     "jsonrepair": "^3.12.0",
     "jsonrepair": "^3.12.0",
     "katex": "^0.16.21",
     "katex": "^0.16.21",
     "ldapjs": "^3.0.2",
     "ldapjs": "^3.0.2",

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

@@ -999,7 +999,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "Bookmarks",
     "bookmarks": "Bookmarks",
-    "recently_created": "Recently Created"
+    "recently_created": "Recently Created",
+    "recent_activity": "Recent Activity",
+    "unknown_action": "made an unspecified change",
+    "page_create": "created a page",
+    "page_update": "updated a page",
+    "page_delete": "deleted a page",
+    "page_delete_completely": "deleted a page",
+    "page_rename": "renamed a page",
+    "page_revert": "reverted a page",
+    "page_like": "liked a page",
+    "page_duplicate": "duplicated a page",
+    "comment_create": "posted a comment"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "bookmark folder",
     "bookmark_folder": "bookmark folder",

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

@@ -993,7 +993,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "Favoris",
     "bookmarks": "Favoris",
-    "recently_created": "Page récentes"
+    "recently_created": "Page récentes",
+    "recent_activity": "Activité récente",
+    "unknown_action": "a effectué une modification non spécifiée",
+    "page_create": "a créé une page",
+    "page_update": "a mis à jour une page",
+    "page_delete": "a supprimé une page",
+    "page_delete_completely": "a supprimé complètement une page",
+    "page_rename": "a renommé une page",
+    "page_revert": "a restauré une page",
+    "page_duplicate": "a dupliqué une page",
+    "page_like": "a aimé une page",
+    "comment_create": "a publié un commentaire"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "dossier de favoris",
     "bookmark_folder": "dossier de favoris",

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

@@ -1032,7 +1032,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "ブックマーク",
     "bookmarks": "ブックマーク",
-    "recently_created": "最近作成したページ"
+    "recently_created": "最近作成したページ",
+    "recent_activity": "最近のアクティビティ",
+    "unknown_action": "未指定の変更を加えました",
+    "page_create": "ページを作成しました",
+    "page_update": "ページを更新しました",
+    "page_delete": "ページを削除しました",
+    "page_delete_completely": "ページを完全に削除しました",
+    "page_rename": "ページの名前を変更しました",
+    "page_revert": "ページを元に戻しました",
+    "page_duplicate": "ページを複製しました",
+    "page_like": "ページをいいねしました",
+    "comment_create": "コメントを投稿しました"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "ブックマークフォルダ",
     "bookmark_folder": "ブックマークフォルダ",

+ 12 - 1
apps/app/public/static/locales/ko_KR/translation.json

@@ -959,7 +959,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "북마크",
     "bookmarks": "북마크",
-    "recently_created": "최근 생성됨"
+    "recently_created": "최근 생성됨",
+    "recent_activity": "최근 활동",
+    "unknown_action": "지정되지 않은 변경 사항을 적용했습니다",
+    "page_create": "페이지를 생성했습니다",
+    "page_update": "페이지를 업데이트했습니다",
+    "page_delete": "페이지를 삭제했습니다",
+    "page_delete_completely": "페이지를 완전히 삭제했습니다",
+    "page_rename": "페이지 이름을 변경했습니다",
+    "page_revert": "페이지를 되돌렸습니다",
+    "page_duplicate": "페이지를 복제했습니다",
+    "page_like": "페이지에 좋아요를 눌렀습니다",
+    "comment_create": "댓글을 게시했습니다"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "북마크 폴더",
     "bookmark_folder": "북마크 폴더",

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

@@ -1004,7 +1004,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "书签",
     "bookmarks": "书签",
-    "recently_created": "最近创建页面"
+    "recently_created": "最近创建页面",
+    "recent_activity": "最近动态",
+    "unknown_action": "进行了未指明的更改",
+    "page_create": "创建了页面",
+    "page_update": "更新了页面",
+    "page_delete": "删除了页面",
+    "page_delete_completely": "彻底删除了页面",
+    "page_rename": "重命名了页面",
+    "page_revert": "还原了页面",
+    "page_duplicate": "复制了页面",
+    "page_like": "赞了页面",
+    "comment_create": "发布了评论"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "书签文件夹",
     "bookmark_folder": "书签文件夹",

+ 3 - 0
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -20,6 +20,7 @@ import Sticky from 'react-stickynode';
 import { DropdownItem, UncontrolledTooltip, Tooltip } from 'reactstrap';
 import { DropdownItem, UncontrolledTooltip, Tooltip } from 'reactstrap';
 
 
 import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
 import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
+import { usePrintMode } from '~/client/services/use-print-mode';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { usePageBulkExportSelectModalActions } from '~/features/page-bulk-export/client/states/modal';
 import { usePageBulkExportSelectModalActions } from '~/features/page-bulk-export/client/states/modal';
@@ -257,6 +258,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const router = useRouter();
   const router = useRouter();
+  const isPrinting = usePrintMode();
 
 
   const shareLinkId = useShareLinkId();
   const shareLinkId = useShareLinkId();
   const { fetchCurrentPage } = useFetchCurrentPage();
   const { fetchCurrentPage } = useFetchCurrentPage();
@@ -389,6 +391,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
 
       <Sticky
       <Sticky
         className="z-1"
         className="z-1"
+        enabled={!isPrinting}
         onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}
         onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}
         innerActiveClass="w-100 end-0"
         innerActiveClass="w-100 end-0"
       >
       >

+ 0 - 8
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss

@@ -13,11 +13,3 @@
     }
     }
   }
   }
 }
 }
-
-@media print {
-  .grw-page-path-nav-sticky :global {
-    .sticky-inner-wrapper {
-      position: static !important;
-    }
-  }
-}

+ 6 - 1
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx

@@ -6,6 +6,7 @@ import { DevidedPagePath } from '@growi/core/dist/models';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import Sticky from 'react-stickynode';
 import Sticky from 'react-stickynode';
 
 
+import { usePrintMode } from '~/client/services/use-print-mode';
 import LinkedPagePath from '~/models/linked-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 import { usePageControlsX } from '~/states/ui/page';
 import { usePageControlsX } from '~/states/ui/page';
 import { useSidebarMode, useCurrentProductNavWidth } from '~/states/ui/sidebar';
 import { useSidebarMode, useCurrentProductNavWidth } from '~/states/ui/sidebar';
@@ -26,6 +27,10 @@ const { isTrashPage } = pagePathUtils;
 export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element => {
 export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element => {
   const { pagePath } = props;
   const { pagePath } = props;
 
 
+  const isPrinting = usePrintMode();
+
+  const isPrinting = usePrintMode();
+
   const pageControlsX = usePageControlsX();
   const pageControlsX = usePageControlsX();
   const [sidebarWidth] = useCurrentProductNavWidth();
   const [sidebarWidth] = useCurrentProductNavWidth();
   const { sidebarMode } = useSidebarMode();
   const { sidebarMode } = useSidebarMode();
@@ -81,7 +86,7 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
     // Controlling pointer-events
     // Controlling pointer-events
     //  1. disable pointer-events with 'pe-none'
     //  1. disable pointer-events with 'pe-none'
     <div ref={pagePathNavRef}>
     <div ref={pagePathNavRef}>
-      <Sticky className={`${moduleClass} mb-4`} innerClass="pe-none" innerActiveClass="active mt-1">
+      <Sticky className={moduleClass} enabled={!isPrinting} innerClass="pe-none" innerActiveClass="active mt-1">
         {({ status }) => {
         {({ status }) => {
           const isParentsCollapsed = status === Sticky.STATUS_FIXED;
           const isParentsCollapsed = status === Sticky.STATUS_FIXED;
 
 

+ 0 - 3
apps/app/src/client/components/PageSideContents/PageSideContents.module.scss

@@ -1,3 +0,0 @@
-/* stylelint-disable-next-line block-no-empty */
-.grw-page-accessories-controls :global {
-}

+ 1 - 3
apps/app/src/client/components/PageSideContents/PageSideContents.tsx

@@ -25,8 +25,6 @@ import TableOfContents from '../TableOfContents';
 
 
 import { PageAccessoriesControl } from './PageAccessoriesControl';
 import { PageAccessoriesControl } from './PageAccessoriesControl';
 
 
-import styles from './PageSideContents.module.scss';
-
 
 
 const { isTopPage, isUsersHomepage, isTrashPage } = pagePathUtils;
 const { isTopPage, isUsersHomepage, isTrashPage } = pagePathUtils;
 
 
@@ -123,7 +121,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
         </div>
         </div>
       )}
       )}
 
 
-      <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
+      <div className=" d-flex flex-column gap-2">
         {/* Page list */}
         {/* Page list */}
         {!isSharedUser && (
         {!isSharedUser && (
           <div className="d-flex" data-testid="pageListButton">
           <div className="d-flex" data-testid="pageListButton">

+ 81 - 0
apps/app/src/client/components/RecentActivity/ActivityListItem.tsx

@@ -0,0 +1,81 @@
+import { formatDistanceToNow } from 'date-fns';
+import { useTranslation } from 'next-i18next';
+import { type Locale } from 'date-fns/locale';
+import { getLocale } from '~/server/util/locale-utils';
+import type { ActivityHasUserId, SupportedActivityActionType } from '~/interfaces/activity';
+import { ActivityLogActions } from '~/interfaces/activity';
+
+
+export const ActivityActionTranslationMap: Record<
+  SupportedActivityActionType,
+  string
+> = {
+  [ActivityLogActions.ACTION_PAGE_CREATE]: 'page_create',
+  [ActivityLogActions.ACTION_PAGE_UPDATE]: 'page_update',
+  [ActivityLogActions.ACTION_PAGE_DELETE]: 'page_delete',
+  [ActivityLogActions.ACTION_PAGE_DELETE_COMPLETELY]: 'page_delete_completely',
+  [ActivityLogActions.ACTION_PAGE_RENAME]: 'page_rename',
+  [ActivityLogActions.ACTION_PAGE_REVERT]: 'page_revert',
+  [ActivityLogActions.ACTION_PAGE_DUPLICATE]: 'page_duplicate',
+  [ActivityLogActions.ACTION_PAGE_LIKE]: 'page_like',
+  [ActivityLogActions.ACTION_COMMENT_CREATE]: 'comment_create',
+};
+
+export const IconActivityTranslationMap: Record<
+  SupportedActivityActionType,
+  string
+> = {
+  [ActivityLogActions.ACTION_PAGE_CREATE]: 'add_box',
+  [ActivityLogActions.ACTION_PAGE_UPDATE]: 'edit',
+  [ActivityLogActions.ACTION_PAGE_DELETE]: 'delete',
+  [ActivityLogActions.ACTION_PAGE_DELETE_COMPLETELY]: 'delete_forever',
+  [ActivityLogActions.ACTION_PAGE_RENAME]: 'label',
+  [ActivityLogActions.ACTION_PAGE_REVERT]: 'undo',
+  [ActivityLogActions.ACTION_PAGE_DUPLICATE]: 'content_copy',
+  [ActivityLogActions.ACTION_PAGE_LIKE]: 'favorite',
+  [ActivityLogActions.ACTION_COMMENT_CREATE]: 'comment',
+};
+
+const translateAction = (action: SupportedActivityActionType): string => {
+  return ActivityActionTranslationMap[action] || 'unknown_action';
+};
+
+const setIcon = (action: SupportedActivityActionType): string => {
+  return IconActivityTranslationMap[action] || 'question_mark';
+};
+
+const calculateTimePassed = (date: Date, locale: Locale): string => {
+  const timePassed = formatDistanceToNow(date, {
+    addSuffix: true,
+    locale,
+  });
+
+  return timePassed;
+};
+
+
+export const ActivityListItem = ({ activity }: { activity: ActivityHasUserId }): JSX.Element => {
+  const { t, i18n } = useTranslation();
+  const currentLangCode = i18n.language;
+  const dateFnsLocale = getLocale(currentLangCode);
+
+  const action = activity.action as SupportedActivityActionType;
+  const keyToTranslate = translateAction(action);
+  const fullKeyPath = `user_home_page.${keyToTranslate}`;
+
+  return (
+    <div className="activity-row">
+      <p className="mb-1">
+        <span className="material-symbols-outlined me-2">{setIcon(action)}</span>
+
+        <span className="dark:text-white">
+          {' '}{t(fullKeyPath)}
+        </span>
+
+        <span className="text-secondary small ms-3">
+          {calculateTimePassed(activity.createdAt, dateFnsLocale)}
+        </span>
+      </p>
+    </div>
+  );
+};

+ 83 - 0
apps/app/src/client/components/RecentActivity/RecentActivity.tsx

@@ -0,0 +1,83 @@
+import React, {
+  useState, useCallback, useEffect, type JSX,
+} from 'react';
+
+import { toastError } from '~/client/util/toastr';
+import type { IActivityHasId, ActivityHasUserId } from '~/interfaces/activity';
+import { useSWRxRecentActivity } from '~/stores/recent-activity';
+import loggerFactory from '~/utils/logger';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import { ActivityListItem } from './ActivityListItem';
+
+
+const logger = loggerFactory('growi:RecentActivity');
+
+type RecentActivityProps = {
+  userId: string,
+}
+
+const hasUser = (activity: IActivityHasId): activity is ActivityHasUserId => {
+  return activity.user != null
+        && typeof activity.user === 'object';
+};
+
+export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
+  const { userId } = props;
+
+  const [activities, setActivities] = useState<ActivityHasUserId[]>([]);
+  const [activePage, setActivePage] = useState(1);
+  const [limit] = useState(10);
+  const [offset, setOffset] = useState(0);
+
+  const { data: paginatedData, error } = useSWRxRecentActivity(limit, offset, userId);
+
+  const handlePage = useCallback(async(selectedPage: number) => {
+    const newOffset = (selectedPage - 1) * limit;
+
+    setOffset(newOffset);
+    setActivePage(selectedPage);
+  }, [limit]);
+
+  useEffect(() => {
+    if (error) {
+      logger.error('Failed to fetch recent activity data', error);
+      toastError(error);
+      return;
+    }
+
+    if (paginatedData) {
+      const activitiesWithPages = paginatedData.docs
+        .filter(hasUser);
+
+      setActivities(activitiesWithPages);
+    }
+  }, [paginatedData, error]);
+
+  const totalItemsCount = paginatedData?.totalDocs || 0;
+  const needsPagination = totalItemsCount > limit;
+
+  return (
+    <div className="page-list-container-activity">
+      <ul className="page-list-ul page-list-ul-flat mb-3">
+        {activities.map(activity => (
+          <li key={`recent-activity-view:${activity._id}`} className="mt-4">
+            <ActivityListItem activity={activity} />
+          </li>
+        ))}
+      </ul>
+
+      {needsPagination && (
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={handlePage}
+          totalItemsCount={totalItemsCount}
+          pagingLimit={limit}
+          align="center"
+          size="sm"
+        />
+      )}
+    </div>
+  );
+};

+ 9 - 0
apps/app/src/client/components/UsersHomepageFooter.tsx

@@ -2,6 +2,7 @@ import React, { useState, type JSX } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
+import { RecentActivity } from '~/client/components/RecentActivity/RecentActivity';
 import { RecentCreated } from '~/client/components/RecentCreated/RecentCreated';
 import { RecentCreated } from '~/client/components/RecentCreated/RecentCreated';
 import { useCurrentUser } from '~/states/global';
 import { useCurrentUser } from '~/states/global';
 
 
@@ -45,6 +46,14 @@ export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Elemen
         <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
         <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
           <RecentCreated userId={creatorId} />
           <RecentCreated userId={creatorId} />
         </div>
         </div>
+
+        <h2 id="user-created-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
+          <span className="growi-custom-icons me-1">recently_created</span>
+          {t('user_home_page.recent_activity')}
+        </h2>
+        <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
+          <RecentActivity userId={creatorId} />
+        </div>
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 28 - 0
apps/app/src/client/services/use-print-mode.ts

@@ -0,0 +1,28 @@
+import { useEffect, useState } from 'react';
+
+import { flushSync } from 'react-dom';
+
+export const usePrintMode = (): boolean => {
+  const [isPrinting, setIsPrinting] = useState(false);
+
+  useEffect(() => {
+    // force re-render on beforeprint
+    const handleBeforePrint = () => flushSync(() => {
+      setIsPrinting(true);
+    });
+
+    const handleAfterPrint = () => {
+      setIsPrinting(false);
+    };
+
+    window.addEventListener('beforeprint', handleBeforePrint);
+    window.addEventListener('afterprint', handleAfterPrint);
+
+    return () => {
+      window.removeEventListener('beforeprint', handleBeforePrint);
+      window.removeEventListener('afterprint', handleAfterPrint);
+    };
+  }, []);
+
+  return isPrinting;
+};

+ 4 - 6
apps/app/src/components/Common/PagePathNavTitle/PagePathNavTitle.tsx

@@ -42,17 +42,15 @@ export const PagePathNavTitle = (
     setClient(true);
     setClient(true);
   }, []);
   }, []);
 
 
+  const className = `${moduleClass} mb-4`;
+
   return isClient ? (
   return isClient ? (
     <PagePathNavSticky
     <PagePathNavSticky
       {...props}
       {...props}
-      className={moduleClass}
+      className={className}
       latterLinkClassName="fs-2"
       latterLinkClassName="fs-2"
     />
     />
   ) : (
   ) : (
-    <PagePathNav
-      {...props}
-      className={moduleClass}
-      latterLinkClassName="fs-2"
-    />
+    <PagePathNav {...props} className={className} latterLinkClassName="fs-2" />
   );
   );
 };
 };

+ 2 - 3
apps/app/src/components/PageView/PageContentFooter.tsx

@@ -22,13 +22,12 @@ export const PageContentFooter = (
   const { creator, lastUpdateUser, createdAt, updatedAt } = page;
   const { creator, lastUpdateUser, createdAt, updatedAt } = page;
 
 
   if (page.isEmpty) {
   if (page.isEmpty) {
+    // biome-ignore lint/complexity/noUselessFragments: ignore
     return <></>;
     return <></>;
   }
   }
 
 
   return (
   return (
-    <div
-      className={`${styles['page-content-footer']} my-4 pt-4 d-edit-none d-print-none}`}
-    >
+    <div className={`${styles['page-content-footer']} my-4 pt-4 d-edit-none`}>
       <div className="page-meta">
       <div className="page-meta">
         <AuthorInfo
         <AuthorInfo
           user={creator}
           user={creator}

+ 6 - 2
apps/app/src/components/PageView/PageViewLayout.tsx

@@ -1,5 +1,7 @@
 import type { JSX, ReactNode } from 'react';
 import type { JSX, ReactNode } from 'react';
 
 
+import { usePrintMode } from '~/client/services/use-print-mode';
+
 import styles from './PageViewLayout.module.scss';
 import styles from './PageViewLayout.module.scss';
 
 
 const pageViewLayoutClass = styles['page-view-layout'] ?? '';
 const pageViewLayoutClass = styles['page-view-layout'] ?? '';
@@ -24,6 +26,8 @@ export const PageViewLayout = (props: Props): JSX.Element => {
     expandContentWidth,
     expandContentWidth,
   } = props;
   } = props;
 
 
+  const isPrinting = usePrintMode();
+
   const fluidLayoutClass = expandContentWidth ? _fluidLayoutClass : '';
   const fluidLayoutClass = expandContentWidth ? _fluidLayoutClass : '';
 
 
   return (
   return (
@@ -33,13 +37,13 @@ export const PageViewLayout = (props: Props): JSX.Element => {
       >
       >
         <div className="container-lg wide-gutter-x-lg grw-container-convertible flex-expand-vert">
         <div className="container-lg wide-gutter-x-lg grw-container-convertible flex-expand-vert">
           {headerContents != null && headerContents}
           {headerContents != null && headerContents}
-          {sideContents != null ? (
+          {!isPrinting && sideContents != null ? (
             <div className="flex-expand-horiz gap-3 z-0">
             <div className="flex-expand-horiz gap-3 z-0">
               <div className="flex-expand-vert flex-basis-0 mw-0">
               <div className="flex-expand-vert flex-basis-0 mw-0">
                 {children}
                 {children}
               </div>
               </div>
               <div
               <div
-                className="grw-side-contents-container col-lg-3  d-edit-none d-print-none"
+                className="grw-side-contents-container col-lg-3 d-edit-none"
                 data-vrt-blackout-side-contents
                 data-vrt-blackout-side-contents
               >
               >
                 <div className="grw-side-contents-sticky-container">
                 <div className="grw-side-contents-sticky-container">

+ 32 - 2
apps/app/src/interfaces/activity.ts

@@ -1,4 +1,12 @@
-import type { HasObjectId, IUser, Ref } from '@growi/core';
+import type {
+  HasObjectId,
+  IPageHasId,
+  IUser,
+  IUserHasId,
+  Ref,
+} from '@growi/core';
+
+import type { PaginateResult } from './mongoose-utils';
 
 
 // Model
 // Model
 const MODEL_PAGE = 'Page';
 const MODEL_PAGE = 'Page';
@@ -377,6 +385,7 @@ export const SupportedAction = {
 
 
 // Action required for notification
 // Action required for notification
 export const EssentialActionGroup = {
 export const EssentialActionGroup = {
+  ACTION_PAGE_CREATE,
   ACTION_PAGE_LIKE,
   ACTION_PAGE_LIKE,
   ACTION_PAGE_BOOKMARK,
   ACTION_PAGE_BOOKMARK,
   ACTION_PAGE_UPDATE,
   ACTION_PAGE_UPDATE,
@@ -568,6 +577,18 @@ export const LargeActionGroup = {
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
 } as const;
 } as const;
 
 
+export const ActivityLogActions = {
+  ACTION_PAGE_CREATE,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_REVERT,
+  ACTION_PAGE_LIKE,
+  ACTION_COMMENT_CREATE,
+} as const;
+
 /*
 /*
  * Array
  * Array
  */
  */
@@ -645,7 +666,8 @@ export type SupportedActionType =
   (typeof SupportedAction)[keyof typeof SupportedAction];
   (typeof SupportedAction)[keyof typeof SupportedAction];
 export type SupportedActionCategoryType =
 export type SupportedActionCategoryType =
   (typeof SupportedActionCategory)[keyof typeof SupportedActionCategory];
   (typeof SupportedActionCategory)[keyof typeof SupportedActionCategory];
-
+export type SupportedActivityActionType =
+  (typeof ActivityLogActions)[keyof typeof ActivityLogActions];
 export type ISnapshot = Partial<Pick<IUser, 'username'>>;
 export type ISnapshot = Partial<Pick<IUser, 'username'>>;
 
 
 export type IActivity = {
 export type IActivity = {
@@ -661,6 +683,10 @@ export type IActivity = {
   snapshot?: ISnapshot;
   snapshot?: ISnapshot;
 };
 };
 
 
+export type ActivityHasUserId = IActivityHasId & {
+  user: IUserHasId;
+};
+
 export type IActivityHasId = IActivity & HasObjectId;
 export type IActivityHasId = IActivity & HasObjectId;
 
 
 export type ISearchFilter = {
 export type ISearchFilter = {
@@ -668,3 +694,7 @@ export type ISearchFilter = {
   dates?: { startDate: string | null; endDate: string | null };
   dates?: { startDate: string | null; endDate: string | null };
   actions?: SupportedActionType[];
   actions?: SupportedActionType[];
 };
 };
+
+export interface UserActivitiesResult {
+  serializedPaginationResult: PaginateResult<IActivityHasId>;
+}

+ 75 - 44
apps/app/src/server/routes/admin.js

@@ -8,7 +8,7 @@ const logger = loggerFactory('growi:routes:admin');
 
 
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
-module.exports = function(crowi, app) {
+module.exports = (crowi, app) => {
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const importer = require('../util/importer')(crowi);
   const importer = require('../util/importer')(crowi);
 
 
@@ -20,40 +20,52 @@ module.exports = function(crowi, app) {
 
 
   const api = {};
   const api = {};
 
 
-
   // Importer management
   // Importer management
   actions.importer = {};
   actions.importer = {};
   actions.importer.api = api;
   actions.importer.api = api;
   api.validators = {};
   api.validators = {};
   api.validators.importer = {};
   api.validators.importer = {};
 
 
-  api.validators.importer.esa = function() {
+  api.validators.importer.esa = () => {
     const validator = [
     const validator = [
-      check('importer:esa:team_name').not().isEmpty().withMessage('Error. Empty esa:team_name'),
-      check('importer:esa:access_token').not().isEmpty().withMessage('Error. Empty esa:access_token'),
+      check('importer:esa:team_name')
+        .not()
+        .isEmpty()
+        .withMessage('Error. Empty esa:team_name'),
+      check('importer:esa:access_token')
+        .not()
+        .isEmpty()
+        .withMessage('Error. Empty esa:access_token'),
     ];
     ];
     return validator;
     return validator;
   };
   };
 
 
-  api.validators.importer.qiita = function() {
+  api.validators.importer.qiita = () => {
     const validator = [
     const validator = [
-      check('importer:qiita:team_name').not().isEmpty().withMessage('Error. Empty qiita:team_name'),
-      check('importer:qiita:access_token').not().isEmpty().withMessage('Error. Empty qiita:access_token'),
+      check('importer:qiita:team_name')
+        .not()
+        .isEmpty()
+        .withMessage('Error. Empty qiita:team_name'),
+      check('importer:qiita:access_token')
+        .not()
+        .isEmpty()
+        .withMessage('Error. Empty qiita:access_token'),
     ];
     ];
     return validator;
     return validator;
   };
   };
 
 
-
   // Export management
   // Export management
   actions.export = {};
   actions.export = {};
   actions.export.api = api;
   actions.export.api = api;
   api.validators.export = {};
   api.validators.export = {};
 
 
-  api.validators.export.download = function() {
+  api.validators.export.download = () => {
     const validator = [
     const validator = [
       // https://regex101.com/r/mD4eZs/6
       // https://regex101.com/r/mD4eZs/6
       // prevent from pass traversal attack
       // prevent from pass traversal attack
-      param('fileName').not().matches(/(\.\.\/|\.\.\\)/),
+      param('fileName')
+        .not()
+        .matches(/(\.\.\/|\.\.\\)/),
     ];
     ];
     return validator;
     return validator;
   };
   };
@@ -63,13 +75,15 @@ module.exports = function(crowi, app) {
     const { validationResult } = require('express-validator');
     const { validationResult } = require('express-validator');
     const errors = validationResult(req);
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
     if (!errors.isEmpty()) {
-      return res.status(422).json({ errors: `${fileName} is invalid. Do not use path like '../'.` });
+      return res.status(422).json({
+        errors: `${fileName} is invalid. Do not use path like '../'.`,
+      });
     }
     }
 
 
     try {
     try {
       const zipFile = exportService.getFile(fileName);
       const zipFile = exportService.getFile(fileName);
       const parameters = {
       const parameters = {
-        ip:  req.ip,
+        ip: req.ip,
         endpoint: req.originalUrl,
         endpoint: req.originalUrl,
         action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_DOWNLOAD,
         action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_DOWNLOAD,
         user: req.user?._id,
         user: req.user?._id,
@@ -79,8 +93,7 @@ module.exports = function(crowi, app) {
       };
       };
       crowi.activityService.createActivity(parameters);
       crowi.activityService.createActivity(parameters);
       return res.download(zipFile);
       return res.download(zipFile);
-    }
-    catch (err) {
+    } catch (err) {
       // TODO: use ApiV3Error
       // TODO: use ApiV3Error
       logger.error(err);
       logger.error(err);
       return res.json(ApiResponse.error());
       return res.json(ApiResponse.error());
@@ -100,10 +113,14 @@ module.exports = function(crowi, app) {
    */
    */
   function isValidFormKeys(form, allowedKeys, res) {
   function isValidFormKeys(form, allowedKeys, res) {
     const receivedKeys = Object.keys(form);
     const receivedKeys = Object.keys(form);
-    const unexpectedKeys = receivedKeys.filter(key => !allowedKeys.includes(key));
+    const unexpectedKeys = receivedKeys.filter(
+      (key) => !allowedKeys.includes(key),
+    );
 
 
     if (unexpectedKeys.length > 0) {
     if (unexpectedKeys.length > 0) {
-      logger.warn('Unexpected keys were found in request body.', { unexpectedKeys });
+      logger.warn('Unexpected keys were found in request body.', {
+        unexpectedKeys,
+      });
       res.json(ApiResponse.error('Invalid config keys provided.'));
       res.json(ApiResponse.error('Invalid config keys provided.'));
       return false;
       return false;
     }
     }
@@ -117,7 +134,7 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} req
    * @param {*} res
    * @param {*} res
    */
    */
-  actions.api.importerSettingEsa = async(req, res) => {
+  actions.api.importerSettingEsa = async (req, res) => {
     const form = req.body;
     const form = req.body;
 
 
     const { validationResult } = require('express-validator');
     const { validationResult } = require('express-validator');
@@ -126,12 +143,17 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('esa.io form is blank'));
       return res.json(ApiResponse.error('esa.io form is blank'));
     }
     }
 
 
-    const ALLOWED_KEYS = ['importer:esa:team_name', 'importer:esa:access_token'];
+    const ALLOWED_KEYS = [
+      'importer:esa:team_name',
+      'importer:esa:access_token',
+    ];
     if (!isValidFormKeys(form, ALLOWED_KEYS, res)) return;
     if (!isValidFormKeys(form, ALLOWED_KEYS, res)) return;
 
 
     await configManager.updateConfigs(form);
     await configManager.updateConfigs(form);
     importer.initializeEsaClient(); // let it run in the back aftert res
     importer.initializeEsaClient(); // let it run in the back aftert res
-    const parameters = { action: SupportedAction.ACTION_ADMIN_ESA_DATA_UPDATED };
+    const parameters = {
+      action: SupportedAction.ACTION_ADMIN_ESA_DATA_UPDATED,
+    };
     activityEvent.emit('update', res.locals.activity._id, parameters);
     activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.json(ApiResponse.success());
     return res.json(ApiResponse.success());
   };
   };
@@ -142,7 +164,7 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} req
    * @param {*} res
    * @param {*} res
    */
    */
-  actions.api.importerSettingQiita = async(req, res) => {
+  actions.api.importerSettingQiita = async (req, res) => {
     const form = req.body;
     const form = req.body;
 
 
     const { validationResult } = require('express-validator');
     const { validationResult } = require('express-validator');
@@ -151,12 +173,17 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Qiita form is blank'));
       return res.json(ApiResponse.error('Qiita form is blank'));
     }
     }
 
 
-    const ALLOWED_KEYS = ['importer:qiita:team_name', 'importer:qiita:access_token'];
+    const ALLOWED_KEYS = [
+      'importer:qiita:team_name',
+      'importer:qiita:access_token',
+    ];
     if (!isValidFormKeys(form, ALLOWED_KEYS, res)) return;
     if (!isValidFormKeys(form, ALLOWED_KEYS, res)) return;
 
 
     await configManager.updateConfigs(form);
     await configManager.updateConfigs(form);
     importer.initializeQiitaClient(); // let it run in the back aftert res
     importer.initializeQiitaClient(); // let it run in the back aftert res
-    const parameters = { action: SupportedAction.ACTION_ADMIN_QIITA_DATA_UPDATED };
+    const parameters = {
+      action: SupportedAction.ACTION_ADMIN_QIITA_DATA_UPDATED,
+    };
     activityEvent.emit('update', res.locals.activity._id, parameters);
     activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.json(ApiResponse.success());
     return res.json(ApiResponse.success());
   };
   };
@@ -167,16 +194,17 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} req
    * @param {*} res
    * @param {*} res
    */
    */
-  actions.api.importDataFromEsa = async(req, res) => {
+  actions.api.importDataFromEsa = async (req, res) => {
     const user = req.user;
     const user = req.user;
     let errors;
     let errors;
 
 
     try {
     try {
       errors = await importer.importDataFromEsa(user);
       errors = await importer.importDataFromEsa(user);
-      const parameters = { action: SupportedAction.ACTION_ADMIN_ESA_DATA_IMPORTED };
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_ESA_DATA_IMPORTED,
+      };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       activityEvent.emit('update', res.locals.activity._id, parameters);
-    }
-    catch (err) {
+    } catch (err) {
       errors = [err];
       errors = [err];
     }
     }
 
 
@@ -192,16 +220,17 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} req
    * @param {*} res
    * @param {*} res
    */
    */
-  actions.api.importDataFromQiita = async(req, res) => {
+  actions.api.importDataFromQiita = async (req, res) => {
     const user = req.user;
     const user = req.user;
     let errors;
     let errors;
 
 
     try {
     try {
       errors = await importer.importDataFromQiita(user);
       errors = await importer.importDataFromQiita(user);
-      const parameters = { action: SupportedAction.ACTION_ADMIN_QIITA_DATA_IMPORTED };
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_QIITA_DATA_IMPORTED,
+      };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       activityEvent.emit('update', res.locals.activity._id, parameters);
-    }
-    catch (err) {
+    } catch (err) {
       errors = [err];
       errors = [err];
     }
     }
 
 
@@ -217,14 +246,15 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} req
    * @param {*} res
    * @param {*} res
    */
    */
-  actions.api.testEsaAPI = async(req, res) => {
+  actions.api.testEsaAPI = async (req, res) => {
     try {
     try {
       await importer.testConnectionToEsa();
       await importer.testConnectionToEsa();
-      const parameters = { action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA };
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA,
+      };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.json(ApiResponse.success());
       return res.json(ApiResponse.success());
-    }
-    catch (err) {
+    } catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
   };
   };
@@ -235,29 +265,30 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} req
    * @param {*} res
    * @param {*} res
    */
    */
-  actions.api.testQiitaAPI = async(req, res) => {
+  actions.api.testQiitaAPI = async (req, res) => {
     try {
     try {
       await importer.testConnectionToQiita();
       await importer.testConnectionToQiita();
-      const parameters = { action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA };
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA,
+      };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.json(ApiResponse.success());
       return res.json(ApiResponse.success());
-    }
-    catch (err) {
+    } catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
   };
   };
 
 
-
-  actions.api.searchBuildIndex = async function(req, res) {
+  actions.api.searchBuildIndex = async (req, res) => {
     const search = crowi.getSearcher();
     const search = crowi.getSearcher();
     if (!search) {
     if (!search) {
-      return res.json(ApiResponse.error('ElasticSearch Integration is not set up.'));
+      return res.json(
+        ApiResponse.error('ElasticSearch Integration is not set up.'),
+      );
     }
     }
 
 
     try {
     try {
       search.buildIndex();
       search.buildIndex();
-    }
-    catch (err) {
+    } catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
 
 

+ 1 - 0
apps/app/src/server/routes/apiv3/index.js

@@ -84,6 +84,7 @@ module.exports = (crowi, app) => {
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
 
 
   router.use('/personal-setting', require('./personal-setting')(crowi));
   router.use('/personal-setting', require('./personal-setting')(crowi));
+  router.use('/user-activities', require('./user-activities')(crowi));
 
 
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
   router.use('/external-user-group-relations', require('~/features/external-user-group/server/routes/apiv3/external-user-group-relation')(crowi));
   router.use('/external-user-group-relations', require('~/features/external-user-group/server/routes/apiv3/external-user-group-relation')(crowi));

+ 300 - 0
apps/app/src/server/routes/apiv3/user-activities.ts

@@ -0,0 +1,300 @@
+import type { IUserHasId } from '@growi/core';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import type { Request, Router } from 'express';
+import express from 'express';
+import { query } from 'express-validator';
+import type { PipelineStage, PaginateResult } from 'mongoose';
+import { Types } from 'mongoose';
+
+import type { IActivity } from '~/interfaces/activity';
+import { ActivityLogActions } from '~/interfaces/activity';
+import Activity from '~/server/models/activity';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+
+import type Crowi from '../../crowi';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
+import type { ApiV3Response } from './interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:activity');
+
+const validator = {
+  list: [
+    query('limit').optional().isInt({ max: 100 }).withMessage('limit must be a number less than or equal to 100')
+      .toInt(),
+    query('offset').optional().isInt().withMessage('page must be a number')
+      .toInt(),
+    query('searchFilter').optional().isString().withMessage('query must be a string'),
+    query('targetUserId').optional().isMongoId().withMessage('user ID must be a MongoDB ID'),
+  ],
+};
+
+interface StrictActivityQuery {
+  limit?: number;
+  offset?: number;
+  searchFilter?: string;
+  targetUserId?: string;
+}
+
+type CustomRequest<
+  TQuery = Request['query'],
+  TBody = any,
+  TParams = any
+> = Omit<Request<TParams, any, TBody, TQuery>, 'query'> & {
+    query: TQuery & Request['query'];
+    user?: IUserHasId;
+};
+
+type AuthorizedRequest = CustomRequest<StrictActivityQuery>;
+
+type ActivityPaginationResult = PaginateResult<IActivity>;
+
+
+/**
+ * @swagger
+ *
+ * components:
+ *   schemas:
+ *     ActivityResponse:
+ *       type: object
+ *       properties:
+ *         serializedPaginationResult:
+ *           type: object
+ *           properties:
+ *             docs:
+ *               type: array
+ *               items:
+ *                 type: object
+ *                 properties:
+ *                   _id:
+ *                     type: string
+ *                     example: "67e33da5d97e8d3b53e99f95"
+ *                   targetModel:
+ *                     type: string
+ *                     example: "Page"
+ *                   target:
+ *                     type: string
+ *                     example: "675547e97f208f8050a361d4"
+ *                   action:
+ *                     type: string
+ *                     example: "PAGE_UPDATE"
+ *                   createdAt:
+ *                     type: string
+ *                     format: date-time
+ *                     example: "2025-03-25T23:35:01.584Z"
+ *                   user:
+ *                     type: object
+ *                     properties:
+ *                       _id:
+ *                         type: string
+ *                         example: "669a5aa48d45e62b521d00e4"
+ *                       name:
+ *                         type: string
+ *                         example: "Taro"
+ *                       username:
+ *                         type: string
+ *                         example: "growi"
+ *                       imageUrlCached:
+ *                         type: string
+ *                         example: "/images/icons/user.svg"
+ *             totalDocs:
+ *               type: integer
+ *               example: 3
+ *             offset:
+ *               type: integer
+ *               example: 0
+ *             limit:
+ *               type: integer
+ *               example: 10
+ *             totalPages:
+ *               type: integer
+ *               example: 1
+ *             page:
+ *               type: integer
+ *               example: 1
+ *             pagingCounter:
+ *               type: integer
+ *               example: 1
+ *             hasPrevPage:
+ *               type: boolean
+ *               example: false
+ *             hasNextPage:
+ *               type: boolean
+ *               example: false
+ *             prevPage:
+ *               type: integer
+ *               nullable: true
+ *               example: null
+ *             nextPage:
+ *               type: integer
+ *               nullable: true
+ *               example: null
+ */
+
+module.exports = (crowi: Crowi): Router => {
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+
+  const router = express.Router();
+
+  /**
+   * @swagger
+   *
+   * /activity:
+   *   get:
+   *     summary: /activity
+   *     tags: [Activity]
+   *     security:
+   *       - cookieAuth: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
+   *     parameters:
+   *       - name: limit
+   *         in: query
+   *         required: false
+   *         schema:
+   *           type: integer
+   *       - name: offset
+   *         in: query
+   *         required: false
+   *         schema:
+   *           type: integer
+   *       - name: searchFilter
+   *         in: query
+   *         required: false
+   *         schema:
+   *           type: string
+   *     responses:
+   *       200:
+   *         description: Activity fetched successfully
+   *         content:
+   *           application/json:
+   *             schema:
+   *               $ref: '#/components/schemas/ActivityResponse'
+   */
+  router.get('/',
+    loginRequiredStrictly, validator.list, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+
+      const defaultLimit = configManager.getConfig('customize:showPageLimitationS');
+
+      const limit = req.query.limit || defaultLimit || 10;
+      const offset = req.query.offset || 0;
+      let targetUserId = req.query.targetUserId;
+
+      if (typeof targetUserId !== 'string') {
+        targetUserId = req.user?._id;
+      }
+
+      if (!targetUserId) {
+        return res.apiv3Err('Target user ID is missing and authenticated user ID is unavailable.', 400);
+      }
+
+
+      try {
+        const userObjectId = new Types.ObjectId(targetUserId);
+
+        const userActivityPipeline: PipelineStage[] = [
+          {
+            $match: {
+              user: userObjectId,
+              action: { $in: Object.values(ActivityLogActions) },
+            },
+          },
+          {
+            $facet: {
+              totalCount: [
+                { $count: 'count' },
+              ],
+              docs: [
+                { $sort: { createdAt: -1 } },
+                { $skip: offset },
+                { $limit: limit },
+                {
+                  $lookup: {
+                    from: 'pages',
+                    localField: 'target',
+                    foreignField: '_id',
+                    as: 'target',
+                  },
+                },
+                {
+                  $unwind: {
+                    path: '$target',
+                    preserveNullAndEmptyArrays: true,
+                  },
+                },
+                {
+                  $lookup: {
+                    from: 'users',
+                    localField: 'user',
+                    foreignField: '_id',
+                    as: 'user',
+                  },
+                },
+                {
+                  $unwind: {
+                    path: '$user',
+                    preserveNullAndEmptyArrays: true,
+                  },
+                },
+                {
+                  $project: {
+                    _id: 1,
+                    'user._id': 1,
+                    'user.username': 1,
+                    'user.name': 1,
+                    'user.imageUrlCached': 1,
+                    action: 1,
+                    createdAt: 1,
+                    target: 1,
+                    targetModel: 1,
+                  },
+                },
+              ],
+            },
+          },
+        ];
+
+        const [activityResults] = await Activity.aggregate(userActivityPipeline);
+
+        const serializedResults = activityResults.docs.map((doc: IActivity) => {
+          const { user, ...rest } = doc;
+          return {
+            user: serializeUserSecurely(user),
+            ...rest,
+          };
+        });
+
+        const totalDocs = activityResults.totalCount.length > 0 ? activityResults.totalCount[0].count : 0;
+        const totalPages = Math.ceil(totalDocs / limit);
+        const page = Math.floor(offset / limit) + 1;
+
+        const nextPage = page < totalPages ? page + 1 : null;
+        const prevPage = page > 1 ? page - 1 : null;
+        const pagingCounter = offset + 1;
+
+        const serializedPaginationResult: ActivityPaginationResult = {
+          docs: serializedResults,
+          totalDocs,
+          limit,
+          offset,
+          page,
+          totalPages,
+          hasPrevPage: page > 1,
+          hasNextPage: page < totalPages,
+          nextPage,
+          prevPage,
+          pagingCounter,
+        };
+
+        return res.apiv3({ serializedPaginationResult });
+      }
+      catch (err) {
+        logger.error('Failed to get paginated activity', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
+  return router;
+};

+ 34 - 19
apps/app/src/server/routes/attachment/api.js

@@ -3,10 +3,9 @@ import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { Attachment } from '../../models/attachment';
 import { Attachment } from '../../models/attachment';
-
 import { validateImageContentType } from './image-content-type-validator';
 import { validateImageContentType } from './image-content-type-validator';
-/* eslint-disable no-use-before-define */
 
 
+/* eslint-disable no-use-before-define */
 
 
 const logger = loggerFactory('growi:routes:attachment');
 const logger = loggerFactory('growi:routes:attachment');
 
 
@@ -154,7 +153,8 @@ export const routesFactory = (crowi) => {
     }
     }
 
 
     const ownerId = attachment.creator._id || attachment.creator;
     const ownerId = attachment.creator._id || attachment.creator;
-    if (attachment.page == null) { // when profile image
+    if (attachment.page == null) {
+      // when profile image
       return user.id === ownerId.toString();
       return user.id === ownerId.toString();
     }
     }
 
 
@@ -162,7 +162,6 @@ export const routesFactory = (crowi) => {
     return await Page.isAccessiblePageByViewer(attachment.page, user);
     return await Page.isAccessiblePageByViewer(attachment.page, user);
   }
   }
 
 
-
   const actions = {};
   const actions = {};
   const api = {};
   const api = {};
 
 
@@ -238,7 +237,7 @@ export const routesFactory = (crowi) => {
    *
    *
    * @apiParam {File} file
    * @apiParam {File} file
    */
    */
-  api.uploadProfileImage = async function(req, res) {
+  api.uploadProfileImage = async (req, res) => {
     // check params
     // check params
     if (req.file == null) {
     if (req.file == null) {
       return res.json(ApiResponse.error('File error.'));
       return res.json(ApiResponse.error('File error.'));
@@ -260,10 +259,14 @@ export const routesFactory = (crowi) => {
     try {
     try {
       const user = await User.findById(req.user._id);
       const user = await User.findById(req.user._id);
       await user.deleteImage();
       await user.deleteImage();
-      attachment = await attachmentService.createAttachment(file, req.user, null, AttachmentType.PROFILE_IMAGE);
+      attachment = await attachmentService.createAttachment(
+        file,
+        req.user,
+        null,
+        AttachmentType.PROFILE_IMAGE,
+      );
       await user.updateImage(attachment);
       await user.updateImage(attachment);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       return res.json(ApiResponse.error(err.message));
       return res.json(ApiResponse.error(err.message));
     }
     }
@@ -312,7 +315,7 @@ export const routesFactory = (crowi) => {
    *
    *
    * @apiParam {String} attachment_id
    * @apiParam {String} attachment_id
    */
    */
-  api.remove = async function(req, res) {
+  api.remove = async (req, res) => {
     const id = req.body.attachment_id;
     const id = req.body.attachment_id;
 
 
     const attachment = await Attachment.findById(id);
     const attachment = await Attachment.findById(id);
@@ -323,18 +326,25 @@ export const routesFactory = (crowi) => {
 
 
     const isDeletable = await isDeletableByUser(req.user, attachment);
     const isDeletable = await isDeletableByUser(req.user, attachment);
     if (!isDeletable) {
     if (!isDeletable) {
-      return res.json(ApiResponse.error(`Forbidden to remove the attachment '${attachment.id}'`));
+      return res.json(
+        ApiResponse.error(
+          `Forbidden to remove the attachment '${attachment.id}'`,
+        ),
+      );
     }
     }
 
 
     try {
     try {
       await attachmentService.removeAttachment(attachment);
       await attachmentService.removeAttachment(attachment);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
-      return res.status(500).json(ApiResponse.error('Error while deleting file'));
+      return res
+        .status(500)
+        .json(ApiResponse.error('Error while deleting file'));
     }
     }
 
 
-    activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_REMOVE });
+    activityEvent.emit('update', res.locals.activity._id, {
+      action: SupportedAction.ACTION_ATTACHMENT_REMOVE,
+    });
 
 
     return res.json(ApiResponse.success({}));
     return res.json(ApiResponse.success({}));
   };
   };
@@ -373,7 +383,7 @@ export const routesFactory = (crowi) => {
    * @apiGroup Attachment
    * @apiGroup Attachment
    * @apiParam {String} attachment_id
    * @apiParam {String} attachment_id
    */
    */
-  api.removeProfileImage = async function(req, res) {
+  api.removeProfileImage = async (req, res) => {
     const user = req.user;
     const user = req.user;
     const attachment = await Attachment.findById(user.imageAttachment);
     const attachment = await Attachment.findById(user.imageAttachment);
 
 
@@ -383,15 +393,20 @@ export const routesFactory = (crowi) => {
 
 
     const isDeletable = await isDeletableByUser(user, attachment);
     const isDeletable = await isDeletableByUser(user, attachment);
     if (!isDeletable) {
     if (!isDeletable) {
-      return res.json(ApiResponse.error(`Forbidden to remove the attachment '${attachment.id}'`));
+      return res.json(
+        ApiResponse.error(
+          `Forbidden to remove the attachment '${attachment.id}'`,
+        ),
+      );
     }
     }
 
 
     try {
     try {
       await user.deleteImage();
       await user.deleteImage();
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
-      return res.status(500).json(ApiResponse.error('Error while deleting image'));
+      return res
+        .status(500)
+        .json(ApiResponse.error('Error while deleting image'));
     }
     }
 
 
     return res.json(ApiResponse.success({}));
     return res.json(ApiResponse.success({}));

+ 14 - 12
apps/app/src/server/routes/attachment/download.ts

@@ -1,5 +1,5 @@
-import express from 'express';
 import type { Router } from 'express';
 import type { Router } from 'express';
+import express from 'express';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
@@ -7,17 +7,14 @@ import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
 import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
 import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
-
 import type { GetRequest, GetResponse } from './get';
 import type { GetRequest, GetResponse } from './get';
 import { getActionFactory, retrieveAttachmentFromIdParam } from './get';
 import { getActionFactory, retrieveAttachmentFromIdParam } from './get';
 
 
-
 const logger = loggerFactory('growi:routes:attachment:download');
 const logger = loggerFactory('growi:routes:attachment:download');
 
 
-
 const generateActivityParameters = (req: CrowiRequest) => {
 const generateActivityParameters = (req: CrowiRequest) => {
   return {
   return {
-    ip:  req.ip,
+    ip: req.ip,
     endpoint: req.originalUrl,
     endpoint: req.originalUrl,
     action: SupportedAction.ACTION_ATTACHMENT_DOWNLOAD,
     action: SupportedAction.ACTION_ATTACHMENT_DOWNLOAD,
     user: req.user?._id,
     user: req.user?._id,
@@ -28,21 +25,25 @@ const generateActivityParameters = (req: CrowiRequest) => {
 };
 };
 
 
 export const downloadRouterFactory = (crowi: Crowi): Router => {
 export const downloadRouterFactory = (crowi: Crowi): Router => {
-
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequired = require('../../middlewares/login-required')(
+    crowi,
+    true,
+  );
 
 
   const router = express.Router();
   const router = express.Router();
 
 
   // note: retrieveAttachmentFromIdParam requires `req.params.id`
   // note: retrieveAttachmentFromIdParam requires `req.params.id`
-  router.get<{ id: string }>('/:id([0-9a-z]{24})',
-    certifySharedPageAttachmentMiddleware, loginRequired,
+  router.get<{ id: string }>(
+    '/:id([0-9a-z]{24})',
+    certifySharedPageAttachmentMiddleware,
+    loginRequired,
     retrieveAttachmentFromIdParam,
     retrieveAttachmentFromIdParam,
 
 
-    async(req: GetRequest, res: GetResponse) => {
+    async (req: GetRequest, res: GetResponse) => {
       const { attachment } = res.locals;
       const { attachment } = res.locals;
 
 
       const activityParameters = generateActivityParameters(req);
       const activityParameters = generateActivityParameters(req);
-      const createActivity = async() => {
+      const createActivity = async () => {
         await crowi.activityService.createActivity(activityParameters);
         await crowi.activityService.createActivity(activityParameters);
       };
       };
 
 
@@ -50,7 +51,8 @@ export const downloadRouterFactory = (crowi: Crowi): Router => {
       await getAction(req, res, { download: true });
       await getAction(req, res, { download: true });
 
 
       createActivity();
       createActivity();
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

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

@@ -1,10 +1,8 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
+import type { Response, Router } from 'express';
 import express from 'express';
 import express from 'express';
-import type {
-  Response, Router,
-} from 'express';
 
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -13,30 +11,39 @@ import { AttachmentType } from '../../interfaces/attachment';
 import { generateCertifyBrandLogoMiddleware } from '../../middlewares/certify-brand-logo';
 import { generateCertifyBrandLogoMiddleware } from '../../middlewares/certify-brand-logo';
 import { Attachment } from '../../models/attachment';
 import { Attachment } from '../../models/attachment';
 import ApiResponse from '../../util/apiResponse';
 import ApiResponse from '../../util/apiResponse';
-
 import { getActionFactory } from './get';
 import { getActionFactory } from './get';
 
 
-
 const logger = loggerFactory('growi:routes:attachment:get-brand-logo');
 const logger = loggerFactory('growi:routes:attachment:get-brand-logo');
 
 
-
 export const getBrandLogoRouterFactory = (crowi: Crowi): Router => {
 export const getBrandLogoRouterFactory = (crowi: Crowi): Router => {
-
   const certifyBrandLogo = generateCertifyBrandLogoMiddleware(crowi);
   const certifyBrandLogo = generateCertifyBrandLogoMiddleware(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequired = require('../../middlewares/login-required')(
+    crowi,
+    true,
+  );
 
 
   const router = express.Router();
   const router = express.Router();
 
 
-  router.get('/brand-logo', certifyBrandLogo, accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]), loginRequired, async(req: CrowiRequest, res: Response) => {
-    const brandLogoAttachment = await Attachment.findOne({ attachmentType: AttachmentType.BRAND_LOGO });
-
-    if (brandLogoAttachment == null) {
-      return res.status(404).json(ApiResponse.error('Brand logo does not exist'));
-    }
-
-    const getAction = getActionFactory(crowi, brandLogoAttachment);
-    getAction(req, res);
-  });
+  router.get(
+    '/brand-logo',
+    certifyBrandLogo,
+    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]),
+    loginRequired,
+    async (req: CrowiRequest, res: Response) => {
+      const brandLogoAttachment = await Attachment.findOne({
+        attachmentType: AttachmentType.BRAND_LOGO,
+      });
+
+      if (brandLogoAttachment == null) {
+        return res
+          .status(404)
+          .json(ApiResponse.error('Brand logo does not exist'));
+      }
+
+      const getAction = getActionFactory(crowi, brandLogoAttachment);
+      getAction(req, res);
+    },
+  );
 
 
   return router;
   return router;
 };
 };

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

@@ -1,17 +1,19 @@
-import {
-  getIdStringForRef, type IPage, type IUser,
-} from '@growi/core';
+import { getIdStringForRef, type IPage, type IUser } from '@growi/core';
+import type { NextFunction, Request, Response, Router } from 'express';
 import express from 'express';
 import express from 'express';
-import type {
-  NextFunction, Request, Response, Router,
-} from 'express';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 import type { CrowiProperties, CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiProperties, CrowiRequest } from '~/interfaces/crowi-request';
-import { ResponseMode, type ExpressHttpHeader, type RespondOptions } from '~/server/interfaces/attachment';
 import {
 import {
+  type ExpressHttpHeader,
+  type RespondOptions,
+  ResponseMode,
+} from '~/server/interfaces/attachment';
+import {
+  applyHeaders,
+  createContentHeaders,
   type FileUploader,
   type FileUploader,
-  toExpressHttpHeaders, applyHeaders, createContentHeaders,
+  toExpressHttpHeaders,
 } from '~/server/service/file-uploader';
 } from '~/server/service/file-uploader';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -20,32 +22,31 @@ import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify
 import { Attachment, type IAttachmentDocument } from '../../models/attachment';
 import { Attachment, type IAttachmentDocument } from '../../models/attachment';
 import ApiResponse from '../../util/apiResponse';
 import ApiResponse from '../../util/apiResponse';
 
 
-
 const logger = loggerFactory('growi:routes:attachment:get');
 const logger = loggerFactory('growi:routes:attachment:get');
 
 
-
 // TODO: remove this local interface when models/page has typescriptized
 // TODO: remove this local interface when models/page has typescriptized
 interface PageModel {
 interface PageModel {
-  isAccessiblePageByViewer: (pageId: string, user: IUser | undefined) => Promise<boolean>
+  isAccessiblePageByViewer: (
+    pageId: string,
+    user: IUser | undefined,
+  ) => Promise<boolean>;
 }
 }
 
 
 type LocalsAfterDataInjection = { attachment: IAttachmentDocument };
 type LocalsAfterDataInjection = { attachment: IAttachmentDocument };
 
 
-type RetrieveAttachmentFromIdParamRequest = CrowiProperties & Request<
-  { id: string },
-  any, any, any,
-  LocalsAfterDataInjection
->;
+type RetrieveAttachmentFromIdParamRequest = CrowiProperties &
+  Request<{ id: string }, any, any, any, LocalsAfterDataInjection>;
 
 
 type RetrieveAttachmentFromIdParamResponse = Response<
 type RetrieveAttachmentFromIdParamResponse = Response<
   any,
   any,
   LocalsAfterDataInjection
   LocalsAfterDataInjection
 >;
 >;
 
 
-export const retrieveAttachmentFromIdParam = async(
-    req: RetrieveAttachmentFromIdParamRequest, res: RetrieveAttachmentFromIdParamResponse, next: NextFunction,
+export const retrieveAttachmentFromIdParam = async (
+  req: RetrieveAttachmentFromIdParamRequest,
+  res: RetrieveAttachmentFromIdParamResponse,
+  next: NextFunction,
 ): Promise<void> => {
 ): Promise<void> => {
-
   const id = req.params.id;
   const id = req.params.id;
   const attachment = await Attachment.findById(id);
   const attachment = await Attachment.findById(id);
 
 
@@ -59,9 +60,16 @@ export const retrieveAttachmentFromIdParam = async(
   // check viewer has permission
   // check viewer has permission
   if (user != null && attachment.page != null) {
   if (user != null && attachment.page != null) {
     const Page = mongoose.model<IPage, PageModel>('Page');
     const Page = mongoose.model<IPage, PageModel>('Page');
-    const isAccessible = await Page.isAccessiblePageByViewer(getIdStringForRef(attachment.page), user);
+    const isAccessible = await Page.isAccessiblePageByViewer(
+      getIdStringForRef(attachment.page),
+      user,
+    );
     if (!isAccessible) {
     if (!isAccessible) {
-      res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'. This attachment might belong to other pages.`));
+      res.json(
+        ApiResponse.error(
+          `Forbidden to access to the attachment '${attachment.id}'. This attachment might belong to other pages.`,
+        ),
+      );
       return;
       return;
     }
     }
   }
   }
@@ -71,16 +79,21 @@ export const retrieveAttachmentFromIdParam = async(
   return next();
   return next();
 };
 };
 
 
-
-export const generateHeadersForFresh = (attachment: IAttachmentDocument): ExpressHttpHeader[] => {
+export const generateHeadersForFresh = (
+  attachment: IAttachmentDocument,
+): ExpressHttpHeader[] => {
   return toExpressHttpHeaders({
   return toExpressHttpHeaders({
     ETag: `Attachment-${attachment._id}`,
     ETag: `Attachment-${attachment._id}`,
     'Last-Modified': attachment.createdAt.toUTCString(),
     'Last-Modified': attachment.createdAt.toUTCString(),
   });
   });
 };
 };
 
 
-
-const respondForRedirectMode = async(res: Response, fileUploadService: FileUploader, attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> => {
+const respondForRedirectMode = async (
+  res: Response,
+  fileUploadService: FileUploader,
+  attachment: IAttachmentDocument,
+  opts?: RespondOptions,
+): Promise<void> => {
   const isDownload = opts?.download ?? false;
   const isDownload = opts?.download ?? false;
 
 
   if (!isDownload) {
   if (!isDownload) {
@@ -91,42 +104,59 @@ const respondForRedirectMode = async(res: Response, fileUploadService: FileUploa
     }
     }
   }
   }
 
 
-  const temporaryUrl = await fileUploadService.generateTemporaryUrl(attachment, opts);
+  const temporaryUrl = await fileUploadService.generateTemporaryUrl(
+    attachment,
+    opts,
+  );
 
 
   res.redirect(temporaryUrl.url);
   res.redirect(temporaryUrl.url);
 
 
   // persist temporaryUrl
   // persist temporaryUrl
   if (!isDownload) {
   if (!isDownload) {
     try {
     try {
-      attachment.cashTemporaryUrlByProvideSec(temporaryUrl.url, temporaryUrl.lifetimeSec);
+      attachment.cashTemporaryUrlByProvideSec(
+        temporaryUrl.url,
+        temporaryUrl.lifetimeSec,
+      );
       return;
       return;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
     }
     }
   }
   }
 };
 };
 
 
-const respondForRelayMode = async(res: Response, fileUploadService: FileUploader, attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> => {
+const respondForRelayMode = async (
+  res: Response,
+  fileUploadService: FileUploader,
+  attachment: IAttachmentDocument,
+  opts?: RespondOptions,
+): Promise<void> => {
   // apply content-* headers before response
   // apply content-* headers before response
   const isDownload = opts?.download ?? false;
   const isDownload = opts?.download ?? false;
-  const contentHeaders = createContentHeaders(attachment, { inline: !isDownload });
+  const contentHeaders = createContentHeaders(attachment, {
+    inline: !isDownload,
+  });
   applyHeaders(res, contentHeaders);
   applyHeaders(res, contentHeaders);
 
 
   try {
   try {
     const readable = await fileUploadService.findDeliveryFile(attachment);
     const readable = await fileUploadService.findDeliveryFile(attachment);
     readable.pipe(res);
     readable.pipe(res);
-  }
-  catch (e) {
+  } catch (e) {
     logger.error(e);
     logger.error(e);
     res.json(ApiResponse.error(e.message));
     res.json(ApiResponse.error(e.message));
     return;
     return;
   }
   }
 };
 };
 
 
-export const getActionFactory = (crowi: Crowi, attachment: IAttachmentDocument) => {
-  return async(req: CrowiRequest, res: Response, opts?: RespondOptions): Promise<void> => {
-
+export const getActionFactory = (
+  crowi: Crowi,
+  attachment: IAttachmentDocument,
+) => {
+  return async (
+    req: CrowiRequest,
+    res: Response,
+    opts?: RespondOptions,
+  ): Promise<void> => {
     // add headers before evaluating 'req.fresh'
     // add headers before evaluating 'req.fresh'
     applyHeaders(res, generateHeadersForFresh(attachment));
     applyHeaders(res, generateHeadersForFresh(attachment));
 
 
@@ -154,26 +184,22 @@ export const getActionFactory = (crowi: Crowi, attachment: IAttachmentDocument)
   };
   };
 };
 };
 
 
+export type GetRequest = CrowiProperties &
+  Request<{ id: string }, any, any, any, LocalsAfterDataInjection>;
 
 
-export type GetRequest = CrowiProperties & Request<
-  { id: string },
-  any, any, any,
-  LocalsAfterDataInjection
->;
-
-export type GetResponse = Response<
-  any,
-  LocalsAfterDataInjection
->
+export type GetResponse = Response<any, LocalsAfterDataInjection>;
 
 
 export const getRouterFactory = (crowi: Crowi): Router => {
 export const getRouterFactory = (crowi: Crowi): Router => {
-
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequired = require('../../middlewares/login-required')(
+    crowi,
+    true,
+  );
 
 
   const router = express.Router();
   const router = express.Router();
 
 
   // note: retrieveAttachmentFromIdParam requires `req.params.id`
   // note: retrieveAttachmentFromIdParam requires `req.params.id`
-  router.get<{ id: string }>('/:id([0-9a-z]{24})',
+  router.get<{ id: string }>(
+    '/:id([0-9a-z]{24})',
     certifySharedPageAttachmentMiddleware,
     certifySharedPageAttachmentMiddleware,
     loginRequired,
     loginRequired,
     retrieveAttachmentFromIdParam,
     retrieveAttachmentFromIdParam,
@@ -182,7 +208,8 @@ export const getRouterFactory = (crowi: Crowi): Router => {
       const { attachment } = res.locals;
       const { attachment } = res.locals;
       const getAction = getActionFactory(crowi, attachment);
       const getAction = getActionFactory(crowi, attachment);
       getAction(req, res);
       getAction(req, res);
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 5 - 2
apps/app/src/server/routes/attachment/image-content-type-validator.spec.ts

@@ -1,6 +1,9 @@
-import { describe, test, expect } from 'vitest';
+import { describe, expect, test } from 'vitest';
 
 
-import { validateImageContentType, type SupportedImageMimeType } from './image-content-type-validator';
+import {
+  type SupportedImageMimeType,
+  validateImageContentType,
+} from './image-content-type-validator';
 
 
 describe('validateImageContentType', () => {
 describe('validateImageContentType', () => {
   describe('valid cases', () => {
   describe('valid cases', () => {

+ 8 - 3
apps/app/src/server/routes/attachment/image-content-type-validator.ts

@@ -15,7 +15,8 @@ export const SUPPORTED_IMAGE_MIME_TYPES = [
 ] as const;
 ] as const;
 
 
 // Create a type for supported MIME types
 // Create a type for supported MIME types
-export type SupportedImageMimeType = typeof SUPPORTED_IMAGE_MIME_TYPES[number];
+export type SupportedImageMimeType =
+  (typeof SUPPORTED_IMAGE_MIME_TYPES)[number];
 
 
 export interface ImageContentTypeValidatorResult {
 export interface ImageContentTypeValidatorResult {
   isValid: boolean;
   isValid: boolean;
@@ -28,7 +29,9 @@ export interface ImageContentTypeValidatorResult {
  * @param mimeType MIME type string
  * @param mimeType MIME type string
  * @returns Validation result containing isValid flag and extracted content type
  * @returns Validation result containing isValid flag and extracted content type
  */
  */
-export const validateImageContentType = (mimeType: string): ImageContentTypeValidatorResult => {
+export const validateImageContentType = (
+  mimeType: string,
+): ImageContentTypeValidatorResult => {
   if (typeof mimeType !== 'string') {
   if (typeof mimeType !== 'string') {
     return {
     return {
       isValid: false,
       isValid: false,
@@ -38,7 +41,9 @@ export const validateImageContentType = (mimeType: string): ImageContentTypeVali
   }
   }
 
 
   const trimmedType = mimeType.trim();
   const trimmedType = mimeType.trim();
-  const isValid = SUPPORTED_IMAGE_MIME_TYPES.includes(trimmedType as SupportedImageMimeType);
+  const isValid = SUPPORTED_IMAGE_MIME_TYPES.includes(
+    trimmedType as SupportedImageMimeType,
+  );
 
 
   if (!isValid) {
   if (!isValid) {
     const supportedFormats = 'PNG, JPEG, GIF, WebP, AVIF, HEIC/HEIF, TIFF, SVG';
     const supportedFormats = 'PNG, JPEG, GIF, WebP, AVIF, HEIC/HEIF, TIFF, SVG';

+ 1 - 1
apps/app/src/server/routes/attachment/index.ts

@@ -1,3 +1,3 @@
+export * from './download';
 export * from './get';
 export * from './get';
 export * from './get-brand-logo';
 export * from './get-brand-logo';
-export * from './download';

+ 1 - 3
apps/app/src/server/routes/avoid-session-routes.js

@@ -1,3 +1 @@
-module.exports = [
-  /^\/api-docs\//,
-];
+module.exports = [/^\/api-docs\//];

+ 80 - 42
apps/app/src/server/routes/comment.js

@@ -2,7 +2,11 @@ import { getIdStringForRef } from '@growi/core';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 
 import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
 import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
-import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
+import {
+  SupportedAction,
+  SupportedEventModel,
+  SupportedTargetModel,
+} from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { GlobalNotificationSettingEvent } from '../models/GlobalNotificationSetting';
 import { GlobalNotificationSettingEvent } from '../models/GlobalNotificationSetting';
@@ -14,7 +18,6 @@ import { preNotifyService } from '../service/pre-notify';
  *    name: Comments
  *    name: Comments
  */
  */
 
 
-
 /**
 /**
  * @swagger
  * @swagger
  *
  *
@@ -55,7 +58,7 @@ import { preNotifyService } from '../service/pre-notify';
  */
  */
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
-module.exports = function(crowi, app) {
+module.exports = (crowi, app) => {
   const logger = loggerFactory('growi:routes:comment');
   const logger = loggerFactory('growi:routes:comment');
   const User = crowi.model('User');
   const User = crowi.model('User');
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
@@ -122,14 +125,16 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    * @apiParam {String} page_id Page Id.
    * @apiParam {String} revision_id Revision Id.
    * @apiParam {String} revision_id Revision Id.
    */
    */
-  api.get = async function(req, res) {
+  api.get = async (req, res) => {
     const pageId = req.query.page_id;
     const pageId = req.query.page_id;
     const revisionId = req.query.revision_id;
     const revisionId = req.query.revision_id;
 
 
     // check whether accessible
     // check whether accessible
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
     if (!isAccessible) {
     if (!isAccessible) {
-      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
+      return res.json(
+        ApiResponse.error('Current user is not accessible to this page.'),
+      );
     }
     }
 
 
     let query = null;
     let query = null;
@@ -137,12 +142,10 @@ module.exports = function(crowi, app) {
     try {
     try {
       if (revisionId) {
       if (revisionId) {
         query = Comment.findCommentsByRevisionId(revisionId);
         query = Comment.findCommentsByRevisionId(revisionId);
-      }
-      else {
+      } else {
         query = Comment.findCommentsByPageId(pageId);
         query = Comment.findCommentsByPageId(pageId);
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
 
 
@@ -156,19 +159,21 @@ module.exports = function(crowi, app) {
     res.json(ApiResponse.success({ comments }));
     res.json(ApiResponse.success({ comments }));
   };
   };
 
 
-  api.validators.add = function() {
+  api.validators.add = () => {
     const validator = [
     const validator = [
       body('commentForm.page_id').exists(),
       body('commentForm.page_id').exists(),
       body('commentForm.revision_id').exists(),
       body('commentForm.revision_id').exists(),
       body('commentForm.comment').exists(),
       body('commentForm.comment').exists(),
       body('commentForm.comment_position').isInt(),
       body('commentForm.comment_position').isInt(),
       body('commentForm.is_markdown').isBoolean(),
       body('commentForm.is_markdown').isBoolean(),
-      body('commentForm.replyTo').exists().custom((value) => {
-        if (value === '') {
-          return undefined;
-        }
-        return ObjectId(value);
-      }),
+      body('commentForm.replyTo')
+        .exists()
+        .custom((value) => {
+          if (value === '') {
+            return undefined;
+          }
+          return ObjectId(value);
+        }),
 
 
       body('slackNotificationForm.isSlackEnabled').isBoolean().exists(),
       body('slackNotificationForm.isSlackEnabled').isBoolean().exists(),
     ];
     ];
@@ -230,7 +235,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} comment Comment body
    * @apiParam {String} comment Comment body
    * @apiParam {Number} comment_position=-1 Line number of the comment
    * @apiParam {Number} comment_position=-1 Line number of the comment
    */
    */
-  api.add = async function(req, res) {
+  api.add = async (req, res) => {
     const { commentForm, slackNotificationForm } = req.body;
     const { commentForm, slackNotificationForm } = req.body;
     const { validationResult } = require('express-validator');
     const { validationResult } = require('express-validator');
 
 
@@ -248,7 +253,9 @@ module.exports = function(crowi, app) {
     // check whether accessible
     // check whether accessible
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
     if (!isAccessible) {
     if (!isAccessible) {
-      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
+      return res.json(
+        ApiResponse.error('Current user is not accessible to this page.'),
+      );
     }
     }
 
 
     if (comment === '') {
     if (comment === '') {
@@ -257,10 +264,16 @@ module.exports = function(crowi, app) {
 
 
     let createdComment;
     let createdComment;
     try {
     try {
-      createdComment = await Comment.add(pageId, req.user._id, revisionId, comment, position, replyTo);
+      createdComment = await Comment.add(
+        pageId,
+        req.user._id,
+        revisionId,
+        comment,
+        position,
+        replyTo,
+      );
       commentEvent.emit(CommentEvent.CREATE, createdComment);
       commentEvent.emit(CommentEvent.CREATE, createdComment);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
@@ -282,23 +295,36 @@ module.exports = function(crowi, app) {
     };
     };
 
 
     /** @type {import('../service/pre-notify').GetAdditionalTargetUsers} */
     /** @type {import('../service/pre-notify').GetAdditionalTargetUsers} */
-    const getAdditionalTargetUsers = async(activity) => {
-      const mentionedUsers = await crowi.commentService.getMentionedUsers(activity.event);
+    const getAdditionalTargetUsers = async (activity) => {
+      const mentionedUsers = await crowi.commentService.getMentionedUsers(
+        activity.event,
+      );
 
 
       return mentionedUsers;
       return mentionedUsers;
     };
     };
 
 
-    activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify, getAdditionalTargetUsers);
+    activityEvent.emit(
+      'update',
+      res.locals.activity._id,
+      parameters,
+      page,
+      preNotifyService.generatePreNotify,
+      getAdditionalTargetUsers,
+    );
 
 
     res.json(ApiResponse.success({ comment: createdComment }));
     res.json(ApiResponse.success({ comment: createdComment }));
 
 
     // global notification
     // global notification
     try {
     try {
-      await globalNotificationService.fire(GlobalNotificationSettingEvent.COMMENT, page, req.user, {
-        comment: createdComment,
-      });
-    }
-    catch (err) {
+      await globalNotificationService.fire(
+        GlobalNotificationSettingEvent.COMMENT,
+        page,
+        req.user,
+        {
+          comment: createdComment,
+        },
+      );
+    } catch (err) {
       logger.error('Comment notification failed', err);
       logger.error('Comment notification failed', err);
     }
     }
 
 
@@ -307,14 +333,20 @@ module.exports = function(crowi, app) {
       const { slackChannels } = slackNotificationForm;
       const { slackChannels } = slackNotificationForm;
 
 
       try {
       try {
-        const results = await userNotificationService.fire(page, req.user, slackChannels, 'comment', {}, createdComment);
+        const results = await userNotificationService.fire(
+          page,
+          req.user,
+          slackChannels,
+          'comment',
+          {},
+          createdComment,
+        );
         results.forEach((result) => {
         results.forEach((result) => {
           if (result.status === 'rejected') {
           if (result.status === 'rejected') {
             logger.error('Create user notification failed', result.reason);
             logger.error('Create user notification failed', result.reason);
           }
           }
         });
         });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Create user notification failed', err);
         logger.error('Create user notification failed', err);
       }
       }
     }
     }
@@ -373,7 +405,7 @@ module.exports = function(crowi, app) {
    * @apiName UpdateComment
    * @apiName UpdateComment
    * @apiGroup Comment
    * @apiGroup Comment
    */
    */
-  api.update = async function(req, res) {
+  api.update = async (req, res) => {
     const { commentForm } = req.body;
     const { commentForm } = req.body;
 
 
     const commentStr = commentForm?.comment;
     const commentStr = commentForm?.comment;
@@ -385,7 +417,7 @@ module.exports = function(crowi, app) {
     }
     }
 
 
     if (commentId == null) {
     if (commentId == null) {
-      return res.json(ApiResponse.error('\'comment_id\' is undefined'));
+      return res.json(ApiResponse.error("'comment_id' is undefined"));
     }
     }
 
 
     let updatedComment;
     let updatedComment;
@@ -398,7 +430,10 @@ module.exports = function(crowi, app) {
 
 
       // check whether accessible
       // check whether accessible
       const pageId = comment.page;
       const pageId = comment.page;
-      const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+      const isAccessible = await Page.isAccessiblePageByViewer(
+        pageId,
+        req.user,
+      );
       if (!isAccessible) {
       if (!isAccessible) {
         throw new Error('Current user is not accessible to this page.');
         throw new Error('Current user is not accessible to this page.');
       }
       }
@@ -411,8 +446,7 @@ module.exports = function(crowi, app) {
         { $set: { comment: commentStr, revision } },
         { $set: { comment: commentStr, revision } },
       );
       );
       commentEvent.emit(CommentEvent.UPDATE, updatedComment);
       commentEvent.emit(CommentEvent.UPDATE, updatedComment);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
@@ -462,10 +496,12 @@ module.exports = function(crowi, app) {
    *
    *
    * @apiParam {String} comment_id Comment Id.
    * @apiParam {String} comment_id Comment Id.
    */
    */
-  api.remove = async function(req, res) {
+  api.remove = async (req, res) => {
     const commentId = req.body.comment_id;
     const commentId = req.body.comment_id;
     if (!commentId) {
     if (!commentId) {
-      return Promise.resolve(res.json(ApiResponse.error('\'comment_id\' is undefined')));
+      return Promise.resolve(
+        res.json(ApiResponse.error("'comment_id' is undefined")),
+      );
     }
     }
 
 
     try {
     try {
@@ -478,7 +514,10 @@ module.exports = function(crowi, app) {
 
 
       // check whether accessible
       // check whether accessible
       const pageId = getIdStringForRef(comment.page);
       const pageId = getIdStringForRef(comment.page);
-      const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+      const isAccessible = await Page.isAccessiblePageByViewer(
+        pageId,
+        req.user,
+      );
       if (!isAccessible) {
       if (!isAccessible) {
         throw new Error('Current user is not accessible to this page.');
         throw new Error('Current user is not accessible to this page.');
       }
       }
@@ -489,8 +528,7 @@ module.exports = function(crowi, app) {
       await Comment.removeWithReplies(comment);
       await Comment.removeWithReplies(comment);
       await Page.updateCommentCount(comment.page);
       await Page.updateCommentCount(comment.page);
       commentEvent.emit(CommentEvent.DELETE, comment);
       commentEvent.emit(CommentEvent.DELETE, comment);
-    }
-    catch (err) {
+    } catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
 
 

+ 39 - 19
apps/app/src/server/routes/forgot-password.ts

@@ -1,6 +1,4 @@
-import type {
-  NextFunction, Request, Response,
-} from 'express';
+import type { NextFunction, Request, Response } from 'express';
 import createError from 'http-errors';
 import createError from 'http-errors';
 
 
 import { forgotPasswordErrorCode } from '~/interfaces/errors/forgot-password';
 import { forgotPasswordErrorCode } from '~/interfaces/errors/forgot-password';
@@ -10,37 +8,46 @@ import type { IPasswordResetOrder } from '../models/password-reset-order';
 
 
 const logger = loggerFactory('growi:routes:forgot-password');
 const logger = loggerFactory('growi:routes:forgot-password');
 
 
-
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
-export const checkForgotPasswordEnabledMiddlewareFactory = (crowi: any, forApi = false) => {
-
+export const checkForgotPasswordEnabledMiddlewareFactory = (
+  crowi: any,
+  forApi = false,
+) => {
   return (req: Request, res: Response, next: NextFunction): void => {
   return (req: Request, res: Response, next: NextFunction): void => {
-    const isPasswordResetEnabled = crowi.configManager.getConfig('security:passport-local:isPasswordResetEnabled');
-    const isLocalStrategySetup = crowi.passportService.isLocalStrategySetup as boolean ?? false;
+    const isPasswordResetEnabled = crowi.configManager.getConfig(
+      'security:passport-local:isPasswordResetEnabled',
+    );
+    const isLocalStrategySetup =
+      (crowi.passportService.isLocalStrategySetup as boolean) ?? false;
 
 
     const isEnabled = isLocalStrategySetup && isPasswordResetEnabled;
     const isEnabled = isLocalStrategySetup && isPasswordResetEnabled;
 
 
     if (!isEnabled) {
     if (!isEnabled) {
-      const message = 'Forgot-password function is unavailable because neither LocalStrategy and LdapStrategy is not setup.';
+      const message =
+        'Forgot-password function is unavailable because neither LocalStrategy and LdapStrategy is not setup.';
       logger.error(message);
       logger.error(message);
 
 
       const statusCode = forApi ? 405 : 404;
       const statusCode = forApi ? 405 : 404;
-      return next(createError(statusCode, message, { code: forgotPasswordErrorCode.PASSWORD_RESET_IS_UNAVAILABLE }));
+      next(
+        createError(statusCode, message, {
+          code: forgotPasswordErrorCode.PASSWORD_RESET_IS_UNAVAILABLE,
+        }),
+      );
+      return;
     }
     }
 
 
     next();
     next();
   };
   };
-
 };
 };
 
 
 type Crowi = {
 type Crowi = {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  nextApp: any,
-}
+  nextApp: any;
+};
 
 
 type CrowiReq = Request & {
 type CrowiReq = Request & {
-  crowi: Crowi,
-}
+  crowi: Crowi;
+};
 
 
 export const renderForgotPassword = (crowi: Crowi) => {
 export const renderForgotPassword = (crowi: Crowi) => {
   return (req: CrowiReq, res: Response, next: NextFunction): void => {
   return (req: CrowiReq, res: Response, next: NextFunction): void => {
@@ -52,24 +59,37 @@ export const renderForgotPassword = (crowi: Crowi) => {
 };
 };
 
 
 export const renderResetPassword = (crowi: Crowi) => {
 export const renderResetPassword = (crowi: Crowi) => {
-  return (req: CrowiReq & { passwordResetOrder: IPasswordResetOrder }, res: Response, next: NextFunction): void => {
+  return (
+    req: CrowiReq & { passwordResetOrder: IPasswordResetOrder },
+    res: Response,
+    next: NextFunction,
+  ): void => {
     const { nextApp } = crowi;
     const { nextApp } = crowi;
     req.crowi = crowi;
     req.crowi = crowi;
-    nextApp.render(req, res, '/reset-password', { email: req.passwordResetOrder.email });
+    nextApp.render(req, res, '/reset-password', {
+      email: req.passwordResetOrder.email,
+    });
     return;
     return;
   };
   };
 };
 };
 
 
 // middleware to handle error
 // middleware to handle error
 export const handleErrorsMiddleware = (crowi: Crowi) => {
 export const handleErrorsMiddleware = (crowi: Crowi) => {
-  return (error: Error & { code: string, statusCode: number }, req: CrowiReq, res: Response, next: NextFunction): void => {
+  return (
+    error: Error & { code: string; statusCode: number },
+    req: CrowiReq,
+    res: Response,
+    next: NextFunction,
+  ): void => {
     if (error != null) {
     if (error != null) {
       const { nextApp } = crowi;
       const { nextApp } = crowi;
 
 
       req.crowi = crowi;
       req.crowi = crowi;
       res.status(error.statusCode);
       res.status(error.statusCode);
 
 
-      nextApp.render(req, res, '/forgot-password-errors', { errorCode: error.code });
+      nextApp.render(req, res, '/forgot-password-errors', {
+        errorCode: error.code,
+      });
       return;
       return;
     }
     }
 
 

+ 342 - 75
apps/app/src/server/routes/index.js

@@ -7,30 +7,35 @@ import { accessTokenParser } from '../middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../middlewares/add-activity';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import * as applicationNotInstalled from '../middlewares/application-not-installed';
 import * as applicationNotInstalled from '../middlewares/application-not-installed';
-import { excludeReadOnlyUser, excludeReadOnlyUserIfCommentNotAllowed } from '../middlewares/exclude-read-only-user';
+import {
+  excludeReadOnlyUser,
+  excludeReadOnlyUserIfCommentNotAllowed,
+} from '../middlewares/exclude-read-only-user';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import * as loginFormValidator from '../middlewares/login-form-validator';
 import * as loginFormValidator from '../middlewares/login-form-validator';
 import {
 import {
-  generateUnavailableWhenMaintenanceModeMiddleware, generateUnavailableWhenMaintenanceModeMiddlewareForApi,
+  generateUnavailableWhenMaintenanceModeMiddleware,
+  generateUnavailableWhenMaintenanceModeMiddlewareForApi,
 } from '../middlewares/unavailable-when-maintenance-mode';
 } from '../middlewares/unavailable-when-maintenance-mode';
-
 import * as attachment from './attachment';
 import * as attachment from './attachment';
 import { routesFactory as attachmentApiRoutesFactory } from './attachment/api';
 import { routesFactory as attachmentApiRoutesFactory } from './attachment/api';
 import * as forgotPassword from './forgot-password';
 import * as forgotPassword from './forgot-password';
 import nextFactory from './next';
 import nextFactory from './next';
 import * as userActivation from './user-activation';
 import * as userActivation from './user-activation';
 
 
-
 const multer = require('multer');
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
 const autoReap = require('multer-autoreap');
 
 
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
-module.exports = function(crowi, app) {
-  const autoReconnectToSearch = require('../middlewares/auto-reconnect-to-search')(crowi);
-  const applicationInstalled = require('../middlewares/application-installed')(crowi);
+module.exports = (crowi, app) => {
+  const autoReconnectToSearch =
+    require('../middlewares/auto-reconnect-to-search')(crowi);
+  const applicationInstalled = require('../middlewares/application-installed')(
+    crowi,
+  );
   const loginRequiredStrictly = require('../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../middlewares/login-required')(crowi);
   const loginRequired = require('../middlewares/login-required')(crowi, true);
   const loginRequired = require('../middlewares/login-required')(crowi, true);
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const adminRequired = require('../middlewares/admin-required')(crowi);
@@ -50,13 +55,17 @@ module.exports = function(crowi, app) {
 
 
   const next = nextFactory(crowi);
   const next = nextFactory(crowi);
 
 
-  const unavailableWhenMaintenanceMode = generateUnavailableWhenMaintenanceModeMiddleware(crowi);
-  const unavailableWhenMaintenanceModeForApi = generateUnavailableWhenMaintenanceModeMiddlewareForApi(crowi);
-
+  const unavailableWhenMaintenanceMode =
+    generateUnavailableWhenMaintenanceModeMiddleware(crowi);
+  const unavailableWhenMaintenanceModeForApi =
+    generateUnavailableWhenMaintenanceModeMiddlewareForApi(crowi);
 
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
 
-  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi, app);
+  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(
+    crowi,
+    app,
+  );
 
 
   // Rate limiter
   // Rate limiter
   app.use(rateLimiterFactory());
   app.use(rateLimiterFactory());
@@ -67,47 +76,163 @@ module.exports = function(crowi, app) {
   // API v3 for auth
   // API v3 for auth
   app.use('/_api/v3', apiV3AuthRouter);
   app.use('/_api/v3', apiV3AuthRouter);
 
 
-  app.get('/_next/*'                  , next.delegateToNext);
+  app.get('/_next/*', next.delegateToNext);
 
 
-  app.get('/'                         ,  applicationInstalled, unavailableWhenMaintenanceMode, loginRequired, autoReconnectToSearch, next.delegateToNext);
+  app.get(
+    '/',
+    applicationInstalled,
+    unavailableWhenMaintenanceMode,
+    loginRequired,
+    autoReconnectToSearch,
+    next.delegateToNext,
+  );
 
 
-  app.get('/login/error/:reason'      , applicationInstalled, next.delegateToNext);
-  app.get('/login'                    , applicationInstalled, login.preLogin, next.delegateToNext);
-  app.get('/invited'                  , applicationInstalled, next.delegateToNext);
+  app.get('/login/error/:reason', applicationInstalled, next.delegateToNext);
+  app.get('/login', applicationInstalled, login.preLogin, next.delegateToNext);
+  app.get('/invited', applicationInstalled, next.delegateToNext);
   // app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
   // app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
 
   // NOTE: get method "/admin/export/:fileName" should be loaded before "/admin/*"
   // NOTE: get method "/admin/export/:fileName" should be loaded before "/admin/*"
-  app.get('/admin/export/:fileName'   , accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA]), loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
+  app.get(
+    '/admin/export/:fileName',
+    accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA]),
+    loginRequiredStrictly,
+    adminRequired,
+    admin.export.api.validators.export.download(),
+    admin.export.download,
+  );
 
 
   // TODO: If you want to use accessTokenParser, you need to add scope ANY e.g. accessTokenParser([SCOPE.READ.ADMIN.ANY])
   // TODO: If you want to use accessTokenParser, you need to add scope ANY e.g. accessTokenParser([SCOPE.READ.ADMIN.ANY])
-  app.get('/admin/*'                  , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
-  app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
+  app.get(
+    '/admin/*',
+    applicationInstalled,
+    loginRequiredStrictly,
+    adminRequired,
+    next.delegateToNext,
+  );
+  app.get(
+    '/admin',
+    applicationInstalled,
+    loginRequiredStrictly,
+    adminRequired,
+    next.delegateToNext,
+  );
 
 
   // installer
   // installer
-  app.get('/installer',
+  app.get(
+    '/installer',
     applicationNotInstalled.generateCheckerMiddleware(crowi),
     applicationNotInstalled.generateCheckerMiddleware(crowi),
     next.delegateToNext,
     next.delegateToNext,
-    applicationNotInstalled.redirectToTopOnError);
+    applicationNotInstalled.redirectToTopOnError,
+  );
 
 
   // OAuth
   // OAuth
-  app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailureForExternalAccount);
-  app.get('/passport/github'                      , loginPassport.loginWithGitHub, loginPassport.loginFailureForExternalAccount);
-  app.get('/passport/oidc'                        , loginPassport.loginWithOidc,   loginPassport.loginFailureForExternalAccount);
-  app.get('/passport/saml'                        , loginPassport.loginWithSaml,   loginPassport.loginFailureForExternalAccount);
-  app.get('/passport/google/callback'             , loginPassport.injectRedirectTo, loginPassport.loginPassportGoogleCallback   , loginPassport.loginFailureForExternalAccount);
-  app.get('/passport/github/callback'             , loginPassport.injectRedirectTo, loginPassport.loginPassportGitHubCallback   , loginPassport.loginFailureForExternalAccount);
-  app.get('/passport/oidc/callback'               , loginPassport.injectRedirectTo, loginPassport.loginPassportOidcCallback     , loginPassport.loginFailureForExternalAccount);
-  app.post('/passport/saml/callback'              , addActivity, loginPassport.injectRedirectTo, loginPassport.loginPassportSamlCallback, loginPassport.loginFailureForExternalAccount);
-
-  app.post('/_api/login/testLdap'    ,  accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]), loginRequiredStrictly , loginFormValidator.loginRules() , loginFormValidator.loginValidation , loginPassport.testLdapCredentials);
+  app.get(
+    '/passport/google',
+    loginPassport.loginWithGoogle,
+    loginPassport.loginFailureForExternalAccount,
+  );
+  app.get(
+    '/passport/github',
+    loginPassport.loginWithGitHub,
+    loginPassport.loginFailureForExternalAccount,
+  );
+  app.get(
+    '/passport/oidc',
+    loginPassport.loginWithOidc,
+    loginPassport.loginFailureForExternalAccount,
+  );
+  app.get(
+    '/passport/saml',
+    loginPassport.loginWithSaml,
+    loginPassport.loginFailureForExternalAccount,
+  );
+  app.get(
+    '/passport/google/callback',
+    loginPassport.injectRedirectTo,
+    loginPassport.loginPassportGoogleCallback,
+    loginPassport.loginFailureForExternalAccount,
+  );
+  app.get(
+    '/passport/github/callback',
+    loginPassport.injectRedirectTo,
+    loginPassport.loginPassportGitHubCallback,
+    loginPassport.loginFailureForExternalAccount,
+  );
+  app.get(
+    '/passport/oidc/callback',
+    loginPassport.injectRedirectTo,
+    loginPassport.loginPassportOidcCallback,
+    loginPassport.loginFailureForExternalAccount,
+  );
+  app.post(
+    '/passport/saml/callback',
+    addActivity,
+    loginPassport.injectRedirectTo,
+    loginPassport.loginPassportSamlCallback,
+    loginPassport.loginFailureForExternalAccount,
+  );
+
+  app.post(
+    '/_api/login/testLdap',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]),
+    loginRequiredStrictly,
+    loginFormValidator.loginRules(),
+    loginFormValidator.loginValidation,
+    loginPassport.testLdapCredentials,
+  );
 
 
   // importer management for admin
   // importer management for admin
-  app.post('/_api/admin/settings/importerEsa'   , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , addActivity, admin.importer.api.validators.importer.esa(),admin.api.importerSettingEsa);
-  app.post('/_api/admin/settings/importerQiita' , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , addActivity, admin.importer.api.validators.importer.qiita(), admin.api.importerSettingQiita);
-  app.post('/_api/admin/import/esa'             , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , addActivity, admin.api.importDataFromEsa);
-  app.post('/_api/admin/import/testEsaAPI'      , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , addActivity, admin.api.testEsaAPI);
-  app.post('/_api/admin/import/qiita'           , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , addActivity, admin.api.importDataFromQiita);
-  app.post('/_api/admin/import/testQiitaAPI'    , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , addActivity, admin.api.testQiitaAPI);
+  app.post(
+    '/_api/admin/settings/importerEsa',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    admin.importer.api.validators.importer.esa(),
+    admin.api.importerSettingEsa,
+  );
+  app.post(
+    '/_api/admin/settings/importerQiita',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    admin.importer.api.validators.importer.qiita(),
+    admin.api.importerSettingQiita,
+  );
+  app.post(
+    '/_api/admin/import/esa',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    admin.api.importDataFromEsa,
+  );
+  app.post(
+    '/_api/admin/import/testEsaAPI',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    admin.api.testEsaAPI,
+  );
+  app.post(
+    '/_api/admin/import/qiita',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    admin.api.importDataFromQiita,
+  );
+  app.post(
+    '/_api/admin/import/testQiitaAPI',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    admin.api.testQiitaAPI,
+  );
 
 
   // brand logo
   // brand logo
   app.use('/attachment', attachment.getBrandLogoRouterFactory(crowi));
   app.use('/attachment', attachment.getBrandLogoRouterFactory(crowi));
@@ -121,58 +246,200 @@ module.exports = function(crowi, app) {
 
 
   const apiV1Router = createApiRouter();
   const apiV1Router = createApiRouter();
 
 
-  apiV1Router.get('/search'              , accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }) , loginRequired , search.api.search);
+  apiV1Router.get(
+    '/search',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    search.api.search,
+  );
 
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  apiV1Router.get('/pages.updatePost'    , accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, page.api.getUpdatePost);
-  apiV1Router.get('/pages.getPageTag'    , accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }) , loginRequired , page.api.getPageTag);
+  apiV1Router.get(
+    '/pages.updatePost',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    page.api.getUpdatePost,
+  );
+  apiV1Router.get(
+    '/pages.getPageTag',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    page.api.getPageTag,
+  );
   // allow posting to guests because the client doesn't know whether the user logged in
   // allow posting to guests because the client doesn't know whether the user logged in
-  apiV1Router.post('/pages.remove'       , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly , excludeReadOnlyUser, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
-  apiV1Router.post('/pages.revertRemove' , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly , excludeReadOnlyUser, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
-  apiV1Router.post('/pages.unlink'       , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly , excludeReadOnlyUser, page.api.unlink); // (Avoid from API Token)
-  apiV1Router.get('/tags.list'           , accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, tag.api.list);
-  apiV1Router.get('/tags.search'         , accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, tag.api.search);
-  apiV1Router.post('/tags.update'        , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);
-  apiV1Router.get('/comments.get'        , accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }) , loginRequired , comment.api.get);
-  apiV1Router.post('/comments.add'       , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), comment.api.validators.add(), loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.add);
-  apiV1Router.post('/comments.update'    , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), comment.api.validators.add(), loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.update);
-  apiV1Router.post('/comments.remove'    , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.remove);
-
-  apiV1Router.post('/attachments.uploadProfileImage'   , accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT], { acceptLegacy: true }), loginRequiredStrictly , uploads.single('file'), autoReap, attachmentApi.uploadProfileImage);
-  apiV1Router.post('/attachments.remove'               , accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT], { acceptLegacy: true }), loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
-  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT], { acceptLegacy: true }), loginRequiredStrictly , attachmentApi.removeProfileImage);
+  apiV1Router.post(
+    '/pages.remove',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    page.validator.remove,
+    apiV1FormValidator,
+    page.api.remove,
+  ); // (Avoid from API Token)
+  apiV1Router.post(
+    '/pages.revertRemove',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    page.validator.revertRemove,
+    apiV1FormValidator,
+    page.api.revertRemove,
+  ); // (Avoid from API Token)
+  apiV1Router.post(
+    '/pages.unlink',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    page.api.unlink,
+  ); // (Avoid from API Token)
+  apiV1Router.get(
+    '/tags.list',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    tag.api.list,
+  );
+  apiV1Router.get(
+    '/tags.search',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    tag.api.search,
+  );
+  apiV1Router.post(
+    '/tags.update',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    tag.api.update,
+  );
+  apiV1Router.get(
+    '/comments.get',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    comment.api.get,
+  );
+  apiV1Router.post(
+    '/comments.add',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    comment.api.validators.add(),
+    loginRequiredStrictly,
+    excludeReadOnlyUserIfCommentNotAllowed,
+    addActivity,
+    comment.api.add,
+  );
+  apiV1Router.post(
+    '/comments.update',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    comment.api.validators.add(),
+    loginRequiredStrictly,
+    excludeReadOnlyUserIfCommentNotAllowed,
+    addActivity,
+    comment.api.update,
+  );
+  apiV1Router.post(
+    '/comments.remove',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    excludeReadOnlyUserIfCommentNotAllowed,
+    addActivity,
+    comment.api.remove,
+  );
+
+  apiV1Router.post(
+    '/attachments.uploadProfileImage',
+    accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    uploads.single('file'),
+    autoReap,
+    attachmentApi.uploadProfileImage,
+  );
+  apiV1Router.post(
+    '/attachments.remove',
+    accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    attachmentApi.remove,
+  );
+  apiV1Router.post(
+    '/attachments.removeProfileImage',
+    accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    attachmentApi.removeProfileImage,
+  );
 
 
   // API v1
   // API v1
   app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);
   app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);
 
 
   app.use(unavailableWhenMaintenanceMode);
   app.use(unavailableWhenMaintenanceMode);
 
 
-  app.get('/me'                                   , loginRequiredStrictly, next.delegateToNext);
-  app.get('/me/*'                                 , loginRequiredStrictly, next.delegateToNext);
-
-  app.use('/attachment', accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]), attachment.getRouterFactory(crowi));
-  app.use('/download', accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]), attachment.downloadRouterFactory(crowi));
-
-  app.get('/_search'                              , loginRequired, next.delegateToNext);
-
-  app.use('/forgot-password', express.Router()
-    .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
-    .get('/', forgotPassword.renderForgotPassword(crowi))
-    .get('/:token', injectResetOrderByTokenMiddleware, forgotPassword.renderResetPassword(crowi))
-    .use(forgotPassword.handleErrorsMiddleware(crowi)));
+  app.get('/me', loginRequiredStrictly, next.delegateToNext);
+  app.get('/me/*', loginRequiredStrictly, next.delegateToNext);
+
+  app.use(
+    '/attachment',
+    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]),
+    attachment.getRouterFactory(crowi),
+  );
+  app.use(
+    '/download',
+    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]),
+    attachment.downloadRouterFactory(crowi),
+  );
+
+  app.get('/_search', loginRequired, next.delegateToNext);
+
+  app.use(
+    '/forgot-password',
+    express
+      .Router()
+      .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
+      .get('/', forgotPassword.renderForgotPassword(crowi))
+      .get(
+        '/:token',
+        injectResetOrderByTokenMiddleware,
+        forgotPassword.renderResetPassword(crowi),
+      )
+      .use(forgotPassword.handleErrorsMiddleware(crowi)),
+  );
 
 
   app.get('/_private-legacy-pages', next.delegateToNext);
   app.get('/_private-legacy-pages', next.delegateToNext);
 
 
-  app.use('/user-activation', express.Router()
-    .get('/:token', applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.renderUserActivationPage(crowi))
-    .use(userActivation.tokenErrorHandlerMiddeware(crowi)));
+  app.use(
+    '/user-activation',
+    express
+      .Router()
+      .get(
+        '/:token',
+        applicationInstalled,
+        injectUserRegistrationOrderByTokenMiddleware,
+        userActivation.renderUserActivationPage(crowi),
+      )
+      .use(userActivation.tokenErrorHandlerMiddeware(crowi)),
+  );
 
 
   app.get('/share$', (req, res) => res.redirect('/'));
   app.get('/share$', (req, res) => res.redirect('/'));
   app.get('/share/:linkId', next.delegateToNext);
   app.get('/share/:linkId', next.delegateToNext);
 
 
-  app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));
-
-  app.get('/*/$'                   , loginRequired, next.delegateToNext);
-  app.get('/*'                     , loginRequired, autoReconnectToSearch, next.delegateToNext);
-
+  app.use(
+    '/ogp',
+    express
+      .Router()
+      .get(
+        '/:pageId([0-9a-z]{0,})',
+        loginRequired,
+        ogp.pageIdRequired,
+        ogp.ogpValidator,
+        ogp.renderOgp,
+      ),
+  );
+
+  app.get('/*/$', loginRequired, next.delegateToNext);
+  app.get('/*', loginRequired, autoReconnectToSearch, next.delegateToNext);
 };
 };

+ 256 - 106
apps/app/src/server/routes/login-passport.js

@@ -11,7 +11,7 @@ import { externalAccountService } from '../service/external-account';
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
-module.exports = function(crowi, app) {
+module.exports = (crowi, app) => {
   const logger = loggerFactory('growi:routes:login-passport');
   const logger = loggerFactory('growi:routes:login-passport');
   const passport = require('passport');
   const passport = require('passport');
   const passportService = crowi.passportService;
   const passportService = crowi.passportService;
@@ -23,14 +23,18 @@ module.exports = function(crowi, app) {
   const promisifiedPassportAuthentication = (strategyName, req, res) => {
   const promisifiedPassportAuthentication = (strategyName, req, res) => {
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
       passport.authenticate(strategyName, (err, response, info) => {
       passport.authenticate(strategyName, (err, response, info) => {
-        if (res.headersSent) { // dirty hack -- 2017.09.25
+        if (res.headersSent) {
+          // dirty hack -- 2017.09.25
           return; //              cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
           return; //              cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
         }
         }
 
 
         logger.debug(`--- authenticate with ${strategyName} strategy ---`);
         logger.debug(`--- authenticate with ${strategyName} strategy ---`);
 
 
         if (err) {
         if (err) {
-          logger.error(`'${strategyName}' passport authentication error: `, err);
+          logger.error(
+            `'${strategyName}' passport authentication error: `,
+            err,
+          );
           reject(err);
           reject(err);
         }
         }
 
 
@@ -52,8 +56,13 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} req
    * @param {*} res
    * @param {*} res
    */
    */
-  const loginSuccessHandler = async(req, res, user, action, isExternalAccount = false) => {
-
+  const loginSuccessHandler = async (
+    req,
+    res,
+    user,
+    action,
+    isExternalAccount = false,
+  ) => {
     // update lastLoginAt
     // update lastLoginAt
     user.updateLastLoginAt(new Date(), (err, userData) => {
     user.updateLastLoginAt(new Date(), (err, userData) => {
       if (err) {
       if (err) {
@@ -63,7 +72,7 @@ module.exports = function(crowi, app) {
     });
     });
 
 
     const parameters = {
     const parameters = {
-      ip:  req.ip,
+      ip: req.ip,
       endpoint: req.originalUrl,
       endpoint: req.originalUrl,
       action,
       action,
       user: req.user?._id,
       user: req.user?._id,
@@ -74,8 +83,11 @@ module.exports = function(crowi, app) {
 
 
     await crowi.activityService.createActivity(parameters);
     await crowi.activityService.createActivity(parameters);
 
 
-    const redirectToForUnauthenticated = createRedirectToForUnauthenticated(req.user.status);
-    const redirectTo = redirectToForUnauthenticated ?? res.locals.redirectTo ?? '/';
+    const redirectToForUnauthenticated = createRedirectToForUnauthenticated(
+      req.user.status,
+    );
+    const redirectTo =
+      redirectToForUnauthenticated ?? res.locals.redirectTo ?? '/';
 
 
     if (isExternalAccount) {
     if (isExternalAccount) {
       return res.safeRedirect(redirectTo);
       return res.safeRedirect(redirectTo);
@@ -85,7 +97,6 @@ module.exports = function(crowi, app) {
   };
   };
 
 
   const injectRedirectTo = (req, res, next) => {
   const injectRedirectTo = (req, res, next) => {
-
     // Move "req.session.redirectTo" to "res.locals.redirectTo"
     // Move "req.session.redirectTo" to "res.locals.redirectTo"
     // Because the session is regenerated when req.login() is called
     // Because the session is regenerated when req.login() is called
     const redirectTo = req.session.redirectTo;
     const redirectTo = req.session.redirectTo;
@@ -97,9 +108,17 @@ module.exports = function(crowi, app) {
   };
   };
 
 
   const isEnableLoginWithLocalOrLdap = (req, res, next) => {
   const isEnableLoginWithLocalOrLdap = (req, res, next) => {
-    if (!passportService.isLocalStrategySetup && !passportService.isLdapStrategySetup) {
+    if (
+      !passportService.isLocalStrategySetup &&
+      !passportService.isLdapStrategySetup
+    ) {
       logger.error('LocalStrategy and LdapStrategy has not been set up');
       logger.error('LocalStrategy and LdapStrategy has not been set up');
-      const error = new ErrorV3('message.strategy_has_not_been_set_up', '', undefined, { strategy: 'LocalStrategy and LdapStrategy' });
+      const error = new ErrorV3(
+        'message.strategy_has_not_been_set_up',
+        '',
+        undefined,
+        { strategy: 'LocalStrategy and LdapStrategy' },
+      );
       return next(error);
       return next(error);
     }
     }
 
 
@@ -120,15 +139,14 @@ module.exports = function(crowi, app) {
    * @param {*} next
    * @param {*} next
    */
    */
   const loginFailure = (error, req, res, next) => {
   const loginFailure = (error, req, res, next) => {
-
     const parameters = { action: SupportedAction.ACTION_USER_LOGIN_FAILURE };
     const parameters = { action: SupportedAction.ACTION_USER_LOGIN_FAILURE };
     activityEvent.emit('update', res.locals.activity._id, parameters);
     activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.apiv3Err(error);
     return res.apiv3Err(error);
   };
   };
 
 
-  const loginFailureForExternalAccount = async(error, req, res, next) => {
+  const loginFailureForExternalAccount = async (error, req, res, next) => {
     const parameters = {
     const parameters = {
-      ip:  req.ip,
+      ip: req.ip,
       endpoint: req.originalUrl,
       endpoint: req.originalUrl,
       action: SupportedAction.ACTION_USER_LOGIN_FAILURE,
       action: SupportedAction.ACTION_USER_LOGIN_FAILURE,
     };
     };
@@ -162,7 +180,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    * @param {*} res
    * @param {*} next
    * @param {*} next
    */
    */
-  const loginWithLdap = async(req, res, next) => {
+  const loginWithLdap = async (req, res, next) => {
     if (!passportService.isLdapStrategySetup) {
     if (!passportService.isLdapStrategySetup) {
       logger.debug('LdapStrategy has not been set up');
       logger.debug('LdapStrategy has not been set up');
       return next();
       return next();
@@ -177,9 +195,12 @@ module.exports = function(crowi, app) {
     let ldapAccountInfo;
     let ldapAccountInfo;
 
 
     try {
     try {
-      ldapAccountInfo = await promisifiedPassportAuthentication(strategyName, req, res);
-    }
-    catch (err) {
+      ldapAccountInfo = await promisifiedPassportAuthentication(
+        strategyName,
+        req,
+        res,
+      );
+    } catch (err) {
       logger.debug(err.message);
       logger.debug(err.message);
       return next(err);
       return next(err);
     }
     }
@@ -190,8 +211,8 @@ module.exports = function(crowi, app) {
     }
     }
 
 
     /*
     /*
-      * authentication success
-      */
+     * authentication success
+     */
     // it is guaranteed that username that is input from form can be acquired
     // it is guaranteed that username that is input from form can be acquired
     // because this processes after authentication
     // because this processes after authentication
     const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
     const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
@@ -211,9 +232,11 @@ module.exports = function(crowi, app) {
 
 
     let externalAccount;
     let externalAccount;
     try {
     try {
-      externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
-    }
-    catch (error) {
+      externalAccount = await externalAccountService.getOrCreateUser(
+        userInfo,
+        providerId,
+      );
+    } catch (error) {
       return next(error);
       return next(error);
     }
     }
 
 
@@ -231,7 +254,13 @@ module.exports = function(crowi, app) {
         return next(err);
         return next(err);
       }
       }
 
 
-      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_LDAP, true);
+      return loginSuccessHandler(
+        req,
+        res,
+        user,
+        SupportedAction.ACTION_USER_LOGIN_WITH_LDAP,
+        true,
+      );
     });
     });
   };
   };
 
 
@@ -241,54 +270,69 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} req
    * @param {*} res
    * @param {*} res
    */
    */
-  const testLdapCredentials = async(req, res) => {
+  const testLdapCredentials = async (req, res) => {
     const { t } = await getTranslation({ lang: req.user.lang });
     const { t } = await getTranslation({ lang: req.user.lang });
 
 
     if (!passportService.isLdapStrategySetup) {
     if (!passportService.isLdapStrategySetup) {
       logger.debug('LdapStrategy has not been set up');
       logger.debug('LdapStrategy has not been set up');
-      return res.json(ApiResponse.success({
-        status: 'warning',
-        message: t('message.strategy_has_not_been_set_up', { strategy: 'LdapStrategy' }),
-      }));
+      return res.json(
+        ApiResponse.success({
+          status: 'warning',
+          message: t('message.strategy_has_not_been_set_up', {
+            strategy: 'LdapStrategy',
+          }),
+        }),
+      );
     }
     }
 
 
     passport.authenticate('ldapauth', (err, user, info) => {
     passport.authenticate('ldapauth', (err, user, info) => {
-      if (res.headersSent) { // dirty hack -- 2017.09.25
+      if (res.headersSent) {
+        // dirty hack -- 2017.09.25
         return; //              cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
         return; //              cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
       }
       }
 
 
-      if (err) { // DB Error
+      if (err) {
+        // DB Error
         logger.error('LDAP Server Error: ', err);
         logger.error('LDAP Server Error: ', err);
-        return res.json(ApiResponse.success({
-          status: 'warning',
-          message: 'LDAP Server Error occured.',
-          err,
-        }));
+        return res.json(
+          ApiResponse.success({
+            status: 'warning',
+            message: 'LDAP Server Error occured.',
+            err,
+          }),
+        );
       }
       }
       if (info && info.message) {
       if (info && info.message) {
-        return res.json(ApiResponse.success({
-          status: 'warning',
-          message: info.message,
-          ldapConfiguration: req.ldapConfiguration,
-          ldapAccountInfo: req.ldapAccountInfo,
-        }));
+        return res.json(
+          ApiResponse.success({
+            status: 'warning',
+            message: info.message,
+            ldapConfiguration: req.ldapConfiguration,
+            ldapAccountInfo: req.ldapAccountInfo,
+          }),
+        );
       }
       }
       if (user) {
       if (user) {
         // check groups
         // check groups
         if (!isValidLdapUserByGroupFilter(user)) {
         if (!isValidLdapUserByGroupFilter(user)) {
-          return res.json(ApiResponse.success({
-            status: 'warning',
-            message: 'This user does not belong to any groups designated by the group search filter.',
+          return res.json(
+            ApiResponse.success({
+              status: 'warning',
+              message:
+                'This user does not belong to any groups designated by the group search filter.',
+              ldapConfiguration: req.ldapConfiguration,
+              ldapAccountInfo: req.ldapAccountInfo,
+            }),
+          );
+        }
+        return res.json(
+          ApiResponse.success({
+            status: 'success',
+            message: 'Successfully authenticated.',
             ldapConfiguration: req.ldapConfiguration,
             ldapConfiguration: req.ldapConfiguration,
             ldapAccountInfo: req.ldapAccountInfo,
             ldapAccountInfo: req.ldapAccountInfo,
-          }));
-        }
-        return res.json(ApiResponse.success({
-          status: 'success',
-          message: 'Successfully authenticated.',
-          ldapConfiguration: req.ldapConfiguration,
-          ldapAccountInfo: req.ldapAccountInfo,
-        }));
+          }),
+        );
       }
       }
     })(req, res, () => {});
     })(req, res, () => {});
   };
   };
@@ -314,7 +358,8 @@ module.exports = function(crowi, app) {
       logger.debug('user', user);
       logger.debug('user', user);
       logger.debug('info', info);
       logger.debug('info', info);
 
 
-      if (err) { // DB Error
+      if (err) {
+        // DB Error
         logger.error('Database Server Error: ', err);
         logger.error('Database Server Error: ', err);
         return next(err);
         return next(err);
       }
       }
@@ -327,15 +372,23 @@ module.exports = function(crowi, app) {
           return next(err);
           return next(err);
         }
         }
 
 
-        return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_LOCAL);
+        return loginSuccessHandler(
+          req,
+          res,
+          user,
+          SupportedAction.ACTION_USER_LOGIN_WITH_LOCAL,
+        );
       });
       });
     })(req, res, next);
     })(req, res, next);
   };
   };
 
 
-  const loginWithGoogle = function(req, res, next) {
+  const loginWithGoogle = (req, res, next) => {
     if (!passportService.isGoogleStrategySetup) {
     if (!passportService.isGoogleStrategySetup) {
       logger.debug('GoogleStrategy has not been set up');
       logger.debug('GoogleStrategy has not been set up');
-      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'GoogleStrategy' });
+      const error = new ExternalAccountLoginError(
+        'message.strategy_has_not_been_set_up',
+        { strategy: 'GoogleStrategy' },
+      );
       return next(error);
       return next(error);
     }
     }
 
 
@@ -344,7 +397,7 @@ module.exports = function(crowi, app) {
     })(req, res);
     })(req, res);
   };
   };
 
 
-  const loginPassportGoogleCallback = async(req, res, next) => {
+  const loginPassportGoogleCallback = async (req, res, next) => {
     const globalLang = crowi.configManager.getConfig('app:globalLang');
     const globalLang = crowi.configManager.getConfig('app:globalLang');
 
 
     const providerId = 'google';
     const providerId = 'google';
@@ -352,9 +405,12 @@ module.exports = function(crowi, app) {
 
 
     let response;
     let response;
     try {
     try {
-      response = await promisifiedPassportAuthentication(strategyName, req, res);
-    }
-    catch (err) {
+      response = await promisifiedPassportAuthentication(
+        strategyName,
+        req,
+        res,
+      );
+    } catch (err) {
       return next(new ExternalAccountLoginError(err.message));
       return next(new ExternalAccountLoginError(err.message));
     }
     }
 
 
@@ -387,7 +443,10 @@ module.exports = function(crowi, app) {
       userInfo.username = userInfo.email.slice(0, userInfo.email.indexOf('@'));
       userInfo.username = userInfo.email.slice(0, userInfo.email.indexOf('@'));
     }
     }
 
 
-    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(
+      userInfo,
+      providerId,
+    );
     if (!externalAccount) {
     if (!externalAccount) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
     }
@@ -395,32 +454,47 @@ module.exports = function(crowi, app) {
     const user = (await externalAccount.populate('user')).user;
     const user = (await externalAccount.populate('user')).user;
 
 
     // login
     // login
-    req.logIn(user, async(err) => {
-      if (err) { logger.debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
+    req.logIn(user, async (err) => {
+      if (err) {
+        logger.debug(err.message);
+        return next(new ExternalAccountLoginError(err.message));
+      }
 
 
-      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_GOOGLE, true);
+      return loginSuccessHandler(
+        req,
+        res,
+        user,
+        SupportedAction.ACTION_USER_LOGIN_WITH_GOOGLE,
+        true,
+      );
     });
     });
   };
   };
 
 
-  const loginWithGitHub = function(req, res, next) {
+  const loginWithGitHub = (req, res, next) => {
     if (!passportService.isGitHubStrategySetup) {
     if (!passportService.isGitHubStrategySetup) {
       logger.debug('GitHubStrategy has not been set up');
       logger.debug('GitHubStrategy has not been set up');
-      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'GitHubStrategy' });
+      const error = new ExternalAccountLoginError(
+        'message.strategy_has_not_been_set_up',
+        { strategy: 'GitHubStrategy' },
+      );
       return next(error);
       return next(error);
     }
     }
 
 
     passport.authenticate('github')(req, res);
     passport.authenticate('github')(req, res);
   };
   };
 
 
-  const loginPassportGitHubCallback = async(req, res, next) => {
+  const loginPassportGitHubCallback = async (req, res, next) => {
     const providerId = 'github';
     const providerId = 'github';
     const strategyName = 'github';
     const strategyName = 'github';
 
 
     let response;
     let response;
     try {
     try {
-      response = await promisifiedPassportAuthentication(strategyName, req, res);
-    }
-    catch (err) {
+      response = await promisifiedPassportAuthentication(
+        strategyName,
+        req,
+        res,
+      );
+    } catch (err) {
       return next(new ExternalAccountLoginError(err.message));
       return next(new ExternalAccountLoginError(err.message));
     }
     }
 
 
@@ -430,7 +504,10 @@ module.exports = function(crowi, app) {
       name: response.displayName,
       name: response.displayName,
     };
     };
 
 
-    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(
+      userInfo,
+      providerId,
+    );
     if (!externalAccount) {
     if (!externalAccount) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
     }
@@ -438,36 +515,59 @@ module.exports = function(crowi, app) {
     const user = (await externalAccount.populate('user')).user;
     const user = (await externalAccount.populate('user')).user;
 
 
     // login
     // login
-    req.logIn(user, async(err) => {
-      if (err) { logger.debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
+    req.logIn(user, async (err) => {
+      if (err) {
+        logger.debug(err.message);
+        return next(new ExternalAccountLoginError(err.message));
+      }
 
 
-      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_GITHUB, true);
+      return loginSuccessHandler(
+        req,
+        res,
+        user,
+        SupportedAction.ACTION_USER_LOGIN_WITH_GITHUB,
+        true,
+      );
     });
     });
   };
   };
 
 
-  const loginWithOidc = function(req, res, next) {
+  const loginWithOidc = (req, res, next) => {
     if (!passportService.isOidcStrategySetup) {
     if (!passportService.isOidcStrategySetup) {
       logger.debug('OidcStrategy has not been set up');
       logger.debug('OidcStrategy has not been set up');
-      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'OidcStrategy' });
+      const error = new ExternalAccountLoginError(
+        'message.strategy_has_not_been_set_up',
+        { strategy: 'OidcStrategy' },
+      );
       return next(error);
       return next(error);
     }
     }
 
 
     passport.authenticate('oidc')(req, res);
     passport.authenticate('oidc')(req, res);
   };
   };
 
 
-  const loginPassportOidcCallback = async(req, res, next) => {
+  const loginPassportOidcCallback = async (req, res, next) => {
     const providerId = 'oidc';
     const providerId = 'oidc';
     const strategyName = 'oidc';
     const strategyName = 'oidc';
-    const attrMapId = crowi.configManager.getConfig('security:passport-oidc:attrMapId');
-    const attrMapUserName = crowi.configManager.getConfig('security:passport-oidc:attrMapUserName');
-    const attrMapName = crowi.configManager.getConfig('security:passport-oidc:attrMapName');
-    const attrMapMail = crowi.configManager.getConfig('security:passport-oidc:attrMapMail');
+    const attrMapId = crowi.configManager.getConfig(
+      'security:passport-oidc:attrMapId',
+    );
+    const attrMapUserName = crowi.configManager.getConfig(
+      'security:passport-oidc:attrMapUserName',
+    );
+    const attrMapName = crowi.configManager.getConfig(
+      'security:passport-oidc:attrMapName',
+    );
+    const attrMapMail = crowi.configManager.getConfig(
+      'security:passport-oidc:attrMapMail',
+    );
 
 
     let response;
     let response;
     try {
     try {
-      response = await promisifiedPassportAuthentication(strategyName, req, res);
-    }
-    catch (err) {
+      response = await promisifiedPassportAuthentication(
+        strategyName,
+        req,
+        res,
+      );
+    } catch (err) {
       logger.debug(err);
       logger.debug(err);
       return next(new ExternalAccountLoginError(err.message));
       return next(new ExternalAccountLoginError(err.message));
     }
     }
@@ -478,46 +578,82 @@ module.exports = function(crowi, app) {
       name: response[attrMapName],
       name: response[attrMapName],
       email: response[attrMapMail],
       email: response[attrMapMail],
     };
     };
-    logger.debug('mapping response to userInfo', userInfo, response, attrMapId, attrMapUserName, attrMapMail);
-
-    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
+    logger.debug(
+      'mapping response to userInfo',
+      userInfo,
+      response,
+      attrMapId,
+      attrMapUserName,
+      attrMapMail,
+    );
+
+    const externalAccount = await externalAccountService.getOrCreateUser(
+      userInfo,
+      providerId,
+    );
     if (!externalAccount) {
     if (!externalAccount) {
       return new ExternalAccountLoginError('message.sign_in_failure');
       return new ExternalAccountLoginError('message.sign_in_failure');
     }
     }
 
 
     // login
     // login
     const user = (await externalAccount.populate('user')).user;
     const user = (await externalAccount.populate('user')).user;
-    req.logIn(user, async(err) => {
-      if (err) { logger.debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
+    req.logIn(user, async (err) => {
+      if (err) {
+        logger.debug(err.message);
+        return next(new ExternalAccountLoginError(err.message));
+      }
 
 
-      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_OIDC, true);
+      return loginSuccessHandler(
+        req,
+        res,
+        user,
+        SupportedAction.ACTION_USER_LOGIN_WITH_OIDC,
+        true,
+      );
     });
     });
   };
   };
 
 
-  const loginWithSaml = function(req, res, next) {
+  const loginWithSaml = (req, res, next) => {
     if (!passportService.isSamlStrategySetup) {
     if (!passportService.isSamlStrategySetup) {
       logger.debug('SamlStrategy has not been set up');
       logger.debug('SamlStrategy has not been set up');
-      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'SamlStrategy' });
+      const error = new ExternalAccountLoginError(
+        'message.strategy_has_not_been_set_up',
+        { strategy: 'SamlStrategy' },
+      );
       return next(error);
       return next(error);
     }
     }
 
 
     passport.authenticate('saml')(req, res);
     passport.authenticate('saml')(req, res);
   };
   };
 
 
-  const loginPassportSamlCallback = async(req, res, next) => {
+  const loginPassportSamlCallback = async (req, res, next) => {
     const providerId = 'saml';
     const providerId = 'saml';
     const strategyName = 'saml';
     const strategyName = 'saml';
-    const attrMapId = crowi.configManager.getConfig('security:passport-saml:attrMapId');
-    const attrMapUsername = crowi.configManager.getConfig('security:passport-saml:attrMapUsername');
-    const attrMapMail = crowi.configManager.getConfig('security:passport-saml:attrMapMail');
-    const attrMapFirstName = crowi.configManager.getConfig('security:passport-saml:attrMapFirstName') || 'firstName';
-    const attrMapLastName = crowi.configManager.getConfig('security:passport-saml:attrMapLastName') || 'lastName';
+    const attrMapId = crowi.configManager.getConfig(
+      'security:passport-saml:attrMapId',
+    );
+    const attrMapUsername = crowi.configManager.getConfig(
+      'security:passport-saml:attrMapUsername',
+    );
+    const attrMapMail = crowi.configManager.getConfig(
+      'security:passport-saml:attrMapMail',
+    );
+    const attrMapFirstName =
+      crowi.configManager.getConfig(
+        'security:passport-saml:attrMapFirstName',
+      ) || 'firstName';
+    const attrMapLastName =
+      crowi.configManager.getConfig('security:passport-saml:attrMapLastName') ||
+      'lastName';
 
 
     let response;
     let response;
     try {
     try {
-      response = await promisifiedPassportAuthentication(strategyName, req, res);
-    }
-    catch (err) {
+      response = await promisifiedPassportAuthentication(
+        strategyName,
+        req,
+        res,
+      );
+    } catch (err) {
       return next(new ExternalAccountLoginError(err.message));
       return next(new ExternalAccountLoginError(err.message));
     }
     }
 
 
@@ -531,15 +667,23 @@ module.exports = function(crowi, app) {
     const firstName = response[attrMapFirstName];
     const firstName = response[attrMapFirstName];
     const lastName = response[attrMapLastName];
     const lastName = response[attrMapLastName];
     if (firstName != null || lastName != null) {
     if (firstName != null || lastName != null) {
-      userInfo.name = `${response[attrMapFirstName]} ${response[attrMapLastName]}`.trim();
+      userInfo.name =
+        `${response[attrMapFirstName]} ${response[attrMapLastName]}`.trim();
     }
     }
 
 
     // Attribute-based Login Control
     // Attribute-based Login Control
     if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
     if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
-      return next(new ExternalAccountLoginError('Sign in failure due to insufficient privileges.'));
+      return next(
+        new ExternalAccountLoginError(
+          'Sign in failure due to insufficient privileges.',
+        ),
+      );
     }
     }
 
 
-    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(
+      userInfo,
+      providerId,
+    );
     if (!externalAccount) {
     if (!externalAccount) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
     }
@@ -553,7 +697,13 @@ module.exports = function(crowi, app) {
         return next(new ExternalAccountLoginError(err.message));
         return next(new ExternalAccountLoginError(err.message));
       }
       }
 
 
-      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_SAML, true);
+      return loginSuccessHandler(
+        req,
+        res,
+        user,
+        SupportedAction.ACTION_USER_LOGIN_WITH_SAML,
+        true,
+      );
     });
     });
   };
   };
 
 

+ 54 - 34
apps/app/src/server/routes/login.js

@@ -8,13 +8,11 @@ import { growiInfoService } from '../service/growi-info';
 // because this file is a deprecated legacy of Crowi
 // because this file is a deprecated legacy of Crowi
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
-module.exports = function(crowi, app) {
+module.exports = (crowi, app) => {
   const logger = loggerFactory('growi:routes:login');
   const logger = loggerFactory('growi:routes:login');
   const path = require('path');
   const path = require('path');
   const User = crowi.model('User');
   const User = crowi.model('User');
-  const {
-    appService, aclService, mailService, activityService,
-  } = crowi;
+  const { appService, aclService, mailService, activityService } = crowi;
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
   const actions = {};
   const actions = {};
@@ -29,7 +27,10 @@ module.exports = function(crowi, app) {
       return mailService.send({
       return mailService.send({
         to: admin.email,
         to: admin.email,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
-        template: path.join(crowi.localeDir, `${locale}/admin/userWaitingActivation.ejs`),
+        template: path.join(
+          crowi.localeDir,
+          `${locale}/admin/userWaitingActivation.ejs`,
+        ),
         vars: {
         vars: {
           adminUser: admin,
           adminUser: admin,
           createdUser: userData,
           createdUser: userData,
@@ -41,12 +42,13 @@ module.exports = function(crowi, app) {
 
 
     const results = await Promise.allSettled(promises);
     const results = await Promise.allSettled(promises);
     results
     results
-      .filter(result => result.status === 'rejected')
-      .forEach(result => logger.error(result.reason));
+      .filter((result) => result.status === 'rejected')
+      .forEach((result) => {
+        logger.error(result.reason);
+      });
   }
   }
 
 
   async function sendNotificationToAllAdmins(user) {
   async function sendNotificationToAllAdmins(user) {
-
     const activity = await activityService.createActivity({
     const activity = await activityService.createActivity({
       action: SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
       action: SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
       target: user,
       target: user,
@@ -56,7 +58,7 @@ module.exports = function(crowi, app) {
     /**
     /**
      * @param {import('../service/pre-notify').PreNotifyProps} props
      * @param {import('../service/pre-notify').PreNotifyProps} props
      */
      */
-    const preNotify = async(props) => {
+    const preNotify = async (props) => {
       /** @type {(import('mongoose').HydratedDocument<import('@growi/core').IUser>)[]} */
       /** @type {(import('mongoose').HydratedDocument<import('@growi/core').IUser>)[]} */
       const adminUsers = await User.findAdmins();
       const adminUsers = await User.findAdmins();
 
 
@@ -68,13 +70,23 @@ module.exports = function(crowi, app) {
     return;
     return;
   }
   }
 
 
-  const registerSuccessHandler = async function(req, res, userData, registrationMode) {
-    const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
+  const registerSuccessHandler = async (
+    req,
+    res,
+    userData,
+    registrationMode,
+  ) => {
+    const parameters = {
+      action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS,
+    };
     activityEvent.emit('update', res.locals.activity._id, parameters);
     activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
     const isMailerSetup = mailService.isMailerSetup ?? false;
     const isMailerSetup = mailService.isMailerSetup ?? false;
 
 
-    if (registrationMode === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+    if (
+      registrationMode ===
+      aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED
+    ) {
       sendNotificationToAllAdmins(userData);
       sendNotificationToAllAdmins(userData);
       if (isMailerSetup) {
       if (isMailerSetup) {
         await sendEmailToAllAdmins(userData);
         await sendEmailToAllAdmins(userData);
@@ -117,8 +129,7 @@ module.exports = function(crowi, app) {
     req.login(userData, (err) => {
     req.login(userData, (err) => {
       if (err) {
       if (err) {
         logger.debug(err);
         logger.debug(err);
-      }
-      else {
+      } else {
         // update lastLoginAt
         // update lastLoginAt
         userData.updateLastLoginAt(new Date(), (err) => {
         userData.updateLastLoginAt(new Date(), (err) => {
           if (err) {
           if (err) {
@@ -132,12 +143,10 @@ module.exports = function(crowi, app) {
         // userData.password can't be empty but, prepare redirect because password property in User Model is optional
         // userData.password can't be empty but, prepare redirect because password property in User Model is optional
         // https://github.com/growilabs/growi/pull/6670
         // https://github.com/growilabs/growi/pull/6670
         redirectTo = '/me#password_settings';
         redirectTo = '/me#password_settings';
-      }
-      else if (req.session.redirectTo != null) {
+      } else if (req.session.redirectTo != null) {
         redirectTo = req.session.redirectTo;
         redirectTo = req.session.redirectTo;
         delete req.session.redirectTo;
         delete req.session.redirectTo;
-      }
-      else {
+      } else {
         redirectTo = '/';
         redirectTo = '/';
       }
       }
 
 
@@ -145,7 +154,7 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
-  actions.preLogin = function(req, res, next) {
+  actions.preLogin = (req, res, next) => {
     // user has already logged in
     // user has already logged in
     const { user } = req;
     const { user } = req;
     if (user != null && user.status === User.STATUS_ACTIVE) {
     if (user != null && user.status === User.STATUS_ACTIVE) {
@@ -199,13 +208,16 @@ module.exports = function(crowi, app) {
    *                 redirectTo:
    *                 redirectTo:
    *                   type: string
    *                   type: string
    */
    */
-  actions.register = function(req, res) {
+  actions.register = (req, res) => {
     if (req.user != null) {
     if (req.user != null) {
       return res.apiv3Err('message.user_already_logged_in', 403);
       return res.apiv3Err('message.user_already_logged_in', 403);
     }
     }
 
 
     // config で closed ならさよなら
     // config で closed ならさよなら
-    if (configManager.getConfig('security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED) {
+    if (
+      configManager.getConfig('security:registrationMode') ===
+      aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED
+    ) {
       return res.apiv3Err('message.registration_closed', 403);
       return res.apiv3Err('message.registration_closed', 403);
     }
     }
 
 
@@ -240,21 +252,29 @@ module.exports = function(crowi, app) {
         return res.apiv3Err(errors, 400);
         return res.apiv3Err(errors, 400);
       }
       }
 
 
-      const registrationMode = configManager.getConfig('security:registrationMode');
+      const registrationMode = configManager.getConfig(
+        'security:registrationMode',
+      );
 
 
-      User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
-        if (err) {
-          const errors = [];
-          if (err.name === 'UserUpperLimitException') {
-            errors.push('message.can_not_register_maximum_number_of_users');
-          }
-          else {
-            errors.push('message.failed_to_register');
+      User.createUserByEmailAndPassword(
+        name,
+        username,
+        email,
+        password,
+        undefined,
+        async (err, userData) => {
+          if (err) {
+            const errors = [];
+            if (err.name === 'UserUpperLimitException') {
+              errors.push('message.can_not_register_maximum_number_of_users');
+            } else {
+              errors.push('message.failed_to_register');
+            }
+            return res.apiv3Err(errors, 405);
           }
           }
-          return res.apiv3Err(errors, 405);
-        }
-        return registerSuccessHandler(req, res, userData, registrationMode);
-      });
+          return registerSuccessHandler(req, res, userData, registrationMode);
+        },
+      );
     });
     });
   };
   };
 
 

+ 10 - 10
apps/app/src/server/routes/next.ts

@@ -1,25 +1,26 @@
-import type { IncomingMessage } from 'http';
-
 import type { NextServer, RequestHandler } from 'next/dist/server/next';
 import type { NextServer, RequestHandler } from 'next/dist/server/next';
+import type { IncomingMessage } from 'http';
 
 
 type Crowi = {
 type Crowi = {
-  nextApp: NextServer,
-}
+  nextApp: NextServer;
+};
 
 
 type CrowiReq = IncomingMessage & {
 type CrowiReq = IncomingMessage & {
-  crowi: Crowi,
-}
+  crowi: Crowi;
+};
 
 
 type NextDelegatorResult = {
 type NextDelegatorResult = {
-  delegateToNext: RequestHandler,
+  delegateToNext: RequestHandler;
 };
 };
 
 
 const delegator = (crowi: Crowi): NextDelegatorResult => {
 const delegator = (crowi: Crowi): NextDelegatorResult => {
-
   const { nextApp } = crowi;
   const { nextApp } = crowi;
   const handle = nextApp.getRequestHandler();
   const handle = nextApp.getRequestHandler();
 
 
-  const delegateToNext: RequestHandler = (req: CrowiReq, res): Promise<void> => {
+  const delegateToNext: RequestHandler = (
+    req: CrowiReq,
+    res,
+  ): Promise<void> => {
     req.crowi = crowi;
     req.crowi = crowi;
     return handle(req, res);
     return handle(req, res);
   };
   };
@@ -27,7 +28,6 @@ const delegator = (crowi: Crowi): NextDelegatorResult => {
   return {
   return {
     delegateToNext,
     delegateToNext,
   };
   };
-
 };
 };
 
 
 export default delegator;
 export default delegator;

+ 53 - 42
apps/app/src/server/routes/ogp.ts

@@ -1,17 +1,14 @@
-import * as fs from 'fs';
-import path from 'path';
-
 import { getIdStringForRef, type IUser } from '@growi/core';
 import { getIdStringForRef, type IUser } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { DevidedPagePath } from '@growi/core/dist/models';
 // biome-ignore lint/style/noRestrictedImports: Direct axios usage for OGP image fetching
 // biome-ignore lint/style/noRestrictedImports: Direct axios usage for OGP image fetching
 import axios from 'axios';
 import axios from 'axios';
-import type {
-  Request, Response, NextFunction,
-} from 'express';
+import type { NextFunction, Request, Response } from 'express';
 import type { ValidationError } from 'express-validator';
 import type { ValidationError } from 'express-validator';
 import { param, validationResult } from 'express-validator';
 import { param, validationResult } from 'express-validator';
+import * as fs from 'fs';
 import type { HydratedDocument } from 'mongoose';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
+import path from 'path';
 
 
 import { projectRoot } from '~/server/util/project-dir-utils';
 import { projectRoot } from '~/server/util/project-dir-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -33,15 +30,14 @@ fs.readFile(path.join(projectRoot, DEFAULT_USER_IMAGE_PATH), (err, buffer) => {
   bufferedDefaultUserImageCache = buffer;
   bufferedDefaultUserImageCache = buffer;
 });
 });
 
 
-
-module.exports = function(crowi: Crowi) {
-
+module.exports = (crowi: Crowi) => {
   const isUserImageAttachment = (userImageUrlCached: string): boolean => {
   const isUserImageAttachment = (userImageUrlCached: string): boolean => {
     return /^\/attachment\/.+/.test(userImageUrlCached);
     return /^\/attachment\/.+/.test(userImageUrlCached);
   };
   };
 
 
-  const getBufferedUserImage = async(userImageUrlCached: string): Promise<Buffer | null> => {
-
+  const getBufferedUserImage = async (
+    userImageUrlCached: string,
+  ): Promise<Buffer | null> => {
     let bufferedUserImage: Buffer;
     let bufferedUserImage: Buffer;
 
 
     if (isUserImageAttachment(userImageUrlCached)) {
     if (isUserImageAttachment(userImageUrlCached)) {
@@ -57,25 +53,25 @@ module.exports = function(crowi: Crowi) {
       return bufferedUserImage;
       return bufferedUserImage;
     }
     }
 
 
-    return (await axios.get(
-      userImageUrlCached, {
+    return (
+      await axios.get(userImageUrlCached, {
         responseType: 'arraybuffer',
         responseType: 'arraybuffer',
-      },
-    )).data;
-
+      })
+    ).data;
   };
   };
 
 
-  const renderOgp = async(req: Request, res: Response) => {
-
+  const renderOgp = async (req: Request, res: Response) => {
     const ogpUri = configManager.getConfig('app:ogpUri');
     const ogpUri = configManager.getConfig('app:ogpUri');
 
 
     if (ogpUri == null) {
     if (ogpUri == null) {
-      return res.status(501).send('OGP_URI for growi-unique-ogp has not been setup');
+      return res
+        .status(501)
+        .send('OGP_URI for growi-unique-ogp has not been setup');
     }
     }
 
 
     const page: PageDocument = req.body.page; // asserted by ogpValidator
     const page: PageDocument = req.body.page; // asserted by ogpValidator
 
 
-    const title = (new DevidedPagePath(page.path)).latter;
+    const title = new DevidedPagePath(page.path).latter;
 
 
     let user: IUser | null = null;
     let user: IUser | null = null;
     let userName = '(unknown)';
     let userName = '(unknown)';
@@ -88,32 +84,34 @@ module.exports = function(crowi: Crowi) {
 
 
         if (user != null) {
         if (user != null) {
           userName = user.username;
           userName = user.username;
-          userImage = user.imageUrlCached !== DEFAULT_USER_IMAGE_URL
-            ? bufferedDefaultUserImageCache
-            : await getBufferedUserImage(user.imageUrlCached) ?? bufferedDefaultUserImageCache;
+          userImage =
+            user.imageUrlCached !== DEFAULT_USER_IMAGE_URL
+              ? bufferedDefaultUserImageCache
+              : ((await getBufferedUserImage(user.imageUrlCached)) ??
+                bufferedDefaultUserImageCache);
         }
         }
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       return res.status(500).send(`error: ${err}`);
       return res.status(500).send(`error: ${err}`);
     }
     }
 
 
-    let result;
+    let result: { data: any };
     try {
     try {
       result = await axios.post(
       result = await axios.post(
-        ogpUri, {
+        ogpUri,
+        {
           data: {
           data: {
             title,
             title,
             userName,
             userName,
             userImage,
             userImage,
           },
           },
-        }, {
+        },
+        {
           responseType: 'stream',
           responseType: 'stream',
         },
         },
       );
       );
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       return res.status(500).send(`error: ${err}`);
       return res.status(500).send(`error: ${err}`);
     }
     }
@@ -122,36 +120,50 @@ module.exports = function(crowi: Crowi) {
       'Content-Type': 'image/jpeg',
       'Content-Type': 'image/jpeg',
     });
     });
     result.data.pipe(res);
     result.data.pipe(res);
-
   };
   };
 
 
-  const pageIdRequired = param('pageId').not().isEmpty().withMessage('page id is not included in the parameter');
+  const pageIdRequired = param('pageId')
+    .not()
+    .isEmpty()
+    .withMessage('page id is not included in the parameter');
 
 
-  const ogpValidator = async(req:Request, res:Response, next:NextFunction) => {
+  const ogpValidator = async (
+    req: Request,
+    res: Response,
+    next: NextFunction,
+  ) => {
     const { aclService, fileUploadService, configManager } = crowi;
     const { aclService, fileUploadService, configManager } = crowi;
 
 
     const ogpUri = configManager.getConfig('app:ogpUri');
     const ogpUri = configManager.getConfig('app:ogpUri');
 
 
-    if (ogpUri == null) return res.status(400).send('OGP URI for GROWI has not been setup');
-    if (!fileUploadService.getIsUploadable()) return res.status(501).send('This GROWI can not upload file');
-    if (!aclService.isGuestAllowedToRead()) return res.status(501).send('This GROWI is not public');
+    if (ogpUri == null)
+      return res.status(400).send('OGP URI for GROWI has not been setup');
+    if (!fileUploadService.getIsUploadable())
+      return res.status(501).send('This GROWI can not upload file');
+    if (!aclService.isGuestAllowedToRead())
+      return res.status(501).send('This GROWI is not public');
 
 
     const errors = validationResult(req);
     const errors = validationResult(req);
 
 
     if (errors.isEmpty()) {
     if (errors.isEmpty()) {
-
-      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+        'Page',
+      );
 
 
       try {
       try {
         const page = await Page.findByIdAndViewer(req.params.pageId, null);
         const page = await Page.findByIdAndViewer(req.params.pageId, null);
 
 
-        if (page == null || page.status !== Page.STATUS_PUBLISHED || (page.grant !== Page.GRANT_PUBLIC && page.grant !== Page.GRANT_RESTRICTED)) {
+        if (
+          page == null ||
+          page.status !== Page.STATUS_PUBLISHED ||
+          (page.grant !== Page.GRANT_PUBLIC &&
+            page.grant !== Page.GRANT_RESTRICTED)
+        ) {
           return res.status(400).send('the page does not exist');
           return res.status(400).send('the page does not exist');
         }
         }
 
 
         req.body.page = page;
         req.body.page = page;
-      }
-      catch (error) {
+      } catch (error) {
         logger.error(error);
         logger.error(error);
         return res.status(500).send(`error: ${error}`);
         return res.status(500).send(`error: ${error}`);
       }
       }
@@ -170,5 +182,4 @@ module.exports = function(crowi: Crowi) {
     pageIdRequired,
     pageIdRequired,
     ogpValidator,
     ogpValidator,
   };
   };
-
 };
 };

+ 119 - 48
apps/app/src/server/routes/page.js

@@ -3,8 +3,8 @@ import mongoose from 'mongoose';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { GlobalNotificationSettingEvent } from '../models/GlobalNotificationSetting';
 import { PathAlreadyExistsError } from '../models/errors';
 import { PathAlreadyExistsError } from '../models/errors';
+import { GlobalNotificationSettingEvent } from '../models/GlobalNotificationSetting';
 import PageTagRelation from '../models/page-tag-relation';
 import PageTagRelation from '../models/page-tag-relation';
 import UpdatePost from '../models/update-post';
 import UpdatePost from '../models/update-post';
 
 
@@ -16,7 +16,7 @@ import UpdatePost from '../models/update-post';
 
 
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
-module.exports = function(crowi, app) {
+module.exports = (crowi, app) => {
   const logger = loggerFactory('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
 
 
   const { pagePathUtils } = require('@growi/core/dist/utils');
   const { pagePathUtils } = require('@growi/core/dist/utils');
@@ -66,7 +66,6 @@ module.exports = function(crowi, app) {
   //   return res.render('page_presentation', renderVars);
   //   return res.render('page_presentation', renderVars);
   // }
   // }
 
 
-
   /**
   /**
    * switch action
    * switch action
    *   - presentation mode
    *   - presentation mode
@@ -81,7 +80,6 @@ module.exports = function(crowi, app) {
   //   return showPageForGrowiBehavior(req, res, next);
   //   return showPageForGrowiBehavior(req, res, next);
   // };
   // };
 
 
-
   const api = {};
   const api = {};
   const validator = {};
   const validator = {};
 
 
@@ -145,12 +143,11 @@ module.exports = function(crowi, app) {
    *
    *
    * @apiParam {String} pageId
    * @apiParam {String} pageId
    */
    */
-  api.getPageTag = async function(req, res) {
+  api.getPageTag = async (req, res) => {
     const result = {};
     const result = {};
     try {
     try {
       result.tags = await PageTagRelation.listTagNamesByPage(req.query.pageId);
       result.tags = await PageTagRelation.listTagNamesByPage(req.query.pageId);
-    }
-    catch (err) {
+    } catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
@@ -219,7 +216,7 @@ module.exports = function(crowi, app) {
    *
    *
    * @apiParam {String} path
    * @apiParam {String} path
    */
    */
-  api.getUpdatePost = function(req, res) {
+  api.getUpdatePost = (req, res) => {
     const path = req.query.path;
     const path = req.query.path;
 
 
     if (!path) {
     if (!path) {
@@ -244,11 +241,15 @@ module.exports = function(crowi, app) {
 
 
   validator.remove = [
   validator.remove = [
     body('completely')
     body('completely')
-      .custom(v => v === 'true' || v === true || v == null)
-      .withMessage('The body property "completely" must be "true" or true. (Omit param for false)'),
+      .custom((v) => v === 'true' || v === true || v == null)
+      .withMessage(
+        'The body property "completely" must be "true" or true. (Omit param for false)',
+      ),
     body('recursively')
     body('recursively')
-      .custom(v => v === 'true' || v === true || v == null)
-      .withMessage('The body property "recursively" must be "true" or true. (Omit param for false)'),
+      .custom((v) => v === 'true' || v === true || v == null)
+      .withMessage(
+        'The body property "recursively" must be "true" or true. (Omit param for false)',
+      ),
   ];
   ];
 
 
   /**
   /**
@@ -349,7 +350,7 @@ module.exports = function(crowi, app) {
    *       500:
    *       500:
    *         $ref: '#/components/responses/InternalServerError'
    *         $ref: '#/components/responses/InternalServerError'
    */
    */
-  api.remove = async function(req, res) {
+  api.remove = async (req, res) => {
     const pageId = req.body.page_id;
     const pageId = req.body.page_id;
     const previousRevision = req.body.revision_id || null;
     const previousRevision = req.body.revision_id || null;
 
 
@@ -366,11 +367,21 @@ module.exports = function(crowi, app) {
     const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
     const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
 
     if (page == null) {
     if (page == null) {
-      return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
+      return res.json(
+        ApiResponse.error(
+          `Page '${pageId}' is not found or forbidden`,
+          'notfound_or_forbidden',
+        ),
+      );
     }
     }
 
 
     if (page.isEmpty && !isRecursively) {
     if (page.isEmpty && !isRecursively) {
-      return res.json(ApiResponse.error('Empty pages cannot be single deleted', 'single_deletion_empty_pages'));
+      return res.json(
+        ApiResponse.error(
+          'Empty pages cannot be single deleted',
+          'single_deletion_empty_pages',
+        ),
+      );
     }
     }
 
 
     const creatorId = await crowi.pageService.getCreatorIdForCanDelete(page);
     const creatorId = await crowi.pageService.getCreatorIdForCanDelete(page);
@@ -379,51 +390,97 @@ module.exports = function(crowi, app) {
 
 
     try {
     try {
       if (isCompletely) {
       if (isCompletely) {
-        const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(req.user);
-        const canDeleteCompletely = crowi.pageService.canDeleteCompletely(page, creatorId, req.user, isRecursively, userRelatedGroups);
+        const userRelatedGroups =
+          await crowi.pageGrantService.getUserRelatedGroups(req.user);
+        const canDeleteCompletely = crowi.pageService.canDeleteCompletely(
+          page,
+          creatorId,
+          req.user,
+          isRecursively,
+          userRelatedGroups,
+        );
         if (!canDeleteCompletely) {
         if (!canDeleteCompletely) {
-          return res.json(ApiResponse.error('You cannot delete this page completely', 'complete_deletion_not_allowed_for_user'));
+          return res.json(
+            ApiResponse.error(
+              'You cannot delete this page completely',
+              'complete_deletion_not_allowed_for_user',
+            ),
+          );
         }
         }
 
 
         if (pagePathUtils.isUsersHomepage(page.path)) {
         if (pagePathUtils.isUsersHomepage(page.path)) {
           if (!crowi.pageService.canDeleteUserHomepageByConfig()) {
           if (!crowi.pageService.canDeleteUserHomepageByConfig()) {
-            return res.json(ApiResponse.error('Could not delete user homepage'));
+            return res.json(
+              ApiResponse.error('Could not delete user homepage'),
+            );
           }
           }
-          if (!await crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
-            return res.json(ApiResponse.error('Could not delete user homepage'));
+          if (
+            !(await crowi.pageService.isUsersHomepageOwnerAbsent(page.path))
+          ) {
+            return res.json(
+              ApiResponse.error('Could not delete user homepage'),
+            );
           }
           }
         }
         }
 
 
-        await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively, false, activityParameters);
-      }
-      else {
+        await crowi.pageService.deleteCompletely(
+          page,
+          req.user,
+          options,
+          isRecursively,
+          false,
+          activityParameters,
+        );
+      } else {
         // behave like not found
         // behave like not found
         const notRecursivelyAndEmpty = page.isEmpty && !isRecursively;
         const notRecursivelyAndEmpty = page.isEmpty && !isRecursively;
         if (notRecursivelyAndEmpty) {
         if (notRecursivelyAndEmpty) {
-          return res.json(ApiResponse.error(`Page '${pageId}' is not found.`, 'notfound'));
+          return res.json(
+            ApiResponse.error(`Page '${pageId}' is not found.`, 'notfound'),
+          );
         }
         }
 
 
         if (!page.isEmpty && !page.isUpdatable(previousRevision)) {
         if (!page.isEmpty && !page.isUpdatable(previousRevision)) {
-          return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
+          return res.json(
+            ApiResponse.error(
+              "Someone could update this page, so couldn't delete.",
+              'outdated',
+            ),
+          );
         }
         }
 
 
-        if (!crowi.pageService.canDelete(page, creatorId, req.user, isRecursively)) {
-          return res.json(ApiResponse.error('You cannot delete this page', 'user_not_admin'));
+        if (
+          !crowi.pageService.canDelete(page, creatorId, req.user, isRecursively)
+        ) {
+          return res.json(
+            ApiResponse.error('You cannot delete this page', 'user_not_admin'),
+          );
         }
         }
 
 
         if (pagePathUtils.isUsersHomepage(page.path)) {
         if (pagePathUtils.isUsersHomepage(page.path)) {
           if (!crowi.pageService.canDeleteUserHomepageByConfig()) {
           if (!crowi.pageService.canDeleteUserHomepageByConfig()) {
-            return res.json(ApiResponse.error('Could not delete user homepage'));
+            return res.json(
+              ApiResponse.error('Could not delete user homepage'),
+            );
           }
           }
-          if (!await crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
-            return res.json(ApiResponse.error('Could not delete user homepage'));
+          if (
+            !(await crowi.pageService.isUsersHomepageOwnerAbsent(page.path))
+          ) {
+            return res.json(
+              ApiResponse.error('Could not delete user homepage'),
+            );
           }
           }
         }
         }
 
 
-        await crowi.pageService.deletePage(page, req.user, options, isRecursively, activityParameters);
+        await crowi.pageService.deletePage(
+          page,
+          req.user,
+          options,
+          isRecursively,
+          activityParameters,
+        );
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error occured while get setting', err);
       logger.error('Error occured while get setting', err);
       return res.json(ApiResponse.error('Failed to delete page.', err.message));
       return res.json(ApiResponse.error('Failed to delete page.', err.message));
     }
     }
@@ -438,9 +495,12 @@ module.exports = function(crowi, app) {
 
 
     try {
     try {
       // global notification
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_DELETE, page, req.user);
-    }
-    catch (err) {
+      await globalNotificationService.fire(
+        GlobalNotificationSettingEvent.PAGE_DELETE,
+        page,
+        req.user,
+      );
+    } catch (err) {
       logger.error('Delete notification failed', err);
       logger.error('Delete notification failed', err);
     }
     }
   };
   };
@@ -448,8 +508,10 @@ module.exports = function(crowi, app) {
   validator.revertRemove = [
   validator.revertRemove = [
     body('recursively')
     body('recursively')
       .optional()
       .optional()
-      .custom(v => v === 'true' || v === true || v == null)
-      .withMessage('The body property "recursively" must be "true" or true. (Omit param for false)'),
+      .custom((v) => v === 'true' || v === true || v == null)
+      .withMessage(
+        'The body property "recursively" must be "true" or true. (Omit param for false)',
+      ),
   ];
   ];
 
 
   /**
   /**
@@ -537,7 +599,7 @@ module.exports = function(crowi, app) {
    *       500:
    *       500:
    *         $ref: '#/components/responses/InternalServerError'
    *         $ref: '#/components/responses/InternalServerError'
    */
    */
-  api.revertRemove = async function(req, res, options) {
+  api.revertRemove = async (req, res, options) => {
     const pageId = req.body.page_id;
     const pageId = req.body.page_id;
 
 
     // get recursively flag
     // get recursively flag
@@ -552,14 +614,24 @@ module.exports = function(crowi, app) {
     try {
     try {
       page = await Page.findByIdAndViewer(pageId, req.user);
       page = await Page.findByIdAndViewer(pageId, req.user);
       if (page == null) {
       if (page == null) {
-        throw new Error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden');
+        throw new Error(
+          `Page '${pageId}' is not found or forbidden`,
+          'notfound_or_forbidden',
+        );
       }
       }
-      page = await crowi.pageService.revertDeletedPage(page, req.user, {}, isRecursively, activityParameters);
-    }
-    catch (err) {
+      page = await crowi.pageService.revertDeletedPage(
+        page,
+        req.user,
+        {},
+        isRecursively,
+        activityParameters,
+      );
+    } catch (err) {
       if (err instanceof PathAlreadyExistsError) {
       if (err instanceof PathAlreadyExistsError) {
         logger.error('Path already exists', err);
         logger.error('Path already exists', err);
-        return res.json(ApiResponse.error(err, 'already_exists', err.targetPath));
+        return res.json(
+          ApiResponse.error(err, 'already_exists', err.targetPath),
+        );
       }
       }
       logger.error('Error occured while get setting', err);
       logger.error('Error occured while get setting', err);
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
@@ -628,14 +700,13 @@ module.exports = function(crowi, app) {
    *       500:
    *       500:
    *         $ref: '#/components/responses/InternalServerError'
    *         $ref: '#/components/responses/InternalServerError'
    */
    */
-  api.unlink = async function(req, res) {
+  api.unlink = async (req, res) => {
     const path = req.body.path;
     const path = req.body.path;
 
 
     try {
     try {
       await PageRedirect.removePageRedirectsByToPath(path);
       await PageRedirect.removePageRedirectsByToPath(path);
       logger.debug('Redirect Page deleted', path);
       logger.debug('Redirect Page deleted', path);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error occured while get setting', err);
       logger.error('Error occured while get setting', err);
       return res.json(ApiResponse.error('Failed to delete redirect page.'));
       return res.json(ApiResponse.error('Failed to delete redirect page.'));
     }
     }

+ 48 - 22
apps/app/src/server/routes/search.ts

@@ -1,12 +1,15 @@
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
+import type {
+  IFormattedSearchResult,
+  ISearchResult,
+} from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
 import UserGroupRelation from '../models/user-group-relation';
 import UserGroupRelation from '../models/user-group-relation';
 import { isSearchError } from '../models/vo/search-error';
 import { isSearchError } from '../models/vo/search-error';
 
 
-
 const logger = loggerFactory('growi:routes:search');
 const logger = loggerFactory('growi:routes:search');
 
 
 /**
 /**
@@ -37,7 +40,7 @@ const logger = loggerFactory('growi:routes:search');
  *           meta:
  *           meta:
  *             $ref: '#/components/schemas/ElasticsearchResultMeta'
  *             $ref: '#/components/schemas/ElasticsearchResultMeta'
  */
  */
-module.exports = function(crowi: Crowi, app) {
+module.exports = (crowi: Crowi, app) => {
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const ApiPaginate = require('../util/apiPaginate');
   const ApiPaginate = require('../util/apiPaginate');
 
 
@@ -110,18 +113,22 @@ module.exports = function(crowi: Crowi, app) {
    * @apiParam {String} offset
    * @apiParam {String} offset
    * @apiParam {String} limit
    * @apiParam {String} limit
    */
    */
-  api.search = async function(req, res) {
+  api.search = async (req, res) => {
     const user = req.user;
     const user = req.user;
     const {
     const {
-      q = null, nq = null, type = null, sort = null, order = null, vector = null,
+      q = null,
+      nq = null,
+      type = null,
+      sort = null,
+      order = null,
+      vector = null,
     } = req.query;
     } = req.query;
-    let paginateOpts;
+    let paginateOpts: { limit: number; offset: number };
 
 
     try {
     try {
       paginateOpts = ApiPaginate.parseOptionsForElasticSearch(req.query);
       paginateOpts = ApiPaginate.parseOptionsForElasticSearch(req.query);
-    }
-    catch (e) {
-      res.json(ApiResponse.error(e));
+    } catch (e) {
+      return res.json(ApiResponse.error(e));
     }
     }
 
 
     if (q === null || q === '') {
     if (q === null || q === '') {
@@ -133,23 +140,38 @@ module.exports = function(crowi: Crowi, app) {
       return res.json(ApiResponse.error('SearchService is not reachable.'));
       return res.json(ApiResponse.error('SearchService is not reachable.'));
     }
     }
 
 
-    const userGroups = user != null ? [
-      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-    ] : null;
+    const userGroups =
+      user != null
+        ? [
+            ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+            ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
+              user,
+            )),
+          ]
+        : null;
 
 
     const searchOpts = {
     const searchOpts = {
-      ...paginateOpts, type, sort, order, vector,
+      ...paginateOpts,
+      type,
+      sort,
+      order,
+      vector,
     };
     };
 
 
-    let searchResult;
+    let searchResult: ISearchResult<unknown>;
+    // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
     let delegatorName;
     let delegatorName;
     try {
     try {
       const query = decodeURIComponent(q);
       const query = decodeURIComponent(q);
       const nqName = nq ?? decodeURIComponent(nq);
       const nqName = nq ?? decodeURIComponent(nq);
-      [searchResult, delegatorName] = await searchService.searchKeyword(query, nqName, user, userGroups, searchOpts);
-    }
-    catch (err) {
+      [searchResult, delegatorName] = await searchService.searchKeyword(
+        query,
+        nqName,
+        user,
+        userGroups,
+        searchOpts,
+      );
+    } catch (err) {
       logger.error('Failed to search', err);
       logger.error('Failed to search', err);
 
 
       if (isSearchError(err)) {
       if (isSearchError(err)) {
@@ -160,17 +182,21 @@ module.exports = function(crowi: Crowi, app) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
 
 
-    let result;
+    let result: IFormattedSearchResult;
     try {
     try {
-      result = await searchService.formatSearchResult(searchResult, delegatorName, user, userGroups);
-    }
-    catch (err) {
+      result = await searchService.formatSearchResult(
+        searchResult,
+        delegatorName,
+        user,
+        userGroups,
+      );
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
 
 
     const parameters = {
     const parameters = {
-      ip:  req.ip,
+      ip: req.ip,
       endpoint: req.originalUrl,
       endpoint: req.originalUrl,
       action: SupportedAction.ACTION_SEARCH_PAGE,
       action: SupportedAction.ACTION_SEARCH_PAGE,
       user: req.user?._id,
       user: req.user?._id,

+ 27 - 17
apps/app/src/server/routes/tag.js

@@ -6,8 +6,7 @@ import { Revision } from '../models/revision';
 import ApiResponse from '../util/apiResponse';
 import ApiResponse from '../util/apiResponse';
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
-module.exports = function(crowi, app) {
-
+module.exports = (crowi, app) => {
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
   const actions = {};
   const actions = {};
   const api = {};
   const api = {};
@@ -56,14 +55,18 @@ module.exports = function(crowi, app) {
    *
    *
    * @apiParam {String} q keyword
    * @apiParam {String} q keyword
    */
    */
-  api.search = async function(req, res) {
+  api.search = async (req, res) => {
     // https://regex101.com/r/J1cN6O/1
     // https://regex101.com/r/J1cN6O/1
     // prevent from unexpecting attack doing regular expression on tag search (DoS attack)
     // prevent from unexpecting attack doing regular expression on tag search (DoS attack)
     // Search for regular expressions as normal characters
     // Search for regular expressions as normal characters
     // e.g. user*$ -> user\*\$ (escape a regular expression)
     // e.g. user*$ -> user\*\$ (escape a regular expression)
     const escapeRegExp = req.query.q.replace(/[\\^$/.*+?()[\]{}|]/g, '\\$&');
     const escapeRegExp = req.query.q.replace(/[\\^$/.*+?()[\]{}|]/g, '\\$&');
-    let tags = await Tag.find({ name: new RegExp(`^${escapeRegExp}`) }).select('_id name');
-    tags = tags.map((tag) => { return tag.name });
+    let tags = await Tag.find({ name: new RegExp(`^${escapeRegExp}`) }).select(
+      '_id name',
+    );
+    tags = tags.map((tag) => {
+      return tag.name;
+    });
     return res.json(ApiResponse.success({ tags }));
     return res.json(ApiResponse.success({ tags }));
   };
   };
 
 
@@ -113,7 +116,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} PageId
    * @apiParam {String} PageId
    * @apiParam {array} tags
    * @apiParam {array} tags
    */
    */
-  api.update = async function(req, res) {
+  api.update = async (req, res) => {
     const Page = crowi.model('Page');
     const Page = crowi.model('Page');
     const User = crowi.model('User');
     const User = crowi.model('User');
     const tagEvent = crowi.event('tag');
     const tagEvent = crowi.event('tag');
@@ -128,22 +131,30 @@ module.exports = function(crowi, app) {
       const page = await Page.findById(pageId);
       const page = await Page.findById(pageId);
       const user = await User.findById(userId);
       const user = await User.findById(userId);
 
 
-      if (!await Page.isAccessiblePageByViewer(page._id, user)) {
-        return res.json(ApiResponse.error("You don't have permission to update this page."));
+      if (!(await Page.isAccessiblePageByViewer(page._id, user))) {
+        return res.json(
+          ApiResponse.error("You don't have permission to update this page."),
+        );
       }
       }
 
 
       const previousRevision = await Revision.findById(revisionId);
       const previousRevision = await Revision.findById(revisionId);
-      result.savedPage = await crowi.pageService.updatePage(page, previousRevision.body, previousRevision.body, req.user);
+      result.savedPage = await crowi.pageService.updatePage(
+        page,
+        previousRevision.body,
+        previousRevision.body,
+        req.user,
+      );
       await PageTagRelation.updatePageTags(pageId, tags);
       await PageTagRelation.updatePageTags(pageId, tags);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
 
 
       tagEvent.emit('update', page, tags);
       tagEvent.emit('update', page, tags);
-    }
-    catch (err) {
+    } catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
 
 
-    activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_TAG_UPDATE });
+    activityEvent.emit('update', res.locals.activity._id, {
+      action: SupportedAction.ACTION_TAG_UPDATE,
+    });
 
 
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
@@ -194,7 +205,7 @@ module.exports = function(crowi, app) {
    * @apiParam {Number} limit
    * @apiParam {Number} limit
    * @apiParam {Number} offset
    * @apiParam {Number} offset
    */
    */
-  api.list = async function(req, res) {
+  api.list = async (req, res) => {
     const limit = +req.query.limit || 50;
     const limit = +req.query.limit || 50;
     const offset = +req.query.offset || 0;
     const offset = +req.query.offset || 0;
     const sortOpt = { count: -1, _id: -1 };
     const sortOpt = { count: -1, _id: -1 };
@@ -202,15 +213,14 @@ module.exports = function(crowi, app) {
 
 
     try {
     try {
       // get tag list contains id name and count properties
       // get tag list contains id name and count properties
-      const tagsWithCount = await PageTagRelation.createTagListWithCount(queryOptions);
+      const tagsWithCount =
+        await PageTagRelation.createTagListWithCount(queryOptions);
 
 
       return res.json(ApiResponse.success(tagsWithCount));
       return res.json(ApiResponse.success(tagsWithCount));
-    }
-    catch (err) {
+    } catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
   };
   };
 
 
-
   return actions;
   return actions;
 };
 };

+ 11 - 6
apps/app/src/server/routes/user-activation.ts

@@ -1,16 +1,16 @@
-import type { Response, NextFunction } from 'express';
+import type { NextFunction, Response } from 'express';
 
 
 import type { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
 import type { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
 import type { ReqWithUserRegistrationOrder } from '~/server/middlewares/inject-user-registration-order-by-token-middleware';
 import type { ReqWithUserRegistrationOrder } from '~/server/middlewares/inject-user-registration-order-by-token-middleware';
 
 
 type Crowi = {
 type Crowi = {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  nextApp: any,
-}
+  nextApp: any;
+};
 
 
 type CrowiReq = ReqWithUserRegistrationOrder & {
 type CrowiReq = ReqWithUserRegistrationOrder & {
-  crowi: Crowi,
-}
+  crowi: Crowi;
+};
 
 
 /**
 /**
  * @swagger
  * @swagger
@@ -45,7 +45,12 @@ export const renderUserActivationPage = (crowi: Crowi) => {
 
 
 // middleware to handle error
 // middleware to handle error
 export const tokenErrorHandlerMiddeware = (crowi: Crowi) => {
 export const tokenErrorHandlerMiddeware = (crowi: Crowi) => {
-  return (error: Error & { code: UserActivationErrorCode, statusCode: number }, req: CrowiReq, res: Response, next: NextFunction): void => {
+  return (
+    error: Error & { code: UserActivationErrorCode; statusCode: number },
+    req: CrowiReq,
+    res: Response,
+    next: NextFunction,
+  ): void => {
     if (error != null) {
     if (error != null) {
       const { nextApp } = crowi;
       const { nextApp } = crowi;
       req.crowi = crowi;
       req.crowi = crowi;

+ 3 - 5
apps/app/src/server/routes/user.js

@@ -45,18 +45,17 @@
  *            example: 2010-01-01T00:00:00.000Z
  *            example: 2010-01-01T00:00:00.000Z
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
-module.exports = function(crowi, app) {
+module.exports = (crowi, app) => {
   const User = crowi.model('User');
   const User = crowi.model('User');
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
 
 
   const actions = {};
   const actions = {};
 
 
-
   const api = {};
   const api = {};
 
 
   actions.api = api;
   actions.api = api;
 
 
-  api.checkUsername = async function(req, res) {
+  api.checkUsername = async (req, res) => {
     const username = req.query.username;
     const username = req.query.username;
 
 
     let valid = false;
     let valid = false;
@@ -64,8 +63,7 @@ module.exports = function(crowi, app) {
       .then((userData) => {
       .then((userData) => {
         if (userData) {
         if (userData) {
           valid = false;
           valid = false;
-        }
-        else {
+        } else {
           valid = true;
           valid = true;
         }
         }
       })
       })

+ 39 - 0
apps/app/src/server/util/locale-utils.ts

@@ -1,4 +1,5 @@
 import { Lang } from '@growi/core/dist/interfaces';
 import { Lang } from '@growi/core/dist/interfaces';
+import { enUS, fr, ja, ko, type Locale, zhCN } from 'date-fns/locale';
 import type { IncomingHttpHeaders } from 'http';
 import type { IncomingHttpHeaders } from 'http';
 
 
 import * as i18nextConfig from '^/config/i18next.config';
 import * as i18nextConfig from '^/config/i18next.config';
@@ -11,6 +12,44 @@ const ACCEPT_LANG_MAP = {
   ko: Lang.ko_KR,
   ko: Lang.ko_KR,
 };
 };
 
 
+const DATE_FNS_LOCALE_MAP: Record<string, Locale | undefined> = {
+  en: enUS,
+  'en-US': enUS,
+  en_US: enUS,
+
+  ja: ja,
+  'ja-JP': ja,
+  ja_JP: ja,
+
+  fr: fr,
+  'fr-FR': fr,
+  fr_FR: fr,
+
+  ko: ko,
+  'ko-KR': ko,
+  ko_KR: ko,
+
+  zh: zhCN,
+  'zh-CN': zhCN,
+  zh_CN: zhCN,
+};
+
+/**
+ * Gets the corresponding date-fns Locale object from an i18next language code.
+ * @param langCode The i18n language code (e.g., 'ja_JP').
+ * @returns The date-fns Locale object, defaulting to enUS if not found.
+ */
+export const getLocale = (langCode: string): Locale => {
+  let locale = DATE_FNS_LOCALE_MAP[langCode];
+
+  if (!locale) {
+    const baseCode = langCode.split(/[-_]/)[0];
+    locale = DATE_FNS_LOCALE_MAP[baseCode];
+  }
+
+  return locale ?? enUS;
+};
+
 /**
 /**
  * It return the first language that matches ACCEPT_LANG_MAP keys from sorted accept languages array
  * It return the first language that matches ACCEPT_LANG_MAP keys from sorted accept languages array
  * @param sortedAcceptLanguagesArray
  * @param sortedAcceptLanguagesArray

+ 32 - 0
apps/app/src/stores/recent-activity.ts

@@ -0,0 +1,32 @@
+import type { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import type {
+  IActivityHasId,
+  UserActivitiesResult,
+} from '~/interfaces/activity';
+import type { PaginateResult } from '~/interfaces/mongoose-utils';
+
+export const useSWRxRecentActivity = (
+  limit?: number,
+  offset?: number,
+  targetUserId?: string,
+): SWRResponse<PaginateResult<IActivityHasId>, Error> => {
+  const shouldFetch = targetUserId && targetUserId.length > 0;
+  const key = shouldFetch
+    ? ['/user-activities', limit, offset, targetUserId]
+    : null;
+
+  const fetcher = ([endpoint, limitParam, offsetParam, targetUserIdParam]) => {
+    const promise = apiv3Get<UserActivitiesResult>(endpoint, {
+      limit: limitParam,
+      offset: offsetParam,
+      targetUserId: targetUserIdParam,
+    });
+
+    return promise.then((result) => result.data.serializedPaginationResult);
+  };
+
+  return useSWRImmutable(key, fetcher);
+};

+ 5 - 0
apps/app/src/styles/_layout.scss

@@ -89,6 +89,11 @@ body {
     padding: 30px;
     padding: 30px;
   }
   }
 
 
+  // Avoid the issue where the footer element overlaps the main content
+  .dynamic-layout-root {
+    display: block !important;
+  }
+
   a::after {
   a::after {
     display: none !important;
     display: none !important;
   }
   }

+ 1 - 1
apps/pdf-converter/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/pdf-converter",
   "name": "@growi/pdf-converter",
-  "version": "1.1.2-RC.0",
+  "version": "1.1.3-RC.0",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
   "types": "dist/index.d.ts",
   "license": "MIT",
   "license": "MIT",

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

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

+ 2 - 1
biome.json

@@ -29,7 +29,8 @@
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/specs",
       "!packages/pdf-converter-client/specs",
       "!apps/app/src/client",
       "!apps/app/src/client",
-      "!apps/app/src/server/routes",
+      "!apps/app/src/server/middlewares",
+      "!apps/app/src/server/routes/apiv3",
       "!apps/app/src/server/service"
       "!apps/app/src/server/service"
     ]
     ]
   },
   },

+ 147 - 140
pnpm-lock.yaml

@@ -72,7 +72,7 @@ importers:
         version: 5.59.7(eslint@8.41.0)(typescript@5.0.4)
         version: 5.59.7(eslint@8.41.0)(typescript@5.0.4)
       '@vitejs/plugin-react':
       '@vitejs/plugin-react':
         specifier: ^4.3.1
         specifier: ^4.3.1
-        version: 4.3.1(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1))
+        version: 4.3.1(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1))
       '@vitest/coverage-v8':
       '@vitest/coverage-v8':
         specifier: ^2.1.1
         specifier: ^2.1.1
         version: 2.1.1(vitest@2.1.1)
         version: 2.1.1(vitest@2.1.1)
@@ -189,16 +189,16 @@ importers:
         version: 3.4.7(typescript@5.0.4)
         version: 3.4.7(typescript@5.0.4)
       vite:
       vite:
         specifier: ^5.4.21
         specifier: ^5.4.21
-        version: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1)
+        version: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1)
       vite-plugin-dts:
       vite-plugin-dts:
         specifier: ^3.9.1
         specifier: ^3.9.1
-        version: 3.9.1(@types/node@20.19.17)(rollup@4.39.0)(typescript@5.0.4)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1))
+        version: 3.9.1(@types/node@20.19.17)(rollup@4.39.0)(typescript@5.0.4)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1))
       vite-tsconfig-paths:
       vite-tsconfig-paths:
         specifier: ^5.0.1
         specifier: ^5.0.1
-        version: 5.0.1(typescript@5.0.4)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1))
+        version: 5.0.1(typescript@5.0.4)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1))
       vitest:
       vitest:
         specifier: ^2.1.1
         specifier: ^2.1.1
-        version: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.43.1)
+        version: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.44.1)
       vitest-mock-extended:
       vitest-mock-extended:
         specifier: ^2.0.2
         specifier: ^2.0.2
         version: 2.0.2(typescript@5.0.4)(vitest@2.1.1)
         version: 2.0.2(typescript@5.0.4)(vitest@2.1.1)
@@ -473,8 +473,8 @@ importers:
         specifier: ^1.0.15
         specifier: ^1.0.15
         version: 1.0.15
         version: 1.0.15
       js-yaml:
       js-yaml:
-        specifier: ^4.1.0
-        version: 4.1.0
+        specifier: ^4.1.1
+        version: 4.1.1
       jsonrepair:
       jsonrepair:
         specifier: ^3.12.0
         specifier: ^3.12.0
         version: 3.13.0
         version: 3.13.0
@@ -1326,7 +1326,7 @@ importers:
         version: 6.5.2
         version: 6.5.2
       '@codemirror/view':
       '@codemirror/view':
         specifier: ^6.36.2
         specifier: ^6.36.2
-        version: 6.38.2
+        version: 6.38.7
       '@emoji-mart/data':
       '@emoji-mart/data':
         specifier: ^1.2.1
         specifier: ^1.2.1
         version: 1.2.1
         version: 1.2.1
@@ -1347,13 +1347,13 @@ importers:
         version: 2.11.8
         version: 2.11.8
       '@replit/codemirror-emacs':
       '@replit/codemirror-emacs':
         specifier: ^6.1.0
         specifier: ^6.1.0
-        version: 6.1.0(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)
+        version: 6.1.0(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)
       '@replit/codemirror-vim':
       '@replit/codemirror-vim':
         specifier: ^6.2.1
         specifier: ^6.2.1
-        version: 6.2.1(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)
+        version: 6.2.1(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)
       '@replit/codemirror-vscode-keymap':
       '@replit/codemirror-vscode-keymap':
         specifier: ^6.0.2
         specifier: ^6.0.2
-        version: 6.0.2(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)
+        version: 6.0.2(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)
       '@types/react':
       '@types/react':
         specifier: ^18.2.14
         specifier: ^18.2.14
         version: 18.3.3
         version: 18.3.3
@@ -1362,28 +1362,28 @@ importers:
         version: 18.3.0
         version: 18.3.0
       '@uiw/codemirror-theme-eclipse':
       '@uiw/codemirror-theme-eclipse':
         specifier: ^4.23.8
         specifier: ^4.23.8
-        version: 4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)
+        version: 4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)
       '@uiw/codemirror-theme-kimbie':
       '@uiw/codemirror-theme-kimbie':
         specifier: ^4.23.8
         specifier: ^4.23.8
-        version: 4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)
+        version: 4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)
       '@uiw/codemirror-themes':
       '@uiw/codemirror-themes':
         specifier: ^4.23.8
         specifier: ^4.23.8
-        version: 4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)
+        version: 4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)
       '@uiw/react-codemirror':
       '@uiw/react-codemirror':
         specifier: ^4.23.8
         specifier: ^4.23.8
-        version: 4.23.8(@babel/runtime@7.25.4)(@codemirror/autocomplete@6.18.4)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.2)(codemirror@6.0.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+        version: 4.23.8(@babel/runtime@7.25.4)(@codemirror/autocomplete@6.18.4)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.7)(codemirror@6.0.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
       bootstrap:
       bootstrap:
         specifier: '=5.3.2'
         specifier: '=5.3.2'
         version: 5.3.2(@popperjs/core@2.11.8)
         version: 5.3.2(@popperjs/core@2.11.8)
       cm6-theme-basic-light:
       cm6-theme-basic-light:
         specifier: ^0.2.0
         specifier: ^0.2.0
-        version: 0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)(@lezer/highlight@1.2.1)
+        version: 0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)(@lezer/highlight@1.2.1)
       cm6-theme-material-dark:
       cm6-theme-material-dark:
         specifier: ^0.2.0
         specifier: ^0.2.0
-        version: 0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)(@lezer/highlight@1.2.1)
+        version: 0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)(@lezer/highlight@1.2.1)
       cm6-theme-nord:
       cm6-theme-nord:
         specifier: ^0.2.0
         specifier: ^0.2.0
-        version: 0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)(@lezer/highlight@1.2.1)
+        version: 0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)(@lezer/highlight@1.2.1)
       codemirror:
       codemirror:
         specifier: ^6.0.1
         specifier: ^6.0.1
         version: 6.0.1
         version: 6.0.1
@@ -1431,7 +1431,7 @@ importers:
         version: 6.2.0
         version: 6.2.0
       y-codemirror.next:
       y-codemirror.next:
         specifier: ^0.3.5
         specifier: ^0.3.5
-        version: 0.3.5(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)(yjs@13.6.19)
+        version: 0.3.5(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)(yjs@13.6.19)
       y-socket.io:
       y-socket.io:
         specifier: ^1.1.3
         specifier: ^1.1.3
         version: 1.1.3(yjs@13.6.19)
         version: 1.1.3(yjs@13.6.19)
@@ -2746,8 +2746,8 @@ packages:
   '@codemirror/theme-one-dark@6.1.2':
   '@codemirror/theme-one-dark@6.1.2':
     resolution: {integrity: sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==}
     resolution: {integrity: sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==}
 
 
-  '@codemirror/view@6.38.2':
-    resolution: {integrity: sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==}
+  '@codemirror/view@6.38.7':
+    resolution: {integrity: sha512-+b0imJTgzehmMToqT9DWPBdeRj7/qDsJj7MzQ+1+do2KK2UkxKuLaHlUVeZk855wO6my6cfbF1c+Qozs8B3YqA==}
 
 
   '@colors/colors@1.5.0':
   '@colors/colors@1.5.0':
     resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
     resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
@@ -6269,8 +6269,8 @@ packages:
     engines: {node: '>=0.4.0'}
     engines: {node: '>=0.4.0'}
     hasBin: true
     hasBin: true
 
 
-  acorn@8.14.1:
-    resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
+  acorn@8.15.0:
+    resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
     engines: {node: '>=0.4.0'}
     engines: {node: '>=0.4.0'}
     hasBin: true
     hasBin: true
 
 
@@ -6684,6 +6684,10 @@ packages:
     resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
     resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
     engines: {node: '>=6.0.0'}
     engines: {node: '>=6.0.0'}
 
 
+  baseline-browser-mapping@2.8.28:
+    resolution: {integrity: sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==}
+    hasBin: true
+
   basic-auth@2.0.1:
   basic-auth@2.0.1:
     resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
     resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
     engines: {node: '>= 0.8'}
     engines: {node: '>= 0.8'}
@@ -6783,8 +6787,8 @@ packages:
     engines: {node: '>= 8.0.0'}
     engines: {node: '>= 8.0.0'}
     hasBin: true
     hasBin: true
 
 
-  browserslist@4.25.4:
-    resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==}
+  browserslist@4.28.0:
+    resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==}
     engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
     engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
     hasBin: true
     hasBin: true
 
 
@@ -6928,8 +6932,8 @@ packages:
   can-use-dom@0.1.0:
   can-use-dom@0.1.0:
     resolution: {integrity: sha512-ceOhN1DL7Y4O6M0j9ICgmTYziV89WMd96SvSl0REd8PMgrY0B/WBOPoed5S1KUmJqXgUXh8gzSe6E3ae27upsQ==}
     resolution: {integrity: sha512-ceOhN1DL7Y4O6M0j9ICgmTYziV89WMd96SvSl0REd8PMgrY0B/WBOPoed5S1KUmJqXgUXh8gzSe6E3ae27upsQ==}
 
 
-  caniuse-lite@1.0.30001739:
-    resolution: {integrity: sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==}
+  caniuse-lite@1.0.30001754:
+    resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==}
 
 
   capital-case@1.0.4:
   capital-case@1.0.4:
     resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
     resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
@@ -8458,8 +8462,8 @@ packages:
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
     hasBin: true
     hasBin: true
 
 
-  electron-to-chromium@1.5.211:
-    resolution: {integrity: sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==}
+  electron-to-chromium@1.5.252:
+    resolution: {integrity: sha512-53uTpjtRgS7gjIxZ4qCgFdNO2q+wJt/Z8+xAvxbCqXPJrY6h7ighUkadQmNMXH96crtpa6gPFNP7BF4UBGDuaA==}
 
 
   emittery@0.13.1:
   emittery@0.13.1:
     resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
     resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
@@ -10470,8 +10474,8 @@ packages:
     resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
     resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
     hasBin: true
     hasBin: true
 
 
-  js-yaml@4.1.0:
-    resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
+  js-yaml@4.1.1:
+    resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
     hasBin: true
     hasBin: true
 
 
   jsbn@0.1.1:
   jsbn@0.1.1:
@@ -10559,7 +10563,6 @@ packages:
     resolution: {integrity: sha512-Quz3MvAwHxVYNXsOByL7xI5EB2WYOeFswqaHIA3qOK3isRWTxiplBEocmmru6XmxDB2L7jDNYtYA4FyimoAFEw==}
     resolution: {integrity: sha512-Quz3MvAwHxVYNXsOByL7xI5EB2WYOeFswqaHIA3qOK3isRWTxiplBEocmmru6XmxDB2L7jDNYtYA4FyimoAFEw==}
     engines: {node: '>=8.17.0'}
     engines: {node: '>=8.17.0'}
     hasBin: true
     hasBin: true
-    bundledDependencies: []
 
 
   jsonfile@3.0.1:
   jsonfile@3.0.1:
     resolution: {integrity: sha512-oBko6ZHlubVB5mRFkur5vgYR1UyqX+S6Y/oCfLhqNdcc2fYFlDpIoNc7AfKS1KOGcnNAkvsr0grLck9ANM815w==}
     resolution: {integrity: sha512-oBko6ZHlubVB5mRFkur5vgYR1UyqX+S6Y/oCfLhqNdcc2fYFlDpIoNc7AfKS1KOGcnNAkvsr0grLck9ANM815w==}
@@ -11069,6 +11072,7 @@ packages:
 
 
   mathjax-full@3.2.2:
   mathjax-full@3.2.2:
     resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==}
     resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==}
+    deprecated: Version 4 replaces this package with the scoped package @mathjax/src
 
 
   mathml-tag-names@2.1.3:
   mathml-tag-names@2.1.3:
     resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
     resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
@@ -11790,8 +11794,8 @@ packages:
   node-readfiles@0.2.0:
   node-readfiles@0.2.0:
     resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==}
     resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==}
 
 
-  node-releases@2.0.19:
-    resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
+  node-releases@2.0.27:
+    resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
 
 
   nodemailer-ses-transport@1.5.1:
   nodemailer-ses-transport@1.5.1:
     resolution: {integrity: sha512-JwL93Lc7KEWbH4a9Ehm6XCJgNhf6QNleSDkIsCvEyViKzqvYsf+8rF2PG8OzI1xDyxvtgsaWAmJWMqABOZmnWg==}
     resolution: {integrity: sha512-JwL93Lc7KEWbH4a9Ehm6XCJgNhf6QNleSDkIsCvEyViKzqvYsf+8rF2PG8OzI1xDyxvtgsaWAmJWMqABOZmnWg==}
@@ -14147,8 +14151,8 @@ packages:
       uglify-js:
       uglify-js:
         optional: true
         optional: true
 
 
-  terser@5.43.1:
-    resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
+  terser@5.44.1:
+    resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==}
     engines: {node: '>=10'}
     engines: {node: '>=10'}
     hasBin: true
     hasBin: true
 
 
@@ -14770,8 +14774,8 @@ packages:
   unzip-stream@0.3.2:
   unzip-stream@0.3.2:
     resolution: {integrity: sha512-oWhfqwjx36ULFG+krfkbtbrc/BeEzaYrlqdEWa5EPNd6x6RerzuNW8aSTM0TtNtrOfUKYdO0TwrlkzrXAE6Olg==}
     resolution: {integrity: sha512-oWhfqwjx36ULFG+krfkbtbrc/BeEzaYrlqdEWa5EPNd6x6RerzuNW8aSTM0TtNtrOfUKYdO0TwrlkzrXAE6Olg==}
 
 
-  update-browserslist-db@1.1.3:
-    resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
+  update-browserslist-db@1.1.4:
+    resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==}
     hasBin: true
     hasBin: true
     peerDependencies:
     peerDependencies:
       browserslist: '>= 4.21.0'
       browserslist: '>= 4.21.0'
@@ -15551,14 +15555,14 @@ snapshots:
     dependencies:
     dependencies:
       '@jsdevtools/ono': 7.1.3
       '@jsdevtools/ono': 7.1.3
       '@types/json-schema': 7.0.15
       '@types/json-schema': 7.0.15
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
 
 
   '@apidevtools/json-schema-ref-parser@9.1.2':
   '@apidevtools/json-schema-ref-parser@9.1.2':
     dependencies:
     dependencies:
       '@jsdevtools/ono': 7.1.3
       '@jsdevtools/ono': 7.1.3
       '@types/json-schema': 7.0.15
       '@types/json-schema': 7.0.15
       call-me-maybe: 1.0.2
       call-me-maybe: 1.0.2
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
 
 
   '@apidevtools/openapi-schemas@2.1.0': {}
   '@apidevtools/openapi-schemas@2.1.0': {}
 
 
@@ -16703,7 +16707,7 @@ snapshots:
     dependencies:
     dependencies:
       '@babel/compat-data': 7.24.6
       '@babel/compat-data': 7.24.6
       '@babel/helper-validator-option': 7.24.6
       '@babel/helper-validator-option': 7.24.6
-      browserslist: 4.25.4
+      browserslist: 4.28.0
       lru-cache: 5.1.1
       lru-cache: 5.1.1
       semver: 6.3.1
       semver: 6.3.1
 
 
@@ -17109,14 +17113,14 @@ snapshots:
     dependencies:
     dependencies:
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/common': 1.2.3
       '@lezer/common': 1.2.3
 
 
   '@codemirror/commands@6.8.0':
   '@codemirror/commands@6.8.0':
     dependencies:
     dependencies:
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/common': 1.2.3
       '@lezer/common': 1.2.3
 
 
   '@codemirror/lang-angular@0.1.2':
   '@codemirror/lang-angular@0.1.2':
@@ -17156,7 +17160,7 @@ snapshots:
       '@codemirror/lang-javascript': 6.1.9
       '@codemirror/lang-javascript': 6.1.9
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/common': 1.2.3
       '@lezer/common': 1.2.3
       '@lezer/css': 1.1.3
       '@lezer/css': 1.1.3
       '@lezer/html': 1.3.6
       '@lezer/html': 1.3.6
@@ -17172,7 +17176,7 @@ snapshots:
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/lint': 6.8.1
       '@codemirror/lint': 6.8.1
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/common': 1.2.3
       '@lezer/common': 1.2.3
       '@lezer/javascript': 1.4.5
       '@lezer/javascript': 1.4.5
 
 
@@ -17194,7 +17198,7 @@ snapshots:
       '@codemirror/lang-html': 6.4.5
       '@codemirror/lang-html': 6.4.5
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/common': 1.2.3
       '@lezer/common': 1.2.3
       '@lezer/highlight': 1.2.1
       '@lezer/highlight': 1.2.1
       '@lezer/lr': 1.4.2
       '@lezer/lr': 1.4.2
@@ -17205,7 +17209,7 @@ snapshots:
       '@codemirror/lang-html': 6.4.5
       '@codemirror/lang-html': 6.4.5
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/common': 1.2.3
       '@lezer/common': 1.2.3
       '@lezer/markdown': 1.0.5
       '@lezer/markdown': 1.0.5
 
 
@@ -17304,7 +17308,7 @@ snapshots:
   '@codemirror/language@6.11.3':
   '@codemirror/language@6.11.3':
     dependencies:
     dependencies:
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/common': 1.2.3
       '@lezer/common': 1.2.3
       '@lezer/highlight': 1.2.1
       '@lezer/highlight': 1.2.1
       '@lezer/lr': 1.4.2
       '@lezer/lr': 1.4.2
@@ -17317,21 +17321,21 @@ snapshots:
   '@codemirror/lint@6.8.1':
   '@codemirror/lint@6.8.1':
     dependencies:
     dependencies:
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       crelt: 1.0.6
       crelt: 1.0.6
 
 
   '@codemirror/merge@6.8.0':
   '@codemirror/merge@6.8.0':
     dependencies:
     dependencies:
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/highlight': 1.2.1
       '@lezer/highlight': 1.2.1
       style-mod: 4.1.2
       style-mod: 4.1.2
 
 
   '@codemirror/search@6.5.6':
   '@codemirror/search@6.5.6':
     dependencies:
     dependencies:
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       crelt: 1.0.6
       crelt: 1.0.6
 
 
   '@codemirror/state@6.5.2':
   '@codemirror/state@6.5.2':
@@ -17342,10 +17346,10 @@ snapshots:
     dependencies:
     dependencies:
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/highlight': 1.2.1
       '@lezer/highlight': 1.2.1
 
 
-  '@codemirror/view@6.38.2':
+  '@codemirror/view@6.38.7':
     dependencies:
     dependencies:
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
       crelt: 1.0.6
       crelt: 1.0.6
@@ -17634,7 +17638,7 @@ snapshots:
       globals: 13.24.0
       globals: 13.24.0
       ignore: 5.3.1
       ignore: 5.3.1
       import-fresh: 3.3.0
       import-fresh: 3.3.0
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       minimatch: 3.1.2
       minimatch: 3.1.2
       strip-json-comments: 3.1.1
       strip-json-comments: 3.1.1
     transitivePeerDependencies:
     transitivePeerDependencies:
@@ -18271,7 +18275,7 @@ snapshots:
     dependencies:
     dependencies:
       color-string: 1.9.1
       color-string: 1.9.1
       cssesc: 3.0.0
       cssesc: 3.0.0
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       lodash.kebabcase: 4.1.1
       lodash.kebabcase: 4.1.1
       markdown-it: 13.0.2
       markdown-it: 13.0.2
       markdown-it-front-matter: 0.2.4
       markdown-it-front-matter: 0.2.4
@@ -19224,7 +19228,7 @@ snapshots:
     dependencies:
     dependencies:
       '@apidevtools/swagger-parser': 10.1.1(openapi-types@12.1.3)
       '@apidevtools/swagger-parser': 10.1.1(openapi-types@12.1.3)
       '@ibm-cloud/openapi-ruleset': 1.23.2(encoding@0.1.13)
       '@ibm-cloud/openapi-ruleset': 1.23.2(encoding@0.1.13)
-      acorn: 8.14.1
+      acorn: 8.15.0
       ajv: 8.17.1
       ajv: 8.17.1
       chalk: 4.1.2
       chalk: 4.1.2
       compare-versions: 6.1.1
       compare-versions: 6.1.1
@@ -19433,7 +19437,7 @@ snapshots:
       colorette: 1.4.0
       colorette: 1.4.0
       https-proxy-agent: 7.0.6(supports-color@10.0.0)
       https-proxy-agent: 7.0.6(supports-color@10.0.0)
       js-levenshtein: 1.1.6
       js-levenshtein: 1.1.6
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       minimatch: 5.1.6
       minimatch: 5.1.6
       pluralize: 8.0.0
       pluralize: 8.0.0
       yaml-ast-parser: 0.0.43
       yaml-ast-parser: 0.0.43
@@ -19446,23 +19450,23 @@ snapshots:
       immutable: 4.3.6
       immutable: 4.3.6
       redux: 4.2.1
       redux: 4.2.1
 
 
-  '@replit/codemirror-emacs@6.1.0(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)':
+  '@replit/codemirror-emacs@6.1.0(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)':
     dependencies:
     dependencies:
       '@codemirror/autocomplete': 6.18.4
       '@codemirror/autocomplete': 6.18.4
       '@codemirror/commands': 6.8.0
       '@codemirror/commands': 6.8.0
       '@codemirror/search': 6.5.6
       '@codemirror/search': 6.5.6
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
 
 
-  '@replit/codemirror-vim@6.2.1(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)':
+  '@replit/codemirror-vim@6.2.1(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)':
     dependencies:
     dependencies:
       '@codemirror/commands': 6.8.0
       '@codemirror/commands': 6.8.0
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/search': 6.5.6
       '@codemirror/search': 6.5.6
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
 
 
-  '@replit/codemirror-vscode-keymap@6.0.2(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)':
+  '@replit/codemirror-vscode-keymap@6.0.2(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)':
     dependencies:
     dependencies:
       '@codemirror/autocomplete': 6.18.4
       '@codemirror/autocomplete': 6.18.4
       '@codemirror/commands': 6.8.0
       '@codemirror/commands': 6.8.0
@@ -19470,7 +19474,7 @@ snapshots:
       '@codemirror/lint': 6.8.1
       '@codemirror/lint': 6.8.1
       '@codemirror/search': 6.5.6
       '@codemirror/search': 6.5.6
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
 
 
   '@restart/hooks@0.4.16(react@18.2.0)':
   '@restart/hooks@0.4.16(react@18.2.0)':
     dependencies:
     dependencies:
@@ -20685,7 +20689,7 @@ snapshots:
       handlebars-utils: 1.0.6
       handlebars-utils: 1.0.6
       inquirer: 9.3.7
       inquirer: 9.3.7
       inquirer-autocomplete-prompt: 3.0.1(inquirer@9.3.7)
       inquirer-autocomplete-prompt: 3.0.1(inquirer@9.3.7)
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       listr2: 8.2.5
       listr2: 8.2.5
       read-pkg-up: 11.0.0
       read-pkg-up: 11.0.0
       registry-url: 6.0.1
       registry-url: 6.0.1
@@ -21785,7 +21789,7 @@ snapshots:
       '@typescript-eslint/types': 5.59.7
       '@typescript-eslint/types': 5.59.7
       eslint-visitor-keys: 3.4.3
       eslint-visitor-keys: 3.4.3
 
 
-  '@uiw/codemirror-extensions-basic-setup@4.23.8(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)':
+  '@uiw/codemirror-extensions-basic-setup@4.23.8(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)':
     dependencies:
     dependencies:
       '@codemirror/autocomplete': 6.18.4
       '@codemirror/autocomplete': 6.18.4
       '@codemirror/commands': 6.8.0
       '@codemirror/commands': 6.8.0
@@ -21793,38 +21797,38 @@ snapshots:
       '@codemirror/lint': 6.8.1
       '@codemirror/lint': 6.8.1
       '@codemirror/search': 6.5.6
       '@codemirror/search': 6.5.6
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
 
 
-  '@uiw/codemirror-theme-eclipse@4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)':
+  '@uiw/codemirror-theme-eclipse@4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)':
     dependencies:
     dependencies:
-      '@uiw/codemirror-themes': 4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)
+      '@uiw/codemirror-themes': 4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - '@codemirror/language'
       - '@codemirror/language'
       - '@codemirror/state'
       - '@codemirror/state'
       - '@codemirror/view'
       - '@codemirror/view'
 
 
-  '@uiw/codemirror-theme-kimbie@4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)':
+  '@uiw/codemirror-theme-kimbie@4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)':
     dependencies:
     dependencies:
-      '@uiw/codemirror-themes': 4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)
+      '@uiw/codemirror-themes': 4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - '@codemirror/language'
       - '@codemirror/language'
       - '@codemirror/state'
       - '@codemirror/state'
       - '@codemirror/view'
       - '@codemirror/view'
 
 
-  '@uiw/codemirror-themes@4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)':
+  '@uiw/codemirror-themes@4.23.8(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)':
     dependencies:
     dependencies:
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
 
 
-  '@uiw/react-codemirror@4.23.8(@babel/runtime@7.25.4)(@codemirror/autocomplete@6.18.4)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.2)(codemirror@6.0.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
+  '@uiw/react-codemirror@4.23.8(@babel/runtime@7.25.4)(@codemirror/autocomplete@6.18.4)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.7)(codemirror@6.0.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
     dependencies:
     dependencies:
       '@babel/runtime': 7.25.4
       '@babel/runtime': 7.25.4
       '@codemirror/commands': 6.8.0
       '@codemirror/commands': 6.8.0
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
       '@codemirror/theme-one-dark': 6.1.2
       '@codemirror/theme-one-dark': 6.1.2
-      '@codemirror/view': 6.38.2
-      '@uiw/codemirror-extensions-basic-setup': 4.23.8(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)
+      '@codemirror/view': 6.38.7
+      '@uiw/codemirror-extensions-basic-setup': 4.23.8(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)
       codemirror: 6.0.1
       codemirror: 6.0.1
       react: 18.2.0
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
       react-dom: 18.2.0(react@18.2.0)
@@ -21838,14 +21842,14 @@ snapshots:
 
 
   '@unts/get-tsconfig@4.1.1': {}
   '@unts/get-tsconfig@4.1.1': {}
 
 
-  '@vitejs/plugin-react@4.3.1(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1))':
+  '@vitejs/plugin-react@4.3.1(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1))':
     dependencies:
     dependencies:
       '@babel/core': 7.24.6
       '@babel/core': 7.24.6
       '@babel/plugin-transform-react-jsx-self': 7.24.6(@babel/core@7.24.6)
       '@babel/plugin-transform-react-jsx-self': 7.24.6(@babel/core@7.24.6)
       '@babel/plugin-transform-react-jsx-source': 7.24.6(@babel/core@7.24.6)
       '@babel/plugin-transform-react-jsx-source': 7.24.6(@babel/core@7.24.6)
       '@types/babel__core': 7.20.5
       '@types/babel__core': 7.20.5
       react-refresh: 0.14.2
       react-refresh: 0.14.2
-      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1)
+      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
 
 
@@ -21863,7 +21867,7 @@ snapshots:
       std-env: 3.7.0
       std-env: 3.7.0
       test-exclude: 7.0.1
       test-exclude: 7.0.1
       tinyrainbow: 1.2.0
       tinyrainbow: 1.2.0
-      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.43.1)
+      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.44.1)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
 
 
@@ -21874,13 +21878,13 @@ snapshots:
       chai: 5.1.1
       chai: 5.1.1
       tinyrainbow: 1.2.0
       tinyrainbow: 1.2.0
 
 
-  '@vitest/mocker@2.1.1(@vitest/spy@2.1.1)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1))':
+  '@vitest/mocker@2.1.1(@vitest/spy@2.1.1)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1))':
     dependencies:
     dependencies:
       '@vitest/spy': 2.1.1
       '@vitest/spy': 2.1.1
       estree-walker: 3.0.3
       estree-walker: 3.0.3
       magic-string: 0.30.11
       magic-string: 0.30.11
     optionalDependencies:
     optionalDependencies:
-      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1)
+      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1)
 
 
   '@vitest/pretty-format@2.1.1':
   '@vitest/pretty-format@2.1.1':
     dependencies:
     dependencies:
@@ -21910,7 +21914,7 @@ snapshots:
       sirv: 2.0.4
       sirv: 2.0.4
       tinyglobby: 0.2.6
       tinyglobby: 0.2.6
       tinyrainbow: 1.2.0
       tinyrainbow: 1.2.0
-      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.43.1)
+      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.44.1)
 
 
   '@vitest/utils@2.1.1':
   '@vitest/utils@2.1.1':
     dependencies:
     dependencies:
@@ -22108,23 +22112,23 @@ snapshots:
       mime-types: 2.1.35
       mime-types: 2.1.35
       negotiator: 0.6.3
       negotiator: 0.6.3
 
 
-  acorn-import-attributes@1.9.5(acorn@8.14.1):
+  acorn-import-attributes@1.9.5(acorn@8.15.0):
     dependencies:
     dependencies:
-      acorn: 8.14.1
+      acorn: 8.15.0
 
 
   acorn-jsx@5.3.2(acorn@7.4.1):
   acorn-jsx@5.3.2(acorn@7.4.1):
     dependencies:
     dependencies:
       acorn: 7.4.1
       acorn: 7.4.1
 
 
-  acorn-jsx@5.3.2(acorn@8.14.1):
+  acorn-jsx@5.3.2(acorn@8.15.0):
     dependencies:
     dependencies:
-      acorn: 8.14.1
+      acorn: 8.15.0
 
 
   acorn-walk@8.3.2: {}
   acorn-walk@8.3.2: {}
 
 
   acorn@7.4.1: {}
   acorn@7.4.1: {}
 
 
-  acorn@8.14.1: {}
+  acorn@8.15.0: {}
 
 
   agent-base@6.0.2:
   agent-base@6.0.2:
     dependencies:
     dependencies:
@@ -22610,6 +22614,8 @@ snapshots:
 
 
   base64url@3.0.1: {}
   base64url@3.0.1: {}
 
 
+  baseline-browser-mapping@2.8.28: {}
+
   basic-auth@2.0.1:
   basic-auth@2.0.1:
     dependencies:
     dependencies:
       safe-buffer: 5.1.2
       safe-buffer: 5.1.2
@@ -22773,12 +22779,13 @@ snapshots:
       - supports-color
       - supports-color
       - utf-8-validate
       - utf-8-validate
 
 
-  browserslist@4.25.4:
+  browserslist@4.28.0:
     dependencies:
     dependencies:
-      caniuse-lite: 1.0.30001739
-      electron-to-chromium: 1.5.211
-      node-releases: 2.0.19
-      update-browserslist-db: 1.1.3(browserslist@4.25.4)
+      baseline-browser-mapping: 2.8.28
+      caniuse-lite: 1.0.30001754
+      electron-to-chromium: 1.5.252
+      node-releases: 2.0.27
+      update-browserslist-db: 1.1.4(browserslist@4.28.0)
 
 
   bs-recipes@1.3.4: {}
   bs-recipes@1.3.4: {}
 
 
@@ -22956,7 +22963,7 @@ snapshots:
 
 
   can-use-dom@0.1.0: {}
   can-use-dom@0.1.0: {}
 
 
-  caniuse-lite@1.0.30001739: {}
+  caniuse-lite@1.0.30001754: {}
 
 
   capital-case@1.0.4:
   capital-case@1.0.4:
     dependencies:
     dependencies:
@@ -23199,25 +23206,25 @@ snapshots:
 
 
   clsx@2.1.1: {}
   clsx@2.1.1: {}
 
 
-  cm6-theme-basic-light@0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)(@lezer/highlight@1.2.1):
+  cm6-theme-basic-light@0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)(@lezer/highlight@1.2.1):
     dependencies:
     dependencies:
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/highlight': 1.2.1
       '@lezer/highlight': 1.2.1
 
 
-  cm6-theme-material-dark@0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)(@lezer/highlight@1.2.1):
+  cm6-theme-material-dark@0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)(@lezer/highlight@1.2.1):
     dependencies:
     dependencies:
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/highlight': 1.2.1
       '@lezer/highlight': 1.2.1
 
 
-  cm6-theme-nord@0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)(@lezer/highlight@1.2.1):
+  cm6-theme-nord@0.2.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)(@lezer/highlight@1.2.1):
     dependencies:
     dependencies:
       '@codemirror/language': 6.11.3
       '@codemirror/language': 6.11.3
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       '@lezer/highlight': 1.2.1
       '@lezer/highlight': 1.2.1
 
 
   co@4.6.0: {}
   co@4.6.0: {}
@@ -23230,7 +23237,7 @@ snapshots:
       '@codemirror/lint': 6.8.1
       '@codemirror/lint': 6.8.1
       '@codemirror/search': 6.5.6
       '@codemirror/search': 6.5.6
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
 
 
   collect-v8-coverage@1.0.2: {}
   collect-v8-coverage@1.0.2: {}
 
 
@@ -23507,7 +23514,7 @@ snapshots:
     dependencies:
     dependencies:
       env-paths: 2.2.1
       env-paths: 2.2.1
       import-fresh: 3.3.0
       import-fresh: 3.3.0
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       parse-json: 5.2.0
       parse-json: 5.2.0
     optionalDependencies:
     optionalDependencies:
       typescript: 5.0.4
       typescript: 5.0.4
@@ -23516,7 +23523,7 @@ snapshots:
     dependencies:
     dependencies:
       env-paths: 2.2.1
       env-paths: 2.2.1
       import-fresh: 3.3.0
       import-fresh: 3.3.0
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       parse-json: 5.2.0
       parse-json: 5.2.0
     optionalDependencies:
     optionalDependencies:
       typescript: 5.4.2
       typescript: 5.4.2
@@ -24225,7 +24232,7 @@ snapshots:
     dependencies:
     dependencies:
       jake: 10.9.1
       jake: 10.9.1
 
 
-  electron-to-chromium@1.5.211: {}
+  electron-to-chromium@1.5.252: {}
 
 
   emittery@0.13.1: {}
   emittery@0.13.1: {}
 
 
@@ -24798,7 +24805,7 @@ snapshots:
       imurmurhash: 0.1.4
       imurmurhash: 0.1.4
       is-glob: 4.0.3
       is-glob: 4.0.3
       is-path-inside: 3.0.3
       is-path-inside: 3.0.3
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       json-stable-stringify-without-jsonify: 1.0.1
       json-stable-stringify-without-jsonify: 1.0.1
       levn: 0.4.1
       levn: 0.4.1
       lodash.merge: 4.6.2
       lodash.merge: 4.6.2
@@ -24821,8 +24828,8 @@ snapshots:
 
 
   espree@9.6.1:
   espree@9.6.1:
     dependencies:
     dependencies:
-      acorn: 8.14.1
-      acorn-jsx: 5.3.2(acorn@8.14.1)
+      acorn: 8.15.0
+      acorn-jsx: 5.3.2(acorn@8.15.0)
       eslint-visitor-keys: 3.4.3
       eslint-visitor-keys: 3.4.3
 
 
   esprima@4.0.1: {}
   esprima@4.0.1: {}
@@ -26083,8 +26090,8 @@ snapshots:
 
 
   import-in-the-middle@1.11.2:
   import-in-the-middle@1.11.2:
     dependencies:
     dependencies:
-      acorn: 8.14.1
-      acorn-import-attributes: 1.9.5(acorn@8.14.1)
+      acorn: 8.15.0
+      acorn-import-attributes: 1.9.5(acorn@8.15.0)
       cjs-module-lexer: 1.3.1
       cjs-module-lexer: 1.3.1
       module-details-from-path: 1.0.3
       module-details-from-path: 1.0.3
 
 
@@ -26842,7 +26849,7 @@ snapshots:
       argparse: 1.0.10
       argparse: 1.0.10
       esprima: 4.0.1
       esprima: 4.0.1
 
 
-  js-yaml@4.1.0:
+  js-yaml@4.1.1:
     dependencies:
     dependencies:
       argparse: 2.0.1
       argparse: 2.0.1
 
 
@@ -26915,7 +26922,7 @@ snapshots:
 
 
   jsonc-eslint-parser@2.4.0:
   jsonc-eslint-parser@2.4.0:
     dependencies:
     dependencies:
-      acorn: 8.14.1
+      acorn: 8.15.0
       eslint-visitor-keys: 3.4.3
       eslint-visitor-keys: 3.4.3
       espree: 9.6.1
       espree: 9.6.1
       semver: 7.6.3
       semver: 7.6.3
@@ -28155,7 +28162,7 @@ snapshots:
 
 
   mlly@1.7.4:
   mlly@1.7.4:
     dependencies:
     dependencies:
-      acorn: 8.14.1
+      acorn: 8.15.0
       pathe: 2.0.3
       pathe: 2.0.3
       pkg-types: 1.3.1
       pkg-types: 1.3.1
       ufo: 1.5.4
       ufo: 1.5.4
@@ -28433,7 +28440,7 @@ snapshots:
       '@next/env': 14.2.32
       '@next/env': 14.2.32
       '@swc/helpers': 0.5.5
       '@swc/helpers': 0.5.5
       busboy: 1.6.0
       busboy: 1.6.0
-      caniuse-lite: 1.0.30001739
+      caniuse-lite: 1.0.30001754
       graceful-fs: 4.2.11
       graceful-fs: 4.2.11
       postcss: 8.4.31
       postcss: 8.4.31
       react: 18.2.0
       react: 18.2.0
@@ -28533,7 +28540,7 @@ snapshots:
     dependencies:
     dependencies:
       es6-promise: 3.3.1
       es6-promise: 3.3.1
 
 
-  node-releases@2.0.19: {}
+  node-releases@2.0.27: {}
 
 
   nodemailer-ses-transport@1.5.1:
   nodemailer-ses-transport@1.5.1:
     dependencies:
     dependencies:
@@ -31431,15 +31438,15 @@ snapshots:
       jest-worker: 27.5.1
       jest-worker: 27.5.1
       schema-utils: 4.3.0
       schema-utils: 4.3.0
       serialize-javascript: 6.0.2
       serialize-javascript: 6.0.2
-      terser: 5.43.1
+      terser: 5.44.1
       webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))
       webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))
     optionalDependencies:
     optionalDependencies:
       '@swc/core': 1.10.7(@swc/helpers@0.5.15)
       '@swc/core': 1.10.7(@swc/helpers@0.5.15)
 
 
-  terser@5.43.1:
+  terser@5.44.1:
     dependencies:
     dependencies:
       '@jridgewell/source-map': 0.3.6
       '@jridgewell/source-map': 0.3.6
-      acorn: 8.14.1
+      acorn: 8.15.0
       commander: 2.20.3
       commander: 2.20.3
       source-map-support: 0.5.21
       source-map-support: 0.5.21
 
 
@@ -31606,7 +31613,7 @@ snapshots:
       '@tsconfig/node14': 1.0.3
       '@tsconfig/node14': 1.0.3
       '@tsconfig/node16': 1.0.3
       '@tsconfig/node16': 1.0.3
       '@types/node': 20.19.17
       '@types/node': 20.19.17
-      acorn: 8.14.1
+      acorn: 8.15.0
       acorn-walk: 8.3.2
       acorn-walk: 8.3.2
       arg: 4.1.3
       arg: 4.1.3
       create-require: 1.1.1
       create-require: 1.1.1
@@ -31626,7 +31633,7 @@ snapshots:
       '@tsconfig/node14': 1.0.3
       '@tsconfig/node14': 1.0.3
       '@tsconfig/node16': 1.0.3
       '@tsconfig/node16': 1.0.3
       '@types/node': 20.19.17
       '@types/node': 20.19.17
-      acorn: 8.14.1
+      acorn: 8.15.0
       acorn-walk: 8.3.2
       acorn-walk: 8.3.2
       arg: 4.1.3
       arg: 4.1.3
       create-require: 1.1.1
       create-require: 1.1.1
@@ -31853,7 +31860,7 @@ snapshots:
       debug: 4.4.1(supports-color@5.5.0)
       debug: 4.4.1(supports-color@5.5.0)
       dotenv: 8.6.0
       dotenv: 8.6.0
       glob: 7.2.3
       glob: 7.2.3
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       mkdirp: 1.0.4
       mkdirp: 1.0.4
       reflect-metadata: 0.1.14
       reflect-metadata: 0.1.14
       sha.js: 2.4.11
       sha.js: 2.4.11
@@ -32039,7 +32046,7 @@ snapshots:
 
 
   unplugin@2.3.5:
   unplugin@2.3.5:
     dependencies:
     dependencies:
-      acorn: 8.14.1
+      acorn: 8.15.0
       picomatch: 4.0.2
       picomatch: 4.0.2
       webpack-virtual-modules: 0.6.2
       webpack-virtual-modules: 0.6.2
 
 
@@ -32055,9 +32062,9 @@ snapshots:
       binary: 0.3.0
       binary: 0.3.0
       mkdirp: 0.5.6
       mkdirp: 0.5.6
 
 
-  update-browserslist-db@1.1.3(browserslist@4.25.4):
+  update-browserslist-db@1.1.4(browserslist@4.28.0):
     dependencies:
     dependencies:
-      browserslist: 4.25.4
+      browserslist: 4.28.0
       escalade: 3.2.0
       escalade: 3.2.0
       picocolors: 1.1.1
       picocolors: 1.1.1
 
 
@@ -32254,12 +32261,12 @@ snapshots:
       '@types/unist': 3.0.3
       '@types/unist': 3.0.3
       vfile-message: 4.0.2
       vfile-message: 4.0.2
 
 
-  vite-node@2.1.1(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1):
+  vite-node@2.1.1(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1):
     dependencies:
     dependencies:
       cac: 6.7.14
       cac: 6.7.14
       debug: 4.4.1(supports-color@5.5.0)
       debug: 4.4.1(supports-color@5.5.0)
       pathe: 1.1.2
       pathe: 1.1.2
-      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1)
+      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - '@types/node'
       - '@types/node'
       - less
       - less
@@ -32271,7 +32278,7 @@ snapshots:
       - supports-color
       - supports-color
       - terser
       - terser
 
 
-  vite-plugin-dts@3.9.1(@types/node@20.19.17)(rollup@4.39.0)(typescript@5.0.4)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1)):
+  vite-plugin-dts@3.9.1(@types/node@20.19.17)(rollup@4.39.0)(typescript@5.0.4)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1)):
     dependencies:
     dependencies:
       '@microsoft/api-extractor': 7.43.0(@types/node@20.19.17)
       '@microsoft/api-extractor': 7.43.0(@types/node@20.19.17)
       '@rollup/pluginutils': 5.2.0(rollup@4.39.0)
       '@rollup/pluginutils': 5.2.0(rollup@4.39.0)
@@ -32282,24 +32289,24 @@ snapshots:
       typescript: 5.0.4
       typescript: 5.0.4
       vue-tsc: 1.8.27(typescript@5.0.4)
       vue-tsc: 1.8.27(typescript@5.0.4)
     optionalDependencies:
     optionalDependencies:
-      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1)
+      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - '@types/node'
       - '@types/node'
       - rollup
       - rollup
       - supports-color
       - supports-color
 
 
-  vite-tsconfig-paths@5.0.1(typescript@5.0.4)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1)):
+  vite-tsconfig-paths@5.0.1(typescript@5.0.4)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1)):
     dependencies:
     dependencies:
       debug: 4.4.1(supports-color@5.5.0)
       debug: 4.4.1(supports-color@5.5.0)
       globrex: 0.1.2
       globrex: 0.1.2
       tsconfck: 3.0.3(typescript@5.0.4)
       tsconfck: 3.0.3(typescript@5.0.4)
     optionalDependencies:
     optionalDependencies:
-      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1)
+      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
       - typescript
       - typescript
 
 
-  vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1):
+  vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1):
     dependencies:
     dependencies:
       esbuild: 0.21.5
       esbuild: 0.21.5
       postcss: 8.5.3
       postcss: 8.5.3
@@ -32308,18 +32315,18 @@ snapshots:
       '@types/node': 20.19.17
       '@types/node': 20.19.17
       fsevents: 2.3.3
       fsevents: 2.3.3
       sass: 1.77.6
       sass: 1.77.6
-      terser: 5.43.1
+      terser: 5.44.1
 
 
   vitest-mock-extended@2.0.2(typescript@5.0.4)(vitest@2.1.1):
   vitest-mock-extended@2.0.2(typescript@5.0.4)(vitest@2.1.1):
     dependencies:
     dependencies:
       ts-essentials: 10.0.2(typescript@5.0.4)
       ts-essentials: 10.0.2(typescript@5.0.4)
       typescript: 5.0.4
       typescript: 5.0.4
-      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.43.1)
+      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.44.1)
 
 
-  vitest@2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.43.1):
+  vitest@2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.44.1):
     dependencies:
     dependencies:
       '@vitest/expect': 2.1.1
       '@vitest/expect': 2.1.1
-      '@vitest/mocker': 2.1.1(@vitest/spy@2.1.1)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1))
+      '@vitest/mocker': 2.1.1(@vitest/spy@2.1.1)(vite@5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1))
       '@vitest/pretty-format': 2.1.1
       '@vitest/pretty-format': 2.1.1
       '@vitest/runner': 2.1.1
       '@vitest/runner': 2.1.1
       '@vitest/snapshot': 2.1.1
       '@vitest/snapshot': 2.1.1
@@ -32334,8 +32341,8 @@ snapshots:
       tinyexec: 0.3.0
       tinyexec: 0.3.0
       tinypool: 1.0.1
       tinypool: 1.0.1
       tinyrainbow: 1.2.0
       tinyrainbow: 1.2.0
-      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1)
-      vite-node: 2.1.1(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1)
+      vite: 5.4.21(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1)
+      vite-node: 2.1.1(@types/node@20.19.17)(sass@1.77.6)(terser@5.44.1)
       why-is-node-running: 2.3.0
       why-is-node-running: 2.3.0
     optionalDependencies:
     optionalDependencies:
       '@types/node': 20.19.17
       '@types/node': 20.19.17
@@ -32438,7 +32445,7 @@ snapshots:
   webpack-bundle-analyzer@4.10.1:
   webpack-bundle-analyzer@4.10.1:
     dependencies:
     dependencies:
       '@discoveryjs/json-ext': 0.5.7
       '@discoveryjs/json-ext': 0.5.7
-      acorn: 8.14.1
+      acorn: 8.15.0
       acorn-walk: 8.3.2
       acorn-walk: 8.3.2
       commander: 7.2.0
       commander: 7.2.0
       debounce: 1.2.1
       debounce: 1.2.1
@@ -32465,9 +32472,9 @@ snapshots:
       '@webassemblyjs/ast': 1.14.1
       '@webassemblyjs/ast': 1.14.1
       '@webassemblyjs/wasm-edit': 1.14.1
       '@webassemblyjs/wasm-edit': 1.14.1
       '@webassemblyjs/wasm-parser': 1.14.1
       '@webassemblyjs/wasm-parser': 1.14.1
-      acorn: 8.14.1
-      acorn-import-attributes: 1.9.5(acorn@8.14.1)
-      browserslist: 4.25.4
+      acorn: 8.15.0
+      acorn-import-attributes: 1.9.5(acorn@8.15.0)
+      browserslist: 4.28.0
       chrome-trace-event: 1.0.4
       chrome-trace-event: 1.0.4
       enhanced-resolve: 5.18.1
       enhanced-resolve: 5.18.1
       es-module-lexer: 1.6.0
       es-module-lexer: 1.6.0
@@ -32682,10 +32689,10 @@ snapshots:
 
 
   xtend@4.0.2: {}
   xtend@4.0.2: {}
 
 
-  y-codemirror.next@0.3.5(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)(yjs@13.6.19):
+  y-codemirror.next@0.3.5(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)(yjs@13.6.19):
     dependencies:
     dependencies:
       '@codemirror/state': 6.5.2
       '@codemirror/state': 6.5.2
-      '@codemirror/view': 6.38.2
+      '@codemirror/view': 6.38.7
       lib0: 0.2.94
       lib0: 0.2.94
       yjs: 13.6.19
       yjs: 13.6.19