Browse Source

WIP: refactor isNotFound and isForbidden handling

Yuki Takei 6 months ago
parent
commit
a96115427d

+ 2 - 2
apps/app/src/client/components/Bookmarks/BookmarkItem.tsx

@@ -2,7 +2,7 @@ import React, { useCallback, useState, type JSX } from 'react';
 
 import nodePath from 'path';
 
-import type { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '@growi/core';
+import type { IPageHasId, IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useRouter } from 'next/router';
@@ -117,7 +117,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     }
   }, [bookmarkedPage, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
     if (bookmarkedPage == null) return;
 
     if (bookmarkedPage._id == null || bookmarkedPage.path == null) {

+ 13 - 10
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import {
-  type IPageInfoAll, isIPageInfoForOperation,
+  type IPageInfoExt, isIPageInfoForOperation,
 } from '@growi/core/dist/interfaces';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
@@ -33,18 +33,18 @@ export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
 export type ForceHideMenuItems = MenuItemType[];
 
-export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
+export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoExt };
 
 type CommonProps = {
-  pageInfo?: IPageInfoAll,
+  pageInfo?: IPageInfoExt,
   isEnableActions?: boolean,
   isReadOnlyUser?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
 
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
-  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoExt | undefined) => Promise<void> | void,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoExt | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
@@ -86,7 +86,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
       return;
     }
-    if (!pageInfo?.isMovable) {
+    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isMovable) {
       logger.warn('This page could not be renamed.');
       return;
     }
@@ -113,7 +113,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (pageInfo == null || onClickDeleteMenuItem == null) {
       return;
     }
-    if (!pageInfo.isDeletable) {
+    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isDeletable) {
       logger.warn('This page could not be deleted.');
       return;
     }
@@ -176,7 +176,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser
+          && isIPageInfoForOperation(pageInfo) && pageInfo.isMovable && (
           <DropdownItem
             onClick={renameItemClickedHandler}
             data-testid="rename-page-btn"
@@ -200,7 +201,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Revert */}
-        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser && pageInfo.isRevertible && (
+        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser
+          && isIPageInfoForOperation(pageInfo) && pageInfo.isRevertible && (
           <DropdownItem
             onClick={revertItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -230,7 +232,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* divider */}
         {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && pageInfo.isDeletable && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser
+          && isIPageInfoForOperation(pageInfo) && pageInfo.isDeletable && (
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem

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

@@ -4,7 +4,7 @@ import React, {
 } from 'react';
 
 import type {
-  IPageInfoAll, IPageWithMeta, IPageInfoForListing,
+  IPageInfoExt, IPageWithMeta, IPageInfoForListing,
 } from '@growi/core';
 import { isIPageInfoForListing, isIPageInfoForEntity } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
@@ -143,13 +143,13 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openDuplicateModal(page, { onDuplicated: onPageDuplicated });
   }, [onPageDuplicated, openDuplicateModal, pageData._id, pageData.path]);
 
-  const renameMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
+  const renameMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoExt | undefined) => {
     const page = { data: pageData, meta: pageInfo };
     openRenameModal(page, { onRenamed: onPageRenamed });
   }, [pageData, onPageRenamed, openRenameModal]);
 
 
