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

Merge pull request #11000 from growilabs/imprv/181627

feat: add dedicated share link page API and simplify GET /page
Shun Miyazawa 1 день назад
Родитель
Сommit
17205fdcc2

+ 27 - 0
apps/app/src/server/middlewares/reject-link-sharing-disabled.ts

@@ -0,0 +1,27 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { RequestHandler } from 'express';
+
+import { configManager } from '~/server/service/config-manager';
+
+import type { ApiV3Response } from '../routes/apiv3/interfaces/apiv3-response';
+
+/**
+ * Middleware that rejects requests when link sharing is globally disabled.
+ * Place before certifySharedPage to skip unnecessary DB access.
+ */
+export const rejectLinkSharingDisabled: RequestHandler = (
+  _req,
+  res: ApiV3Response,
+  next,
+) => {
+  const disableLinkSharing = configManager.getConfig(
+    'security:disableLinkSharing',
+  );
+  if (disableLinkSharing) {
+    return res.apiv3Err(
+      new ErrorV3('Link sharing is disabled', 'link-sharing-disabled'),
+      403,
+    );
+  }
+  return next();
+};

+ 119 - 0
apps/app/src/server/routes/apiv3/page/get-page-by-share-link.ts

@@ -0,0 +1,119 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { query } from 'express-validator';
+
+import type Crowi from '~/server/crowi';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { rejectLinkSharingDisabled } from '~/server/middlewares/reject-link-sharing-disabled';
+import { configManager } from '~/server/service/config-manager';
+import { findPageAndMetaDataByViewer } from '~/server/service/page/find-page-and-meta-data-by-viewer';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+import { respondWithSinglePage } from './respond-with-single-page';
+
+const logger = loggerFactory('growi:routes:apiv3:page:get-page-by-share-link');
+
+type ReqQuery = {
+  pageId: string;
+  shareLinkId: string;
+};
+
+type Req = Request<
+  Record<string, string>,
+  ApiV3Response,
+  undefined,
+  ReqQuery
+> & {
+  isSharedPage?: boolean;
+};
+
+/**
+ * @swagger
+ *
+ *    /page/shared:
+ *      get:
+ *        tags: [Page]
+ *        summary: Get page by share link
+ *        description: Get page data via a valid share link (public endpoint, no authentication required)
+ *        parameters:
+ *          - name: shareLinkId
+ *            in: query
+ *            required: true
+ *            description: share link ID
+ *            schema:
+ *              $ref: '#/components/schemas/ObjectId'
+ *          - name: pageId
+ *            in: query
+ *            required: true
+ *            description: page ID
+ *            schema:
+ *              $ref: '#/components/schemas/ObjectId'
+ *        responses:
+ *          200:
+ *            description: Successfully retrieved page via share link
+ *            content:
+ *              application/json:
+ *                schema:
+ *                  $ref: '#/components/schemas/GetPageResponse'
+ *          403:
+ *            description: Link sharing disabled, link expired, or forbidden page
+ *          404:
+ *            description: Share link not found or page not found
+ *          400:
+ *            description: Invalid or missing parameters
+ */
+export const getPageByShareLinkHandlerFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const { pageService, pageGrantService } = crowi;
+  const certifySharedPage = require('../../../middlewares/certify-shared-page')(
+    crowi,
+  );
+
+  const validator = [
+    query('shareLinkId').isMongoId().withMessage('shareLinkId is required'),
+    query('pageId').isMongoId().withMessage('pageId is required'),
+  ];
+
+  return [
+    ...validator,
+    apiV3FormValidator,
+    rejectLinkSharingDisabled,
+    certifySharedPage,
+    async (req: Req, res: ApiV3Response) => {
+      const { pageId } = req.query;
+
+      if (!req.isSharedPage) {
+        return res.apiv3Err(
+          new ErrorV3(
+            'Share link is not found or has expired',
+            'share-link-invalid',
+          ),
+          404,
+        );
+      }
+
+      try {
+        const pageWithMeta = await findPageAndMetaDataByViewer(
+          pageService,
+          pageGrantService,
+          {
+            pageId,
+            path: null,
+            user: undefined,
+            isSharedPage: true,
+          },
+        );
+
+        const disableUserPages = configManager.getConfig(
+          'security:disableUserPages',
+        );
+        return respondWithSinglePage(res, pageWithMeta, { disableUserPages });
+      } catch (err) {
+        logger.error('get-page-by-share-link-failed', err);
+        return res.apiv3Err(err, 500);
+      }
+    },
+  ];
+};

