diff --git a/apps/app/public/static/locales/en_US/admin.json b/apps/app/public/static/locales/en_US/admin.json index d0397d7c8d1..f0726d40f9e 100644 --- a/apps/app/public/static/locales/en_US/admin.json +++ b/apps/app/public/static/locales/en_US/admin.json @@ -56,6 +56,11 @@ "enable_force_delete_user_homepage_on_user_deletion": "When you delete a user, the user's homepage and all its sub pages will be completely deleted", "desc": "You will be able to delete a deleted user's homepage." }, + "user_page_visibility": { + "user_page_visibility": "User page visibility", + "hide_user_pages": "Hide user pages", + "desc": "Hides all user related pages for general users" + }, "session": "Session", "max_age": "Max age (msec)", "max_age_desc": "Specifies the number (in milliseconds) to expire users session.
Default: 2592000000 (30days)", diff --git a/apps/app/public/static/locales/fr_FR/admin.json b/apps/app/public/static/locales/fr_FR/admin.json index e41321639e4..899fddc464b 100644 --- a/apps/app/public/static/locales/fr_FR/admin.json +++ b/apps/app/public/static/locales/fr_FR/admin.json @@ -56,6 +56,11 @@ "enable_force_delete_user_homepage_on_user_deletion": "Supprimer la page d'accueil et ses pages enfants", "desc": "Les pages d'accueil utilisateurs pourront être supprimées." }, + "user_page_visibility": { + "user_page_visibility": "Visibilité de la page utilisateur", + "hide_user_pages": "Masquer les pages utilisateur", + "desc": "Masque toutes les pages liées aux utilisateurs pour les utilisateurs généraux" + }, "session": "Session", "max_age": "Âge maximal (ms)", "max_age_desc": "Spécifie (en milliseconde) l'âge maximal d'une session
Par défaut: 2592000000 (30 jours)", diff --git a/apps/app/public/static/locales/ja_JP/admin.json b/apps/app/public/static/locales/ja_JP/admin.json index d0a4f72c5b1..f6e36aab886 100644 --- a/apps/app/public/static/locales/ja_JP/admin.json +++ b/apps/app/public/static/locales/ja_JP/admin.json @@ -65,6 +65,11 @@ "enable_force_delete_user_homepage_on_user_deletion": "ユーザーを削除したとき、ユーザーホームページとその配下のページを完全削除する", "desc": "削除済みユーザーのユーザーホームページを削除できるようになります。" }, + "user_page_visibility": { + "user_page_visibility": "ユーザーページの表示/非表示", + "hide_user_pages": "ユーザーページを非表示にする", + "desc": "一般ユーザーに対して、すべてのユーザー関連ページを非表示にする" + }, "session": "セッション", "max_age": "有効期間 (ミリ秒)", "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。
デフォルト値: 2592000000 (30日間)", diff --git a/apps/app/public/static/locales/ko_KR/admin.json b/apps/app/public/static/locales/ko_KR/admin.json index de4759bfd79..900a00f17f8 100644 --- a/apps/app/public/static/locales/ko_KR/admin.json +++ b/apps/app/public/static/locales/ko_KR/admin.json @@ -56,6 +56,11 @@ "enable_force_delete_user_homepage_on_user_deletion": "사용자를 삭제할 때, 사용자의 홈페이지와 모든 하위 페이지가 완전히 삭제됩니다.", "desc": "삭제된 사용자의 홈페이지를 삭제할 수 있습니다." }, + "user_page_visibility": { + "user_page_visibility": "사용자 페이지 가시성", + "hide_user_pages": "사용자 페이지 숨기기", + "desc": "일반 사용자를 위해 모든 사용자 관련 페이지를 숨깁니다" + }, "session": "세션", "max_age": "최대 수명 (밀리초)", "max_age_desc": "사용자 세션이 만료되는 시간(밀리초)을 지정합니다.
기본값: 2592000000 (30일)", diff --git a/apps/app/public/static/locales/zh_CN/admin.json b/apps/app/public/static/locales/zh_CN/admin.json index e6f11b9d6b2..7bd0d2d3208 100644 --- a/apps/app/public/static/locales/zh_CN/admin.json +++ b/apps/app/public/static/locales/zh_CN/admin.json @@ -65,6 +65,11 @@ "enable_force_delete_user_homepage_on_user_deletion": "删除用户时,该用户的主页及其所有子页面将被完全删除", "desc": "您可以删除已删除用户的主页。" }, + "user_page_visibility": { + "user_page_visibility": "用户页面可见性", + "hide_user_pages": "隐藏用户页面", + "desc": "对一般用户隐藏所有用户相关页面" + }, "session": "会议", "max_age": "有效期间 (msec)", "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。
默认值: 2592000000 (30天)", diff --git a/apps/app/src/client/components/Admin/Security/SecuritySetting/UserPageVisibilitySettings.tsx b/apps/app/src/client/components/Admin/Security/SecuritySetting/UserPageVisibilitySettings.tsx new file mode 100644 index 00000000000..f8bed1cc84c --- /dev/null +++ b/apps/app/src/client/components/Admin/Security/SecuritySetting/UserPageVisibilitySettings.tsx @@ -0,0 +1,46 @@ +/* eslint-disable react/no-danger */ +import type React from 'react'; + +import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer'; + +type Props = { + adminGeneralSecurityContainer: AdminGeneralSecurityContainer; + t: (key: string) => string; +}; + +export const UserPageVisibilitySettings: React.FC = ({ + adminGeneralSecurityContainer, + t, +}) => { + return ( + <> +