-  const deleteMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
+  const deleteMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoExt | undefined) => {
     const pageToDelete = { data: pageData, meta: pageInfo };
 
     // open modal

+ 2 - 2
apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -5,7 +5,7 @@ import React, {
 
 import nodePath from 'path';
 
-import type { IPageInfoAll, IPageToDeleteWithMeta } from '@growi/core';
+import type { IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
@@ -76,7 +76,7 @@ export const usePageItemControl = (): UsePageItemControl => {
       setShowRenameInput(true);
     }, []);
 
-    const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+    const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
       if (onClickDeleteMenuItem == null) {
         return;
       }

+ 3 - 4
apps/app/src/pages/[[...path]]/index.page.tsx

@@ -84,11 +84,10 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   // Initialize Jotai atoms with initial data - must be called unconditionally
   const pageData = isInitialProps(props) ? props.pageWithMeta?.data : undefined;
-  useHydratePageAtoms(pageData, {
+  const pageMeta = isInitialProps(props) ? props.pageWithMeta?.meta : undefined;
+
+  useHydratePageAtoms(pageData, pageMeta, {
     redirectFrom: props.redirectFrom ?? undefined,
-    isNotFound: props.isNotFound,
-    isNotCreatable: props.isNotCreatable,
-    isForbidden: props.isForbidden,
     templateTags: props.templateTagData,
     templateBody: props.templateBodyData,
   });

+ 33 - 52
apps/app/src/pages/[[...path]]/page-data-props.ts

@@ -1,5 +1,13 @@
-import type { IPage, IUser } from '@growi/core/dist/interfaces';
-import { isPermalink as _isPermalink, isCreatablePage, isTopPage } from '@growi/core/dist/utils/page-path-utils';
+import assert from 'assert';
+
+import type {
+  IDataWithMeta, IPage, IPageNotFoundInfo, IUser,
+} from '@growi/core/dist/interfaces';
+import {
+  isIPageInfo,
+  isIPageNotFoundInfo,
+} from '@growi/core/dist/interfaces';
+import { isPermalink as _isPermalink, isTopPage } from '@growi/core/dist/utils/page-path-utils';
 import { removeHeadingSlash } from '@growi/core/dist/utils/path-utils';
 import type { model, HydratedDocument } from 'mongoose';
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
@@ -9,7 +17,7 @@ import type { PageModel } from '~/server/models/page';
 import type { IPageRedirect, PageRedirectModel } from '~/server/models/page-redirect';
 
 import type { CommonEachProps } from '../common-props';
-import type { GeneralPageInitialProps, GeneralPageStatesProps } from '../general-page';
+import type { GeneralPageInitialProps, IPageToShowRevisionWithMeta } from '../general-page';
 
 import type { EachProps } from './types';
 
@@ -87,44 +95,11 @@ function resolveFinalizedPathname(
   return finalPathname;
 }
 
-function getPageStatesPropsForIdenticalPathPage(): GeneralPageStatesProps {
-  return {
-    isNotFound: false,
-    isNotCreatable: true,
-    isForbidden: false,
-  };
-}
-
-async function getPageStatesProps(page: IPage | null, pagePath: string, pageId?: string | null): Promise<GeneralPageStatesProps> {
-  await initModels();
-
-  if (page != null) {
-    // Existing page
-    return {
-      isNotFound: page.isEmpty,
-      isNotCreatable: false,
-      isForbidden: false,
-    };
-  }
-
-  // Handle non-existent page
-  const isPermalink = _isPermalink(pagePath);
-  const count = isPermalink && pageId
-    ? await Page.count({ _id: pageId })
-    : await Page.count({ path: pagePath });
-
-  return {
-    isNotFound: true,
-    isNotCreatable: !isCreatablePage(pagePath),
-    isForbidden: count > 0,
-  };
-}
 
 // Page data retrieval for initial load - returns GetServerSidePropsResult
 export async function getPageDataForInitial(
     context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<
-  GeneralPageStatesProps &
   Pick<GeneralPageInitialProps, 'pageWithMeta' | 'skipSSR'> &
   Pick<EachProps, 'currentPathname' | 'isIdenticalPathPage' | 'redirectFrom'>
 >> {
@@ -153,25 +128,29 @@ export async function getPageDataForInitial(
         pageWithMeta: null,
         skipSSR: false,
         redirectFrom,
-        ...getPageStatesPropsForIdenticalPathPage(),
       },
     };
   }
 
   // Get full page data
   const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, resolvedPagePath, user, true);
-  const { data: page, meta } = pageWithMeta ?? {};
 
   // Handle URL conversion
-  const currentPathname = resolveFinalizedPathname(resolvedPagePath, page, isPermalink);
+  const currentPathname = resolveFinalizedPathname(resolvedPagePath, pageWithMeta.data, isPermalink);
 
-  // Add user to seen users
-  if (page != null && user != null) {
-    await page.seen(user);
-  }
+  // When the page exists
+  if (pageWithMeta.data != null) {
+    const { data: page, meta } = pageWithMeta;
 
-  if (page != null) {
-    // Handle existing page
+    // type assertion
+    assert(isIPageInfo(meta), 'meta should be IPageInfo when data is not null');
+
+    // Add user to seen users
+    if (user != null) {
+      await page.seen(user);
+    }
+
+    // Handle existing page with valid meta that is not IPageNotFoundInfo
     page.initLatestRevisionField(revisionId);
     const ssrMaxRevisionBodyLength = configManager.getConfig('app:ssrMaxRevisionBodyLength');
 
@@ -185,22 +164,27 @@ export async function getPageDataForInitial(
       props: {
         currentPathname,
         isIdenticalPathPage: false,
-        pageWithMeta: { data: populatedPage, meta },
+        pageWithMeta: {
+          data: populatedPage,
+          meta,
+        } satisfies IPageToShowRevisionWithMeta,
         skipSSR,
         redirectFrom,
-        ...await getPageStatesProps(page, resolvedPagePath, pageId),
       },
     };
   }
 
+  // type assertion
+  assert(isIPageNotFoundInfo(pageWithMeta.meta), 'meta should be IPageNotFoundInfo when data is null');
+
+  // Handle the case where the page does not exist
   return {
     props: {
       currentPathname: resolvedPagePath,
       isIdenticalPathPage: false,
-      pageWithMeta: null,
+      pageWithMeta: pageWithMeta satisfies IDataWithMeta<null, IPageNotFoundInfo>,
       skipSSR: false,
       redirectFrom,
-      ...await getPageStatesProps(null, resolvedPagePath, pageId),
     },
   };
 }
@@ -209,7 +193,6 @@ export async function getPageDataForInitial(
 export async function getPageDataForSameRoute(
     context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<
-    GeneralPageStatesProps &
     Pick<CommonEachProps, 'currentPathname'> &
     Pick<EachProps, 'currentPathname' | 'isIdenticalPathPage' | 'redirectFrom'>
 >> {
@@ -228,7 +211,6 @@ export async function getPageDataForSameRoute(
         currentPathname: resolvedPagePath,
         isIdenticalPathPage: true,
         redirectFrom,
-        ...getPageStatesPropsForIdenticalPathPage(),
       },
     };
   }
@@ -245,7 +227,6 @@ export async function getPageDataForSameRoute(
       currentPathname,
       isIdenticalPathPage: false,
       redirectFrom,
-      ...await getPageStatesProps(basicPageInfo, resolvedPagePath, pageId),
     },
   };
 }

+ 6 - 2
apps/app/src/pages/general-page/get-activity-action.ts

@@ -1,3 +1,4 @@
+import type { IDataWithMeta, IPageNotFoundInfo } from '@growi/core/dist/interfaces';
 import { pagePathUtils } from '@growi/core/dist/utils';
 
 import { SupportedAction } from '~/interfaces/activity';
@@ -9,7 +10,7 @@ export const getActivityAction = (props: {
   isNotCreatable: boolean;
   isForbidden: boolean;
   isNotFound: boolean;
-  pageWithMeta?: IPageToShowRevisionWithMeta | null;
+  pageWithMeta?: IPageToShowRevisionWithMeta | IDataWithMeta<null, IPageNotFoundInfo> | null;
 }): SupportedActionType => {
   if (props.isNotCreatable) {
     return SupportedAction.ACTION_PAGE_NOT_CREATABLE;
@@ -20,7 +21,10 @@ export const getActivityAction = (props: {
   if (props.isNotFound) {
     return SupportedAction.ACTION_PAGE_NOT_FOUND;
   }
-  if (pagePathUtils.isUsersHomepage(props.pageWithMeta?.data.path ?? '')) {
+
+  // Type-safe access to page data - only access path if data is not null
+  const pagePath = props.pageWithMeta?.data?.path ?? '';
+  if (pagePathUtils.isUsersHomepage(pagePath)) {
     return SupportedAction.ACTION_PAGE_USER_HOME_VIEW;
   }
   return SupportedAction.ACTION_PAGE_VIEW;

+ 7 - 11
apps/app/src/pages/general-page/type-guards.ts

@@ -1,3 +1,5 @@
+import { isIPageInfo } from '@growi/core/dist/interfaces';
+
 import loggerFactory from '~/utils/logger';
 
 import type { GeneralPageInitialProps } from './types';
@@ -23,17 +25,11 @@ export function isValidGeneralPageInitialProps(props: unknown): props is General
   }
 
   // GeneralPageInitialProps specific page state
-  if (typeof p.isNotFound !== 'boolean') {
-    logger.warn('isValidGeneralPageInitialProps: isNotFound is not a boolean', { isNotFound: p.isNotFound });
-    return false;
-  }
-  if (typeof p.isForbidden !== 'boolean') {
-    logger.warn('isValidGeneralPageInitialProps: isForbidden is not a boolean', { isForbidden: p.isForbidden });
-    return false;
-  }
-  if (typeof p.isNotCreatable !== 'boolean') {
-    logger.warn('isValidGeneralPageInitialProps: isNotCreatable is not a boolean', { isNotCreatable: p.isNotCreatable });
-    return false;
+  if (p.meta != null && typeof p.meta === 'object') {
+    if (!isIPageInfo(p.meta)) {
+      logger.warn('isValidGeneralPageInitialProps: meta is not a valid IPageInfo', { meta: p.meta });
+      return false;
+    }
   }
 
   return true;

+ 6 - 11
apps/app/src/pages/general-page/types.ts

@@ -1,11 +1,11 @@
 import type {
-  IDataWithMeta, IPageInfo, IPagePopulatedToShowRevision,
+  IDataWithMeta, IPageInfoExt, IPageNotFoundInfo, IPagePopulatedToShowRevision,
 } from '@growi/core';
 
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { PageDocument } from '~/server/models/page';
 
-export type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfo>;
+export type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfoExt>;
 
 export type RendererConfigProps = {
   rendererConfig: RendererConfig,
@@ -37,17 +37,12 @@ export type ServerConfigurationProps = {
   },
 }
 
-export type GeneralPageStatesProps = {
-  isNotFound: boolean,
-  isForbidden: boolean,
-  isNotCreatable: boolean,
-}
-
 // Do not include CommonEachProps for multi stage
-export type GeneralPageEachProps = GeneralPageStatesProps;
+// eslint-disable-next-line @typescript-eslint/ban-types
+export type GeneralPageEachProps = {};
 
 // Do not include CommonEachProps for multi stage
-export type GeneralPageInitialProps = GeneralPageStatesProps & RendererConfigProps & ServerConfigurationProps & {
-  pageWithMeta: IPageToShowRevisionWithMeta | null,
+export type GeneralPageInitialProps = RendererConfigProps & ServerConfigurationProps & {
+  pageWithMeta: IPageToShowRevisionWithMeta | IDataWithMeta<null, IPageNotFoundInfo> | null,
   skipSSR?: boolean,
 }

+ 4 - 2
apps/app/src/pages/share/[[...path]]/index.page.tsx

@@ -43,10 +43,12 @@ const isInitialProps = (props: Props): props is InitialProps => {
 const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
 
   // Initialize Jotai atoms with initial data - must be called unconditionally
+  const pageData = isInitialProps(props) ? props.pageWithMeta?.data : undefined;
+  const pageMeta = isInitialProps(props) ? props.pageWithMeta?.meta : undefined;
   const shareLink = isInitialProps(props) ? props.shareLink : undefined;
   const isExpired = isInitialProps(props) ? props.isExpired : undefined;
-  const pageData = isInitialProps(props) ? props.pageWithMeta?.data : undefined;
-  useHydratePageAtoms(pageData, {
+
+  useHydratePageAtoms(pageData, pageMeta, {
     shareLinkId: shareLink?._id,
   });
 

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

@@ -313,8 +313,10 @@ const routerFactory = (crowi: Crowi): Router => {
         const userRelatedGroups = await pageGrantService.getUserRelatedGroups(req.user);
 
         for (const page of pages) {
-        // construct isIPageInfoForListing
-          const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
+          const basicPageInfo = {
+            ...pageService.constructBasicPageInfo(page, isGuestUser),
+            bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id.toString()] ?? 0 : 0,
+          };
 
           // TODO: use pageService.getCreatorIdForCanDelete to get creatorId (https://redmine.weseek.co.jp/issues/140574)
           const canDeleteCompletely = pageService.canDeleteCompletely(
@@ -327,13 +329,11 @@ const routerFactory = (crowi: Crowi): Router => {
 
           const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
             ? basicPageInfo
-          // create IPageInfoForListing
             : {
               ...basicPageInfo,
               isAbleToDeleteCompletely: canDeleteCompletely,
-              bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id.toString()] : undefined,
               revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id.toString()] : undefined,
-            } as IPageInfoForListing;
+            } satisfies IPageInfoForListing;
 
           idToPageInfoMap[page._id.toString()] = pageInfo;
         }

+ 21 - 11
apps/app/src/server/routes/apiv3/page/index.ts

@@ -2,8 +2,11 @@ import path from 'path';
 import { type Readable } from 'stream';
 import { pipeline } from 'stream/promises';
 
-import type { IPage, IRevision } from '@growi/core';
+import type {
+  IDataWithMeta, IPage, IPageInfoExt, IPageNotFoundInfo, IRevision,
+} from '@growi/core';
 import {
+  isIPageNotFoundInfo,
   AllSubscriptionStatusType, PageGrant, SCOPE, SubscriptionStatusType,
   getIdForRef,
 } from '@growi/core';
@@ -183,21 +186,21 @@ module.exports = (crowi) => {
         return res.apiv3Err(new Error('Either parameter of (pageId or path) or (pageId and shareLinkId) is required.'), 400);
       }
 
-      let page;
+      let pageWithMeta: IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt> | IDataWithMeta<null, IPageNotFoundInfo> = {
+        data: null,
+      };
       let pages;
       try {
         if (isSharedPage) {
           const shareLink = await ShareLink.findOne({ _id: { $eq: shareLinkId } });
           if (shareLink == null) {
-            throw new Error('ShareLink is not found');
+            return res.apiv3Err('ShareLink is not found', 404);
           }
-          page = await Page.findOne({ _id: getIdForRef(shareLink.relatedPage) });
-        }
-        else if (pageId != null) { // prioritized
-          page = await Page.findByIdAndViewer(pageId, user);
+          // page = await Page.findOne({ _id: getIdForRef(shareLink.relatedPage) });
+          pageWithMeta = await pageService.findPageAndMetaDataByShareLink(getIdForRef(shareLink.relatedPage), path, user, false, true);
         }
         else if (!findAll) {
-          page = await Page.findByPathAndViewer(path, user, null, true, false);
+          pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, path, user, false);
         }
         else {
           pages = await Page.findByPathAndViewer(path, user, null, false, includeEmpty);
@@ -208,8 +211,15 @@ module.exports = (crowi) => {
         return res.apiv3Err(err, 500);
       }
 
+      let { data: page } = pageWithMeta;
+      const { meta } = pageWithMeta;
+
+      // not found or forbidden
       if (page == null && (pages == null || pages.length === 0)) {
-        return res.apiv3Err('Page is not found', 404);
+        if (isIPageNotFoundInfo(meta) && meta.isForbidden) {
+          return res.apiv3Err('Page is forbidden', 403, meta);
+        }
+        return res.apiv3Err('Page is not found', 404, meta);
       }
 
       if (page != null) {
@@ -221,11 +231,11 @@ module.exports = (crowi) => {
         }
         catch (err) {
           logger.error('populate-page-failed', err);
-          return res.apiv3Err(err, 500);
+          return res.apiv3Err(err, 500, meta);
         }
       }
 
-      return res.apiv3({ page, pages });
+      return res.apiv3({ page, pages, meta });
     });
 
   router.get('/page-paths-with-descendant-count', getPagePathsWithDescendantCountFactory(crowi));

+ 51 - 21
apps/app/src/server/service/page/index.ts

@@ -7,11 +7,11 @@ import {
   PageStatus, YDocStatus, getIdForRef,
   getIdStringForRef,
 } from '@growi/core';
-import { PageGrant } from '@growi/core/dist/interfaces';
+import { PageGrant, isIPageInfoForEntity } from '@growi/core/dist/interfaces';
 import type {
   Ref, HasObjectId, IUserHasId, IUser,
-  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IGrantedGroup, IRevisionHasId,
-  IDataWithMeta,
+  IPage, IGrantedGroup, IRevisionHasId,
+  IDataWithMeta, IPageNotFoundInfo, IPageInfoExt, IPageInfo, IPageInfoForEntity, IPageInfoForOperation,
 } from '@growi/core/dist/interfaces';
 import {
   pagePathUtils, pathUtils,
@@ -404,8 +404,15 @@ class PageService implements IPageService {
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   async findPageAndMetaDataByViewer(
-      pageId: string | null, path: string, user?: HydratedDocument<IUser>, includeEmpty = false, isSharedPage = false,
-  ): Promise<IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoAll>|null> {
+      pageId: string | null,
+      path: string,
+      user?: HydratedDocument<IUser>,
+      includeEmpty = false,
+      isSharedPage = false,
+  ): Promise<
+    IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt> |
+    IDataWithMeta<null, IPageNotFoundInfo>
+  > {
 
     const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
 
@@ -418,46 +425,48 @@ class PageService implements IPageService {
     }
 
     if (page == null) {
-      return null;
+      return {
+        data: null,
+        meta: {
+          isNotFound: true,
+          isForbidden: false,
+        },
+      };
     }
 
     if (isSharedPage) {
       return {
         data: page,
         meta: {
+          isNotFound: false,
           isV5Compatible: isTopPage(page.path) || page.parent != null,
           isEmpty: page.isEmpty,
           isMovable: false,
           isDeletable: false,
           isAbleToDeleteCompletely: false,
           isRevertible: false,
+          bookmarkCount: 0,
         },
       };
     }
 
     const isGuestUser = user == null;
-    const pageInfo = this.constructBasicPageInfo(page, isGuestUser);
 
-    const Bookmark = mongoose.model<BookmarkedPage, { countByPageId, findByPageIdAndUserId }>('Bookmark');
-    const bookmarkCount = await Bookmark.countByPageId(pageId);
+    const Bookmark = mongoose.model<BookmarkedPage, { countDocuments, findByPageIdAndUserId }>('Bookmark');
+    const bookmarkCount: number = await Bookmark.countDocuments({ page: pageId });
 
-    const metadataForGuest = {
-      ...pageInfo,
+    const pageInfo = {
+      ...this.constructBasicPageInfo(page, isGuestUser),
       bookmarkCount,
-    };
+    } satisfies IPageInfo | IPageInfoForEntity;
 
     if (isGuestUser) {
       return {
         data: page,
-        meta: metadataForGuest,
+        meta: pageInfo,
       };
     }
 
-    const isBookmarked: boolean = (await Bookmark.findByPageIdAndUserId(pageId, user._id)) != null;
-    const isLiked: boolean = page.isLiked(user);
-
-    const subscription = await Subscription.findByUserIdAndTargetId(user._id, page._id);
-
     const creatorId = await this.getCreatorIdForCanDelete(page);
 
     const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
@@ -465,17 +474,36 @@ class PageService implements IPageService {
     const isDeletable = this.canDelete(page, creatorId, user, false);
     const isAbleToDeleteCompletely = this.canDeleteCompletely(page, creatorId, user, false, userRelatedGroups); // use normal delete config
 
+    if (!isIPageInfoForEntity(pageInfo)) {
+      return {
+        data: page,
+        meta: {
+          ...pageInfo,
+          isDeletable,
+          isAbleToDeleteCompletely,
+        } satisfies IPageInfo,
+      };
+    }
+
+    const isBookmarked: boolean = isGuestUser
+      ? false
+      : (await Bookmark.findByPageIdAndUserId(pageId, user._id)) != null;
+
+    const isLiked: boolean = page.isLiked(user);
+    const subscription = await Subscription.findByUserIdAndTargetId(user._id, page._id);
+
     return {
       data: page,
       meta: {
-        ...metadataForGuest,
+        ...pageInfo,
         isDeletable,
         isAbleToDeleteCompletely,
         isBookmarked,
         isLiked,
         subscriptionStatus: subscription?.status,
-      },
+      } satisfies IPageInfoForOperation,
     };
+
   }
 
   private shouldUseV4ProcessForRevert(page): boolean {
@@ -2538,12 +2566,13 @@ class PageService implements IPageService {
     });
   }
 
-  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | Omit<IPageInfoForEntity, 'bookmarkCount'> {
+  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): | Omit<IPageInfo | IPageInfoForEntity, 'bookmarkCount'> {
     const isMovable = isGuestUser ? false : isMovablePage(page.path);
     const isDeletable = !(isGuestUser || isTopPage(page.path) || isUsersTopPage(page.path));
 
     if (page.isEmpty) {
       return {
+        isNotFound: true,
         isV5Compatible: true,
         isEmpty: true,
         isMovable,
@@ -2557,6 +2586,7 @@ class PageService implements IPageService {
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
 
     const infoForEntity: Omit<IPageInfoForEntity, 'bookmarkCount'> = {
+      isNotFound: false,
       isV5Compatible: isTopPage(page.path) || page.parent != null,
       isEmpty: false,
       sumOfLikers: page.liker.length,

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

@@ -4,7 +4,7 @@ import type {
   HasObjectId,
   IDataWithMeta,
   IGrantedGroup,
-  IPageInfo, IPageInfoAll, IPageInfoForEntity, IUser,
+  IPageInfo, IPageInfoForEntity, IPageNotFoundInfo, IUser, IPageInfoExt,
 } from '@growi/core';
 import type { HydratedDocument, Types } from 'mongoose';
 
@@ -30,7 +30,7 @@ export interface IPageService {
   deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
   findPageAndMetaDataByViewer(
       pageId: string | null, path: string, user?: HydratedDocument<IUser>, includeEmpty?: boolean, isSharedPage?: boolean,
-  ): Promise<IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoAll>|null>
+  ): Promise<IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt> | IDataWithMeta<null, IPageNotFoundInfo>>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
   findChildrenByParentPathOrIdAndViewer(
     parentPathOrId: string, user, userGroups?, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
@@ -43,7 +43,7 @@ export interface IPageService {
     user: IUser,
 ): Promise<void>
   shortBodiesMapByPageIds(pageIds?: Types.ObjectId[], user?): Promise<Record<string, string | null>>,
-  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | Omit<IPageInfoForEntity, 'bookmarkCount'>,
+  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): Omit<IPageInfo | IPageInfoForEntity, 'bookmarkCount'>,
   normalizeAllPublicPages(): Promise<void>,
   canDelete(page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean): boolean,
   canDeleteCompletely(

+ 29 - 16
apps/app/src/states/page/hydrate.ts

@@ -1,4 +1,10 @@
-import type { IPagePopulatedToShowRevision } from '@growi/core';
+import {
+  type IPageInfo,
+  type IPageNotFoundInfo,
+  type IPagePopulatedToShowRevision,
+  isIPageInfo,
+  isIPageNotFoundInfo,
+} from '@growi/core';
 import { useHydrateAtoms } from 'jotai/utils';
 
 import {
@@ -6,7 +12,6 @@ import {
   currentPageIdAtom,
   isForbiddenAtom,
   latestRevisionAtom,
-  pageNotCreatableAtom,
   pageNotFoundAtom,
   redirectFromAtom,
   remoteRevisionBodyAtom,
@@ -40,25 +45,31 @@ import {
  * });
  */
 export const useHydratePageAtoms = (
-  page: IPagePopulatedToShowRevision | undefined,
+  page: IPagePopulatedToShowRevision | null | undefined,
+  pageMeta: IPageNotFoundInfo | IPageInfo | undefined,
   options?: {
+    // always overwrited
     isLatestRevision?: boolean;
     shareLinkId?: string;
-    isNotFound?: boolean; // always overwrited
-    isNotCreatable?: boolean; // always overwrited
-    isForbidden?: boolean; // always overwrited
-    redirectFrom?: string; // always overwrited
-    templateTags?: string[]; // always overwrited
-    templateBody?: string; // always overwrited
+    redirectFrom?: string;
+    templateTags?: string[];
+    templateBody?: string;
   },
 ): void => {
   useHydrateAtoms([
     // Core page state - automatically extract from page object
     [currentPageIdAtom, page?._id],
-    [currentPageDataAtom, page],
-
-    // ShareLink page state
-    [shareLinkIdAtom, options?.shareLinkId],
+    [currentPageDataAtom, page ?? undefined],
+    [
+      pageNotFoundAtom,
+      isIPageInfo(pageMeta)
+        ? pageMeta.isNotFound
+        : page == null || page.isEmpty,
+    ],
+    [
+      isForbiddenAtom,
+      isIPageNotFoundInfo(pageMeta) ? pageMeta.isForbidden : false,
+    ],
 
     // Remote revision data - auto-extracted from page.revision
     [remoteRevisionIdAtom, page?.revision?._id],
@@ -68,11 +79,13 @@ export const useHydratePageAtoms = (
   // always overwrited
   useHydrateAtoms(
     [
-      [pageNotFoundAtom, options?.isNotFound ?? (page == null || page.isEmpty)],
-      [pageNotCreatableAtom, options?.isNotCreatable ?? false],
-      [isForbiddenAtom, options?.isForbidden ?? false],
       [latestRevisionAtom, options?.isLatestRevision ?? true],
+
+      // ShareLink page state
+      [shareLinkIdAtom, options?.shareLinkId],
+
       [redirectFromAtom, options?.redirectFrom ?? undefined],
+
       // Template data - from options (not auto-extracted from page)
       [templateTagsAtom, options?.templateTags ?? []],
       [templateBodyAtom, options?.templateBody ?? ''],

+ 24 - 20
packages/core/src/interfaces/page.ts

@@ -83,17 +83,25 @@ export type PageStatus = (typeof PageStatus)[keyof typeof PageStatus];
 
 export type IPageHasId = IPage & HasObjectId;
 
+export type IPageNotFoundInfo = {
+  isNotFound: true;
+  isForbidden: boolean;
+};
+
 export type IPageInfo = {
+  isNotFound: boolean;
   isV5Compatible: boolean;
   isEmpty: boolean;
   isMovable: boolean;
   isDeletable: boolean;
   isAbleToDeleteCompletely: boolean;
   isRevertible: boolean;
+  bookmarkCount: number;
 };
 
-export type IPageInfoForEntity = IPageInfo & {
-  bookmarkCount: number;
+export type IPageInfoForEntity = Omit<IPageInfo, 'isNotFound' | 'isEmpty'> & {
+  isNotFound: false;
+  isEmpty: false;
   sumOfLikers: number;
   likerIds: string[];
   sumOfSeenUsers: number;
@@ -111,12 +119,24 @@ export type IPageInfoForOperation = IPageInfoForEntity & {
 
 export type IPageInfoForListing = IPageInfoForEntity & HasRevisionShortbody;
 
-export type IPageInfoAll =
+export type IPageInfoExt =
   | IPageInfo
   | IPageInfoForEntity
   | IPageInfoForOperation
   | IPageInfoForListing;
 
+export const isIPageNotFoundInfo = (
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  pageInfo: any | undefined,
+): pageInfo is IPageNotFoundInfo => {
+  return (
+    pageInfo != null &&
+    pageInfo instanceof Object &&
+    pageInfo.isNotFound === true &&
+    !('isEmpty' in pageInfo)
+  );
+};
+
 export const isIPageInfo = (
   // biome-ignore lint/suspicious/noExplicitAny: ignore
   pageInfo: any | undefined,
@@ -152,28 +172,12 @@ export const isIPageInfoForListing = (
   return isIPageInfoForEntity(pageInfo) && 'revisionShortBody' in pageInfo;
 };
 
-// export type IPageInfoTypeResolver<T extends IPageInfo> =
-//   T extends HasRevisionShortbody ? IPageInfoForListing :
-//   T extends { isBookmarked?: boolean } | { isLiked?: boolean } | { subscriptionStatus?: SubscriptionStatusType } ? IPageInfoForOperation :
-//   T extends { bookmarkCount: number } ? IPageInfoForEntity :
-//   T extends { isEmpty: number } ? IPageInfo :
-//   T;
-
-/**
- * Union Distribution
- * @param pageInfo
- * @returns
- */
-// export const resolvePageInfo = <T extends IPageInfo>(pageInfo: T | undefined): IPageInfoTypeResolver<T> => {
-//   return <IPageInfoTypeResolver<T>>pageInfo;
-// };
-
 export type IDataWithMeta<D = unknown, M = unknown> = {
   data: D;
   meta?: M;
 };
 
-export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
+export type IPageWithMeta<M = IPageInfoExt> = IDataWithMeta<IPageHasId, M>;
 
 export type IPageToDeleteWithMeta<T = IPageInfoForEntity | unknown> =
   IDataWithMeta<