+ 10 - 99
apps/app/src/server/routes/apiv3/page/index.ts

@@ -10,7 +10,6 @@ import type {
 import {
 import {
   AllSubscriptionStatusType,
   AllSubscriptionStatusType,
   getIdForRef,
   getIdForRef,
-  getIdStringForRef,
   isIPageNotFoundInfo,
   isIPageNotFoundInfo,
   PageGrant,
   PageGrant,
   SCOPE,
   SCOPE,
@@ -40,7 +39,6 @@ import loginRequiredFactory from '~/server/middlewares/login-required';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
 import { Revision } from '~/server/models/revision';
-import ShareLink from '~/server/models/share-link';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import { exportService } from '~/server/service/export';
 import { exportService } from '~/server/service/export';
@@ -53,10 +51,12 @@ import loggerFactory from '~/utils/logger';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
 import { createPageHandlersFactory } from './create-page';
+import { getPageByShareLinkHandlerFactory } from './get-page-by-share-link';
 import { getPageInfoHandlerFactory } from './get-page-info';
 import { getPageInfoHandlerFactory } from './get-page-info';
 import { getPagePathsWithDescendantCountFactory } from './get-page-paths-with-descendant-count';
 import { getPagePathsWithDescendantCountFactory } from './get-page-paths-with-descendant-count';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
 import { publishPageHandlersFactory } from './publish-page';
+import { respondWithSinglePage } from './respond-with-single-page';
 import { syncLatestRevisionBodyToYjsDraftHandlerFactory } from './sync-latest-revision-body-to-yjs-draft';
 import { syncLatestRevisionBodyToYjsDraftHandlerFactory } from './sync-latest-revision-body-to-yjs-draft';
 import { unpublishPageHandlersFactory } from './unpublish-page';
 import { unpublishPageHandlersFactory } from './unpublish-page';
 import { updatePageHandlersFactory } from './update-page';
 import { updatePageHandlersFactory } from './update-page';
@@ -89,9 +89,6 @@ const router = express.Router();
 module.exports = (crowi: Crowi) => {
 module.exports = (crowi: Crowi) => {
   const loginRequired = loginRequiredFactory(crowi, true);
   const loginRequired = loginRequiredFactory(crowi, true);
   const loginRequiredStrictly = loginRequiredFactory(crowi);
   const loginRequiredStrictly = loginRequiredFactory(crowi);
-  const certifySharedPage = require('../../../middlewares/certify-shared-page')(
-    crowi,
-  );
   const addActivity = generateAddActivityMiddleware();
   const addActivity = generateAddActivityMiddleware();
 
 
   const globalNotificationService = crowi.globalNotificationService;
   const globalNotificationService = crowi.globalNotificationService;
@@ -105,7 +102,6 @@ module.exports = (crowi: Crowi) => {
       query('pageId').isMongoId().optional().isString(),
       query('pageId').isMongoId().optional().isString(),
       query('path').optional().isString(),
       query('path').optional().isString(),
       query('findAll').optional().isBoolean(),
       query('findAll').optional().isBoolean(),
-      query('shareLinkId').optional().isMongoId(),
       query('includeEmpty').optional().isBoolean(),
       query('includeEmpty').optional().isBoolean(),
     ],
     ],
     likes: [body('pageId').isString(), body('bool').isBoolean()],
     likes: [body('pageId').isString(), body('bool').isBoolean()],
@@ -183,11 +179,6 @@ module.exports = (crowi: Crowi) => {
    *            description: Specific revision ID to retrieve
    *            description: Specific revision ID to retrieve
    *            schema:
    *            schema:
    *              $ref: '#/components/schemas/ObjectId'
    *              $ref: '#/components/schemas/ObjectId'
-   *          - name: shareLinkId
-   *            in: query
-   *            description: Share link ID for shared page access
-   *            schema:
-   *              $ref: '#/components/schemas/ObjectId'
    *          - name: includeEmpty
    *          - name: includeEmpty
    *            in: query
    *            in: query
    *            description: Include empty pages in results when using findAll
    *            description: Include empty pages in results when using findAll
@@ -204,110 +195,26 @@ module.exports = (crowi: Crowi) => {
   router.get(
   router.get(
     '/',
     '/',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    certifySharedPage,
     loginRequired,
     loginRequired,
     validator.getPage,
     validator.getPage,
     apiV3FormValidator,
     apiV3FormValidator,
     async (req, res) => {
     async (req, res) => {
-      const { user, isSharedPage } = req;
-      const { pageId, path, findAll, revisionId, shareLinkId, includeEmpty } =
-        req.query;
+      const { user } = req;
+      const { pageId, path, findAll, revisionId, includeEmpty } = req.query;
 
 
       const disableUserPages = crowi.configManager.getConfig(
       const disableUserPages = crowi.configManager.getConfig(
         'security:disableUserPages',
         'security:disableUserPages',
       );
       );
 
 
-      const respondWithSinglePage = async (
-        pageWithMeta:
-          | IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt>
-          | IDataWithMeta<null, IPageNotFoundInfo>,
-      ) => {
-        let { data: page } = pageWithMeta;
-        const { meta } = pageWithMeta;
-
-        if (isIPageNotFoundInfo(meta)) {
-          if (meta.isForbidden) {
-            return res.apiv3Err(
-              new ErrorV3(
-                'Page is forbidden',
-                'page-is-forbidden',
-                undefined,
-                meta,
-              ),
-              403,
-            );
-          }
-          return res.apiv3Err(
-            new ErrorV3('Page is not found', 'page-not-found', undefined, meta),
-            404,
-          );
-        }
-
-        if (disableUserPages && page != null) {
-          const isTargetUserPage =
-            isUserPage(page.path) || isUsersTopPage(page.path);
-
-          if (isTargetUserPage) {
-            return res.apiv3Err(
-              new ErrorV3('Page is forbidden', 'page-is-forbidden'),
-              403,
-            );
-          }
-        }
-
-        if (page != null) {
-          try {
-            page.initLatestRevisionField(revisionId);
-
-            // populate
-            page = await page.populateDataToShowRevision();
-          } catch (err) {
-            logger.error('populate-page-failed', err);
-            return res.apiv3Err(
-              new ErrorV3(
-                'Failed to populate page',
-                'populate-page-failed',
-                undefined,
-                { err, meta },
-              ),
-              500,
-            );
-          }
-        }
-
-        return res.apiv3({ page, pages: undefined, meta });
-      };
-
-      const isValid =
-        (shareLinkId != null && pageId != null && path == null) ||
-        (shareLinkId == null && (pageId != null || path != null));
+      const isValid = pageId != null || path != null;
       if (!isValid) {
       if (!isValid) {
         return res.apiv3Err(
         return res.apiv3Err(
-          new Error(
-            'Either parameter of (pageId or path) or (pageId and shareLinkId) is required.',
-          ),
+          new Error('Either pageId or path is required.'),
           400,
           400,
         );
         );
       }
       }
 
 
       try {
       try {
-        if (isSharedPage) {
-          const shareLink = await ShareLink.findOne({
-            _id: { $eq: shareLinkId },
-          });
-          if (shareLink == null) {
-            return res.apiv3Err('ShareLink is not found', 404);
-          }
-          return respondWithSinglePage(
-            await findPageAndMetaDataByViewer(pageService, pageGrantService, {
-              pageId: getIdStringForRef(shareLink.relatedPage),
-              path,
-              user,
-              isSharedPage: true,
-            }),
-          );
-        }
-
         if (findAll != null) {
         if (findAll != null) {
           const pages = await Page.findByPathAndViewer(
           const pages = await Page.findByPathAndViewer(
             path,
             path,
@@ -327,11 +234,13 @@ module.exports = (crowi: Crowi) => {
         }
         }
 
 
         return respondWithSinglePage(
         return respondWithSinglePage(
+          res,
           await findPageAndMetaDataByViewer(pageService, pageGrantService, {
           await findPageAndMetaDataByViewer(pageService, pageGrantService, {
             pageId,
             pageId,
             path,
             path,
             user,
             user,
           }),
           }),
+          { revisionId, disableUserPages },
         );
         );
       } catch (err) {
       } catch (err) {
         logger.error('get-page-failed', err);
         logger.error('get-page-failed', err);
@@ -587,6 +496,8 @@ module.exports = (crowi: Crowi) => {
     },
     },
   );
   );
 
 
+  router.get('/shared', getPageByShareLinkHandlerFactory(crowi));
+
   router.get('/info', getPageInfoHandlerFactory(crowi));
   router.get('/info', getPageInfoHandlerFactory(crowi));
 
 
   /**
   /**

+ 224 - 0
apps/app/src/server/routes/apiv3/page/respond-with-single-page.spec.ts

@@ -0,0 +1,224 @@
+import type {
+  IDataWithMeta,
+  IPageInfo,
+  IPageInfoExt,
+  IPageNotFoundInfo,
+} from '@growi/core';
+import type { HydratedDocument } from 'mongoose';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { PageDocument } from '~/server/models/page';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+// Mock logger to avoid path resolution issues in tests
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    debug: vi.fn(),
+    error: vi.fn(),
+    info: vi.fn(),
+    warn: vi.fn(),
+  }),
+}));
+
+import { respondWithSinglePage } from './respond-with-single-page';
+
+// ApiV3Response extends Express Response which requires 50+ properties (status, json, send, …).
+// Only apiv3/apiv3Err are exercised in these tests, so a full implementation is impractical.
+function createMockRes(): ApiV3Response {
+  return {
+    apiv3: vi.fn().mockReturnValue(undefined),
+    apiv3Err: vi.fn().mockReturnValue(undefined),
+  } as unknown as ApiV3Response;
+}
+
+// HydratedDocument<PageDocument> inherits Mongoose Document internals (save, $isNew, toObject, …).
+// Only path / initLatestRevisionField / populateDataToShowRevision are exercised here.
+function createMockPage(path = '/normal-page'): HydratedDocument<PageDocument> {
+  const page = {
+    path,
+    initLatestRevisionField: vi.fn(),
+    populateDataToShowRevision: vi.fn(),
+  };
+  page.populateDataToShowRevision.mockResolvedValue(page);
+  return page as unknown as HydratedDocument<PageDocument>;
+}
+
+function createPageInfo(overrides: Partial<IPageInfo> = {}): IPageInfo {
+  return {
+    isNotFound: false,
+    isV5Compatible: true,
+    isEmpty: false,
+    isMovable: true,
+    isDeletable: true,
+    isAbleToDeleteCompletely: true,
+    isRevertible: false,
+    bookmarkCount: 0,
+    ...overrides,
+  };
+}
+
+describe('respondWithSinglePage', () => {
+  let mockRes: ApiV3Response;
+  let mockPage: HydratedDocument<PageDocument>;
+
+  beforeEach(() => {
+    mockRes = createMockRes();
+    mockPage = createMockPage();
+  });
+
+  describe('success case', () => {
+    it('should return success response with page and meta when page exists', async () => {
+      // Arrange
+      const mockMeta = createPageInfo();
+      const pageWithMeta: IDataWithMeta<
+        HydratedDocument<PageDocument>,
+        IPageInfoExt
+      > = {
+        data: mockPage,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta);
+
+      // Assert
+      expect(mockRes.apiv3).toHaveBeenCalledWith(
+        expect.objectContaining({
+          page: mockPage,
+          pages: undefined,
+          meta: mockMeta,
+        }),
+      );
+      expect(mockPage.initLatestRevisionField).toHaveBeenCalledWith(undefined);
+      expect(mockPage.populateDataToShowRevision).toHaveBeenCalled();
+    });
+
+    it('should initialize revision field when revisionId is provided', async () => {
+      // Arrange
+      const revisionId = '507f1f77bcf86cd799439011';
+      const mockMeta = createPageInfo();
+      const pageWithMeta: IDataWithMeta<
+        HydratedDocument<PageDocument>,
+        IPageInfoExt
+      > = {
+        data: mockPage,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta, { revisionId });
+
+      // Assert
+      expect(mockPage.initLatestRevisionField).toHaveBeenCalledWith(revisionId);
+    });
+  });
+
+  describe('forbidden case', () => {
+    it('should return 403 when page meta has isForbidden=true', async () => {
+      // Arrange
+      const mockMeta: IPageNotFoundInfo = {
+        isNotFound: true,
+        isForbidden: true,
+      };
+      const pageWithMeta: IDataWithMeta<null, IPageNotFoundInfo> = {
+        data: null,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta);
+
+      // Assert
+      expect(mockRes.apiv3Err).toHaveBeenCalledWith(
+        expect.objectContaining({
+          message: 'Page is forbidden',
+          code: 'page-is-forbidden',
+        }),
+        403,
+      );
+      expect(mockRes.apiv3).not.toHaveBeenCalled();
+    });
+
+    it('should return 403 when disableUserPages=true and page is a user page', async () => {
+      // Arrange
+      const userPage = createMockPage('/user/john');
+      const mockMeta = createPageInfo();
+      const pageWithMeta: IDataWithMeta<
+        HydratedDocument<PageDocument>,
+        IPageInfoExt
+      > = {
+        data: userPage,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta, {
+        disableUserPages: true,
+      });
+
+      // Assert
+      expect(mockRes.apiv3Err).toHaveBeenCalledWith(
+        expect.objectContaining({
+          message: 'Page is forbidden',
+          code: 'page-is-forbidden',
+        }),
+        403,
+      );
+      expect(mockRes.apiv3).not.toHaveBeenCalled();
+    });
+
+    it('should return 403 when disableUserPages=true and page is a user top page', async () => {
+      // Arrange
+      const userTopPage = createMockPage('/user');
+      const mockMeta = createPageInfo();
+      const pageWithMeta: IDataWithMeta<
+        HydratedDocument<PageDocument>,
+        IPageInfoExt
+      > = {
+        data: userTopPage,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta, {
+        disableUserPages: true,
+      });
+
+      // Assert
+      expect(mockRes.apiv3Err).toHaveBeenCalledWith(
+        expect.objectContaining({
+          message: 'Page is forbidden',
+          code: 'page-is-forbidden',
+        }),
+        403,
+      );
+    });
+  });
+
+  describe('not-found case', () => {
+    it('should return 404 when page meta has isForbidden=false (not-found only)', async () => {
+      // Arrange
+      const mockMeta: IPageNotFoundInfo = {
+        isNotFound: true,
+        isForbidden: false,
+      };
+      const pageWithMeta: IDataWithMeta<null, IPageNotFoundInfo> = {
+        data: null,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta);
+
+      // Assert
+      expect(mockRes.apiv3Err).toHaveBeenCalledWith(
+        expect.objectContaining({
+          message: 'Page is not found',
+          code: 'page-not-found',
+        }),
+        404,
+      );
+    });
+  });
+});

+ 97 - 0
apps/app/src/server/routes/apiv3/page/respond-with-single-page.ts

@@ -0,0 +1,97 @@
+import type {
+  IDataWithMeta,
+  IPageInfoExt,
+  IPageNotFoundInfo,
+} from '@growi/core';
+import { isIPageNotFoundInfo } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import {
+  isUserPage,
+  isUsersTopPage,
+} from '@growi/core/dist/utils/page-path-utils';
+import type { HydratedDocument } from 'mongoose';
+
+import type { PageDocument } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory(
+  'growi:routes:apiv3:page:respond-with-single-page',
+);
+
+export interface RespondWithSinglePageOptions {
+  revisionId?: string;
+  disableUserPages?: boolean;
+}
+
+/**
+ * Generate and send a single page response via Express.
+ *
+ * Handles success (200), not found (404), forbidden (403), and error (500) responses.
+ * Optionally initializes revision field and checks disableUserPages setting.
+ *
+ * @param res - Express response object
+ * @param pageWithMeta - Page data with metadata (success or not-found states)
+ * @param options - Optional revisionId and disableUserPages settings
+ */
+export async function respondWithSinglePage(
+  res: ApiV3Response,
+  pageWithMeta:
+    | IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt>
+    | IDataWithMeta<null, IPageNotFoundInfo>,
+  options: RespondWithSinglePageOptions = {},
+): Promise<void> {
+  const { revisionId, disableUserPages = false } = options;
+  let { data: page } = pageWithMeta;
+  const { meta } = pageWithMeta;
+
+  // Handle not found or forbidden cases
+  if (isIPageNotFoundInfo(meta)) {
+    if (meta.isForbidden) {
+      return res.apiv3Err(
+        new ErrorV3('Page is forbidden', 'page-is-forbidden', undefined, meta),
+        403,
+      );
+    }
+    return res.apiv3Err(
+      new ErrorV3('Page is not found', 'page-not-found', undefined, meta),
+      404,
+    );
+  }
+
+  // Check disableUserPages setting
+  if (disableUserPages && page != null) {
+    const isTargetUserPage = isUserPage(page.path) || isUsersTopPage(page.path);
+
+    if (isTargetUserPage) {
+      return res.apiv3Err(
+        new ErrorV3('Page is forbidden', 'page-is-forbidden'),
+        403,
+      );
+    }
+  }
+
+  // Populate page data with revision information
+  if (page != null) {
+    try {
+      page.initLatestRevisionField(revisionId);
+
+      // populate
+      page = await page.populateDataToShowRevision();
+    } catch (err) {
+      logger.error('populate-page-failed', err);
+      return res.apiv3Err(
+        new ErrorV3(
+          'Failed to populate page',
+          'populate-page-failed',
+          undefined,
+          { err, meta },
+        ),
+        500,
+      );
+    }
+  }
+
+  return res.apiv3({ page, pages: undefined, meta });
+}

+ 5 - 5
apps/app/src/states/page/use-fetch-current-page.spec.tsx

@@ -1072,10 +1072,10 @@ describe('useFetchCurrentPage - Integration Test', () => {
       });
       });
     });
     });
 
 
-    // Assert: API should be called with pageId (not path=/share/...) and shareLinkId
+    // Assert: API should be called with /page/shared endpoint, pageId (not path=/share/...) and shareLinkId
     await waitFor(() => {
     await waitFor(() => {
       expect(mockedApiv3Get).toHaveBeenCalledWith(
       expect(mockedApiv3Get).toHaveBeenCalledWith(
-        '/page',
+        '/page/shared',
         expect.objectContaining({
         expect.objectContaining({
           pageId,
           pageId,
           shareLinkId,
           shareLinkId,
@@ -1083,7 +1083,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       );
       );
       // path should NOT be sent
       // path should NOT be sent
       expect(mockedApiv3Get).toHaveBeenCalledWith(
       expect(mockedApiv3Get).toHaveBeenCalledWith(
-        '/page',
+        '/page/shared',
         expect.not.objectContaining({ path: expect.anything() }),
         expect.not.objectContaining({ path: expect.anything() }),
       );
       );
       expect(store.get(currentPageEntityIdAtom)).toBe(pageId);
       expect(store.get(currentPageEntityIdAtom)).toBe(pageId);
@@ -1109,10 +1109,10 @@ describe('useFetchCurrentPage - Integration Test', () => {
       await result.current.fetchCurrentPage({ path: '/some/path' });
       await result.current.fetchCurrentPage({ path: '/some/path' });
     });
     });
 
 
-    // Assert: Falls through to path-based logic since currentPageId is null
+    // Assert: Falls through to path-based logic since currentPageId is null, but still uses /page/shared
     await waitFor(() => {
     await waitFor(() => {
       expect(mockedApiv3Get).toHaveBeenCalledWith(
       expect(mockedApiv3Get).toHaveBeenCalledWith(
-        '/page',
+        '/page/shared',
         expect.objectContaining({
         expect.objectContaining({
           path: '/some/path',
           path: '/some/path',
           shareLinkId,
           shareLinkId,

+ 5 - 2
apps/app/src/states/page/use-fetch-current-page.ts

@@ -147,7 +147,7 @@ const buildApiParams = ({
   if (fetchPageArgs?.pageId != null) {
   if (fetchPageArgs?.pageId != null) {
     params.pageId = fetchPageArgs.pageId;
     params.pageId = fetchPageArgs.pageId;
   }
   }
-  // priority B: currentPageId for share link (required by certifySharedPage middleware)
+  // priority B: currentPageId for share link (required by /page/shared endpoint)
   else if (shareLinkId != null && currentPageId != null) {
   else if (shareLinkId != null && currentPageId != null) {
     params.pageId = currentPageId;
     params.pageId = currentPageId;
   } else if (decodedPathname != null) {
   } else if (decodedPathname != null) {
@@ -243,7 +243,10 @@ export const useFetchCurrentPage = (): {
         }
         }
 
 
         try {
         try {
-          const { data } = await apiv3Get<FetchedPageResult>('/page', params);
+          // Use dedicated /page/shared endpoint for share link access
+          const endpoint =
+            params.shareLinkId != null ? '/page/shared' : '/page';
+          const { data } = await apiv3Get<FetchedPageResult>(endpoint, params);
           const { page: newData, meta } = data;
           const { page: newData, meta } = data;
 
 
           set(currentPageDataAtom, newData ?? undefined);
           set(currentPageDataAtom, newData ?? undefined);