+ {t('security_settings.user_page_visibility.user_page_visibility')} +

+
+
+
+ { + adminGeneralSecurityContainer.changeUserPageVisibility(); + }} + /> + +
+

+ {t('security_settings.user_page_visibility.desc')} +

+
+
+ + ); +}; diff --git a/apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx b/apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx index fe6f911ec16..854d2757388 100644 --- a/apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx +++ b/apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx @@ -13,6 +13,7 @@ import { PageDeleteRightsSettings } from './PageDeleteRightsSettings'; import { PageListDisplaySettings } from './PageListDisplaySettings'; import { SessionMaxAgeSettings } from './SessionMaxAgeSettings'; import { UserHomepageDeletionSettings } from './UserHomepageDeletionSettings'; +import { UserPageVisibilitySettings } from './UserPageVisibilitySettings'; type FormData = { sessionMaxAge: string; @@ -63,6 +64,8 @@ const SecuritySettingComponent: React.FC = ({ hideRestrictedByOwner: adminGeneralSecurityContainer.state .currentOwnerRestrictionDisplayMode === 'Hidden', + isHidingUserPages: + adminGeneralSecurityContainer.state.isHidingUserPages, isUsersHomepageDeletionEnabled: adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled, isForceDeleteUserHomepageOnUserDeletion: @@ -114,6 +117,10 @@ const SecuritySettingComponent: React.FC = ({ adminGeneralSecurityContainer={adminGeneralSecurityContainer} t={t} /> + { const hideRestrictedByGroup = await configManager.getConfig( 'security:list-policy:hideRestrictedByGroup', ); + const hideUserPages = await configManager.getConfig( + 'security:isHidingUserPages', + ); try { - const pages = + let pages = await pageListingService.findChildrenByParentPathOrIdAndViewer( (id || path) as string, req.user, !hideRestrictedByOwner, !hideRestrictedByGroup, ); + + if (hideUserPages === true) { + const isUserPagePath = /^\/user(\/|$)/; + pages = pages.filter((page) => !isUserPagePath.test(page.path)); + } + return res.apiv3({ children: pages }); } catch (err) { logger.error('Error occurred while finding children.', err); diff --git a/apps/app/src/server/routes/apiv3/pages/index.js b/apps/app/src/server/routes/apiv3/pages/index.js index 1e77e2b79dd..dddc121abbe 100644 --- a/apps/app/src/server/routes/apiv3/pages/index.js +++ b/apps/app/src/server/routes/apiv3/pages/index.js @@ -193,6 +193,9 @@ module.exports = (crowi) => { const hideRestrictedByGroup = configManager.getConfig( 'security:list-policy:hideRestrictedByGroup', ); + const hideUserPages = configManager.getConfig( + 'security:isHidingUserPages', + ); /** * @type {import('~/server/models/page').FindRecentUpdatedPagesOption} @@ -207,6 +210,7 @@ module.exports = (crowi) => { desc: -1, hideRestrictedByOwner, hideRestrictedByGroup, + hideUserPages, }; try { diff --git a/apps/app/src/server/routes/apiv3/security-settings/index.js b/apps/app/src/server/routes/apiv3/security-settings/index.js index 09dfb5a9d12..6a1f9dfb76d 100644 --- a/apps/app/src/server/routes/apiv3/security-settings/index.js +++ b/apps/app/src/server/routes/apiv3/security-settings/index.js @@ -47,6 +47,9 @@ const validator = { body('hideRestrictedByGroup') .if((value) => value != null) .isBoolean(), + body('isHidingUserPages') + .if((value) => value != null) + .isBoolean(), body('isUsersHomepageDeletionEnabled') .if((value) => value != null) .isBoolean(), @@ -217,6 +220,9 @@ const validator = { * pageCompleteDeletionAuthority: * type: string * description: type of pageDeletionAuthority + * isHidingUserPages: + * type: boolean + * description: hide all user pages from general users * hideRestrictedByOwner: * type: boolean * description: enable hide by owner @@ -505,6 +511,9 @@ module.exports = (crowi) => { hideRestrictedByGroup: await configManager.getConfig( 'security:list-policy:hideRestrictedByGroup', ), + isHidingUserPages: await configManager.getConfig( + 'security:isHidingUserPages', + ), isUsersHomepageDeletionEnabled: await configManager.getConfig( 'security:user-homepage-deletion:isEnabled', ), @@ -995,6 +1004,7 @@ module.exports = (crowi) => { req.body.hideRestrictedByOwner, 'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup, + 'security:isHidingUserPages': req.body.isHidingUserPages, 'security:user-homepage-deletion:isEnabled': req.body.isUsersHomepageDeletionEnabled, // Validate user-homepage-deletion config @@ -1067,6 +1077,9 @@ module.exports = (crowi) => { hideRestrictedByGroup: await configManager.getConfig( 'security:list-policy:hideRestrictedByGroup', ), + isHidingUserPages: await configManager.getConfig( + 'security:isHidingUserPages', + ), isUsersHomepageDeletionEnabled: await configManager.getConfig( 'security:user-homepage-deletion:isEnabled', ), diff --git a/apps/app/src/server/service/config-manager/config-definition.ts b/apps/app/src/server/service/config-manager/config-definition.ts index d7e6d119559..e0f25b01cdc 100644 --- a/apps/app/src/server/service/config-manager/config-definition.ts +++ b/apps/app/src/server/service/config-manager/config-definition.ts @@ -115,6 +115,7 @@ export const CONFIG_KEYS = [ 'security:pageRecursiveDeletionAuthority', 'security:pageRecursiveCompleteDeletionAuthority', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion', + 'security:isHidingUserPages', 'security:user-homepage-deletion:isEnabled', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion', 'security:isRomUserAllowedToComment', @@ -678,6 +679,9 @@ export const CONFIG_DEFINITIONS = { defineConfig({ defaultValue: true, }), + 'security:isHidingUserPages': defineConfig({ + defaultValue: false, + }), 'security:user-homepage-deletion:isEnabled': defineConfig({ defaultValue: false, }), diff --git a/apps/app/src/server/service/page-listing/page-listing.ts b/apps/app/src/server/service/page-listing/page-listing.ts index bc64eea36d4..63cb3dbe85d 100644 --- a/apps/app/src/server/service/page-listing/page-listing.ts +++ b/apps/app/src/server/service/page-listing/page-listing.ts @@ -60,6 +60,7 @@ class PageListingService implements IPageListingService { user?: IUser, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false, + hideUserPages = false, ): Promise { const Page = mongoose.model, PageModel>( 'Page', diff --git a/apps/app/src/server/service/page/index.ts b/apps/app/src/server/service/page/index.ts index 0d6dd303e2c..b05531ce4ab 100644 --- a/apps/app/src/server/service/page/index.ts +++ b/apps/app/src/server/service/page/index.ts @@ -796,6 +796,16 @@ class PageService implements IPageService { return renamedPage; } + getExcludedPathsBySystem(): string[] { + const excludedPaths: string[] = []; + + if (configManager.getConfig('security:isHidingUserPages')) { + excludedPaths.push('/user'); + } + + return excludedPaths; + } + async renameSubOperation( page, newPagePathSanitized: string, diff --git a/packages/remark-lsx/src/client/stores/lsx/lsx.spec.ts b/packages/remark-lsx/src/client/stores/lsx/lsx.spec.ts index c25bff4b8eb..0132774d4e0 100644 --- a/packages/remark-lsx/src/client/stores/lsx/lsx.spec.ts +++ b/packages/remark-lsx/src/client/stores/lsx/lsx.spec.ts @@ -84,6 +84,12 @@ describe('useSWRxLsx integration tests', () => { const mockCrowi = { require: () => () => (req: any, res: any, next: any) => next(), accessTokenParser: () => (req: any, res: any, next: any) => next(), + pageService: { + getExcludedPathsBySystem: vi.fn().mockReturnValue(['/user']), + }, + configManager: { + getConfig: vi.fn().mockReturnValue(false), + }, }; // Import and setup the LSX middleware diff --git a/packages/remark-lsx/src/server/index.ts b/packages/remark-lsx/src/server/index.ts index 94388a8eb6c..6eb7625d71b 100644 --- a/packages/remark-lsx/src/server/index.ts +++ b/packages/remark-lsx/src/server/index.ts @@ -57,6 +57,7 @@ const middleware = (crowi: any, app: any): void => { loginRequiredFallback, ); const accessTokenParser = crowi.accessTokenParser; + const excludedPaths = crowi.pageService.getExcludedPathsBySystem(); app.get( '/_api/lsx', @@ -64,7 +65,7 @@ const middleware = (crowi: any, app: any): void => { loginRequired, lsxValidator, paramValidator, - listPages, + listPages({ excludedPaths }), ); }; diff --git a/packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts b/packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts index c3d7c39c58f..326874d84c3 100644 --- a/packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts +++ b/packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts @@ -1,4 +1,5 @@ import type { IPageHasId, IUser } from '@growi/core'; +import createError from 'http-errors'; import type { Document, Query } from 'mongoose'; import { model } from 'mongoose'; @@ -17,6 +18,10 @@ export const generateBaseQuery = async ( pagePath: string, user: IUser, ): Promise => { + if (pagePath === '') { + throw createError(400, 'pagePath must not be empty'); + } + const Page = model('Page'); // biome-ignore lint/suspicious/noExplicitAny: ignore const PageAny = Page as any; diff --git a/packages/remark-lsx/src/server/routes/list-pages/index.spec.ts b/packages/remark-lsx/src/server/routes/list-pages/index.spec.ts index 66833a95bb6..21d222535df 100644 --- a/packages/remark-lsx/src/server/routes/list-pages/index.spec.ts +++ b/packages/remark-lsx/src/server/routes/list-pages/index.spec.ts @@ -36,20 +36,27 @@ vi.mock('./get-toppage-viewers-count', () => ({ })); describe('listPages', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("returns 400 HTTP response when the query 'pagePath' is undefined", async () => { // setup const reqMock = mock(); + reqMock.query = { pagePath: '' }; + const resMock = mock(); const resStatusMock = mock(); - resMock.status.calledWith(400).mockReturnValue(resStatusMock); + resMock.status.mockReturnValue(resStatusMock); + + mocks.generateBaseQueryMock.mockRejectedValue( + createError(400, 'pagePath is required'), + ); - // when - await listPages(reqMock, resMock); + const handler = listPages({ excludedPaths: [] }); + await handler(reqMock, resMock); - // then - expect(resMock.status).toHaveBeenCalledOnce(); - expect(resStatusMock.send).toHaveBeenCalledOnce(); - expect(mocks.generateBaseQueryMock).not.toHaveBeenCalled(); + expect(resMock.status).toHaveBeenCalledWith(400); }); describe('with num option', () => { @@ -58,12 +65,16 @@ describe('listPages', () => { const builderMock = mock(); - mocks.generateBaseQueryMock.mockResolvedValue(builderMock); - mocks.getToppageViewersCountMock.mockImplementation(() => 99); - const queryMock = mock(); builderMock.query = queryMock; + beforeEach(() => { + mocks.generateBaseQueryMock.mockResolvedValue(builderMock); + mocks.getToppageViewersCountMock.mockImplementation(() => 99); + + queryMock.and.mockReturnValue(queryMock); + }); + it('returns 200 HTTP response', async () => { // setup query.clone().count() const queryClonedMock = mock(); @@ -85,7 +96,8 @@ describe('listPages', () => { resMock.status.calledWith(200).mockReturnValue(resStatusMock); // when - await listPages(reqMock, resMock); + const handler = listPages({ excludedPaths: [] }); + await handler(reqMock, resMock); // then expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce(); @@ -118,7 +130,8 @@ describe('listPages', () => { resMock.status.calledWith(500).mockReturnValue(resStatusMock); // when - await listPages(reqMock, resMock); + const handler = listPages({ excludedPaths: [] }); + await handler(reqMock, resMock); // then expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce(); @@ -147,7 +160,8 @@ describe('listPages', () => { resMock.status.calledWith(400).mockReturnValue(resStatusMock); // when - await listPages(reqMock, resMock); + const handler = listPages({ excludedPaths: [] }); + await handler(reqMock, resMock); // then expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce(); @@ -243,3 +257,67 @@ describe('listPages', () => { }); }); }); + +describe('when excludedPaths is handled', () => { + const pagePath = '/Sandbox'; + const builderMock = mock(); + const queryMock = mock(); + builderMock.query = queryMock; + + beforeEach(() => { + mocks.generateBaseQueryMock.mockResolvedValue(builderMock); + queryMock.and.mockReturnValue(queryMock); + + // Setup successful flow for count and exec + const queryClonedMock = mock(); + queryMock.clone.mockReturnValue(queryClonedMock); + queryClonedMock.count.mockResolvedValue(0); + queryMock.exec.mockResolvedValue([]); + + mocks.addNumConditionMock.mockReturnValue(queryMock); + mocks.addSortConditionMock.mockReturnValue(queryMock); + mocks.getToppageViewersCountMock.mockResolvedValue(0); + }); + + it('does not add path exclusion conditions when excludedPaths is empty', async () => { + // setup + const reqMock = mock(); + reqMock.query = { pagePath }; + const resMock = mock(); + resMock.status.mockReturnValue(mock()); + + // excludedPaths is empty + const handler = listPages({ excludedPaths: [] }); + await handler(reqMock, resMock); + + // query.and should NOT be called with a $not regex for paths + expect(queryMock.and).not.toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + path: expect.objectContaining({ $not: expect.any(RegExp) }), + }), + ]), + ); + }); + + it('adds a regex exclusion condition when excludedPaths is specified', async () => { + // setup + const reqMock = mock(); + reqMock.query = { pagePath }; + const resMock = mock(); + resMock.status.mockReturnValue(mock()); + + // excludedPaths provided + const excludedPaths = ['/user', '/tmp']; + const handler = listPages({ excludedPaths }); + await handler(reqMock, resMock); + + // check if the logic generates the correct regex: ^\/(user|tmp)(\/|$) + const expectedRegex = /^\/(user|tmp)(\/|$)/; + expect(queryMock.and).toHaveBeenCalledWith([ + { + path: { $not: expectedRegex }, + }, + ]); + }); +}); diff --git a/packages/remark-lsx/src/server/routes/list-pages/index.ts b/packages/remark-lsx/src/server/routes/list-pages/index.ts index f86ca5bd4ba..1707dfe9923 100644 --- a/packages/remark-lsx/src/server/routes/list-pages/index.ts +++ b/packages/remark-lsx/src/server/routes/list-pages/index.ts @@ -66,78 +66,86 @@ interface IListPagesRequest user: IUser; } -export const listPages = async ( - req: IListPagesRequest, - res: Response, -): Promise => { - const user = req.user; - - if (req.query.pagePath == null) { - return res.status(400).send("the 'pagepath' query must not be null."); - } - - const params: LsxApiParams = { - pagePath: removeTrailingSlash(req.query.pagePath), - offset: req.query?.offset, - limit: req.query?.limit, - options: req.query?.options ?? {}, - }; +export const listPages = ({ excludedPaths }: { excludedPaths: string[] }) => { + return async (req: IListPagesRequest, res: Response): Promise => { + const params: LsxApiParams = { + pagePath: removeTrailingSlash(req.query.pagePath), + offset: req.query?.offset, + limit: req.query?.limit, + options: req.query?.options ?? {}, + }; - const { pagePath, offset, limit, options } = params; - const builder = await generateBaseQuery(params.pagePath, user); + const { pagePath, offset, limit, options } = params; - // count viewers of `/` - let toppageViewersCount: number; - try { - toppageViewersCount = await getToppageViewersCount(); - } catch (error) { - // biome-ignore lint/suspicious/noConsole: Allow to use console.error here - console.error('Error occurred in getToppageViewersCount:', error); - return res.status(500).send('An internal server error occurred.'); - } - - let query = builder.query; - try { - // depth - if (options?.depth != null) { - query = addDepthCondition( - query, - params.pagePath, - OptionParser.parseRange(options.depth), - ); + // count viewers of `/` + let toppageViewersCount: number; + try { + toppageViewersCount = await getToppageViewersCount(); + } catch (error) { + // biome-ignore lint/suspicious/noConsole: Allow to use console.error here + console.error('Error occurred in getToppageViewersCount:', error); + return res.status(500).send('An internal server error occurred.'); } - // filter - if (options?.filter != null) { - query = addFilterCondition(query, pagePath, options.filter); - } - if (options?.except != null) { - query = addExceptCondition(query, pagePath, options.except); - } - - // get total num before adding num/sort conditions - const total = await query.clone().count(); - - // num - query = addNumCondition(query, offset, limit); - // sort - query = addSortCondition(query, options?.sort, options?.reverse); - - const pages = await query.exec(); - const cursor = (offset ?? 0) + pages.length; - const responseData: LsxApiResponseData = { - pages, - cursor, - total, - toppageViewersCount, - }; - return res.status(200).send(responseData); - } catch (error) { - // biome-ignore lint/suspicious/noConsole: Allow to use console.error here - console.error('Error occurred while processing listPages request:', error); - if (isHttpError(error)) { - return res.status(error.status).send(error.message); + try { + const user = req.user; + const builder = await generateBaseQuery(params.pagePath, user); + let query = builder.query; + + if (excludedPaths.length > 0) { + const escapedPaths = excludedPaths.map((p) => { + const cleanPath = p.startsWith('/') ? p.substring(1) : p; + return cleanPath.replace(/\//g, '\\/'); + }); + + const regex = new RegExp(`^\\/(${escapedPaths.join('|')})(\\/|$)`); + query = query.and([{ path: { $not: regex } }]); + } + + // depth + if (options?.depth != null) { + query = addDepthCondition( + query, + params.pagePath, + OptionParser.parseRange(options.depth), + ); + } + // filter + if (options?.filter != null) { + query = addFilterCondition(query, pagePath, options.filter); + } + if (options?.except != null) { + query = addExceptCondition(query, pagePath, options.except); + } + + // get total num before adding num/sort conditions + const total = await query.clone().count(); + + // num + query = addNumCondition(query, offset, limit); + // sort + query = addSortCondition(query, options?.sort, options?.reverse); + + const pages = await query.exec(); + const cursor = (offset ?? 0) + pages.length; + + const responseData: LsxApiResponseData = { + pages, + cursor, + total, + toppageViewersCount, + }; + return res.status(200).send(responseData); + } catch (error) { + // biome-ignore lint/suspicious/noConsole: Allow to use console.error here + console.error( + 'Error occurred while processing listPages request:', + error, + ); + if (isHttpError(error)) { + return res.status(error.status).send(error.message); + } + return res.status(500).send('An internal server error occurred.'); } - return res.status(500).send('An internal server error occurred.'); - } + }; };