diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 82ae401e5..92cce96ae 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -43,18 +43,20 @@ export class ShareService { throw new NotFoundException('Share not found'); } + const isRestricted = + await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId); + if (isRestricted) { + throw new NotFoundException('Share not found'); + } + if (share.includeSubPages) { - const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, { - includeContent: false, - }); + const pageTree = + await this.pageRepo.getPageAndDescendantsExcludingRestricted( + share.pageId, + { includeContent: false }, + ); - // Filter out restricted pages and their descendants - const restrictedIds = - await this.pagePermissionRepo.getRestrictedSubtreeIds(share.pageId); - const restrictedSet = new Set(restrictedIds); - const filteredPages = pageList.filter((page) => !restrictedSet.has(page.id)); - - return { share, pageTree: filteredPages }; + return { share, pageTree }; } else { return { share, pageTree: [] }; } diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 3b948a48a..82449e206 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -454,4 +454,73 @@ export class PageRepo { .selectAll() .execute(); } + + /** + * Get page and all descendants, excluding restricted pages and their subtrees. + * More efficient than getPageAndDescendants + filtering because: + * 1. Single DB query (no separate restricted IDs query) + * 2. Stops traversing at restricted pages (doesn't fetch data to discard) + * 3. No in-memory filtering needed + */ + async getPageAndDescendantsExcludingRestricted( + parentPageId: string, + opts: { includeContent: boolean }, + ) { + return this.db + .withRecursive('page_hierarchy', (db) => + db + .selectFrom('pages') + .leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id') + .select([ + 'pages.id', + 'pages.slugId', + 'pages.title', + 'pages.icon', + 'pages.position', + 'pages.parentPageId', + 'pages.spaceId', + 'pages.workspaceId', + sql`page_access.id IS NOT NULL`.as('isRestricted'), + ]) + .$if(opts?.includeContent, (qb) => qb.select('pages.content')) + .where('pages.id', '=', parentPageId) + .where('pages.deletedAt', 'is', null) + .unionAll((exp) => + exp + .selectFrom('pages as p') + .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') + .leftJoin('pageAccess', 'pageAccess.pageId', 'p.id') + .select([ + 'p.id', + 'p.slugId', + 'p.title', + 'p.icon', + 'p.position', + 'p.parentPageId', + 'p.spaceId', + 'p.workspaceId', + sql`page_access.id IS NOT NULL`.as('isRestricted'), + ]) + .$if(opts?.includeContent, (qb) => qb.select('p.content')) + .where('p.deletedAt', 'is', null) + // Only recurse into children of non-restricted pages + .where('ph.isRestricted', '=', false), + ), + ) + .selectFrom('page_hierarchy') + .select([ + 'id', + 'slugId', + 'title', + 'icon', + 'position', + 'parentPageId', + 'spaceId', + 'workspaceId', + ]) + .$if(opts?.includeContent, (qb) => qb.select('content')) + // Filter out restricted pages from the result + .where('isRestricted', '=', false) + .execute(); + } }