diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts index cce95921..357bb122 100644 --- a/apps/server/src/core/share/share.controller.ts +++ b/apps/server/src/core/share/share.controller.ts @@ -26,6 +26,7 @@ import { UpdateShareDto, } from './dto/share.dto'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { PageAccessService } from '../page-access/page-access.service'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { Public } from '../../common/decorators/public.decorator'; @@ -42,6 +43,7 @@ export class ShareController { private readonly spaceAbility: SpaceAbilityFactory, private readonly shareRepo: ShareRepo, private readonly pageRepo: PageRepo, + private readonly pagePermissionRepo: PagePermissionRepo, private readonly pageAccessService: PageAccessService, private readonly environmentService: EnvironmentService, ) {} @@ -128,6 +130,20 @@ export class ShareController { // User must be able to edit the page to create a share await this.pageAccessService.validateCanEdit(page, user); + // Block includeSubPages if user cannot access all descendants + if (createShareDto.includeSubPages) { + const hasInaccessible = + await this.pagePermissionRepo.hasInaccessibleDescendants( + page.id, + user.id, + ); + if (hasInaccessible) { + throw new BadRequestException( + 'Cannot share subpages: restricted pages found', + ); + } + } + return this.shareService.createShare({ page, authUserId: user.id, @@ -153,6 +169,20 @@ export class ShareController { // User must be able to edit the page to update its share await this.pageAccessService.validateCanEdit(page, user); + // Block includeSubPages if user cannot access all descendants + if (updateShareDto.includeSubPages) { + const hasInaccessible = + await this.pagePermissionRepo.hasInaccessibleDescendants( + page.id, + user.id, + ); + if (hasInaccessible) { + throw new BadRequestException( + 'Cannot share subpages: restricted pages found', + ); + } + } + return this.shareService.updateShare(share.id, updateShareDto); } diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index d065f860..ec676214 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -19,6 +19,7 @@ import { } from '../../common/helpers/prosemirror/utils'; import { Node } from '@tiptap/pm/model'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { updateAttachmentAttr } from './share.util'; import { Page } from '@docmost/db/types/entity.types'; import { validate as isValidUUID } from 'uuid'; @@ -31,6 +32,7 @@ export class ShareService { constructor( private readonly shareRepo: ShareRepo, private readonly pageRepo: PageRepo, + private readonly pagePermissionRepo: PagePermissionRepo, @InjectKysely() private readonly db: KyselyDB, private readonly tokenService: TokenService, ) {} @@ -42,16 +44,114 @@ export class ShareService { } if (share.includeSubPages) { - const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, { + const allPages = await this.pageRepo.getPageAndDescendants(share.pageId, { includeContent: false, }); - return { share, pageTree: pageList }; + // Filter out restricted pages and maintain tree integrity + const filteredPages = await this.filterPublicPages(allPages, share.pageId); + + return { share, pageTree: filteredPages }; } else { return { share, pageTree: [] }; } } + /** + * Filter pages for public share - exclude restricted pages. + * A page is included only if: + * 1. It has no page_access restriction AND + * 2. Its parent is also included (or it's the root) + */ + private async filterPublicPages< + T extends { id: string; parentPageId: string | null }, + >(pages: T[], rootPageId: string): Promise { + if (pages.length === 0) return []; + + // Get all restricted page IDs + const restrictedIds = + await this.pagePermissionRepo.getRestrictedDescendantIds(rootPageId); + const restrictedSet = new Set(restrictedIds); + + // Include pages that are NOT restricted and have valid parent chain + const includedIds = new Set(); + + let changed = true; + while (changed) { + changed = false; + for (const page of pages) { + if (includedIds.has(page.id)) continue; + if (restrictedSet.has(page.id)) continue; + + // Root page: include if not restricted + if (page.id === rootPageId) { + includedIds.add(page.id); + changed = true; + continue; + } + + // Non-root: include if parent is included + if (page.parentPageId && includedIds.has(page.parentPageId)) { + includedIds.add(page.id); + changed = true; + } + } + } + + return pages.filter((p) => includedIds.has(p.id)); + } + + /** + * Check if a specific page is accessible within a public share. + * A page is accessible if no page in its ancestor chain + * (from the page up to and including the share root) has a page_access restriction. + */ + private async isPagePubliclyAccessible( + pageId: string, + shareRootPageId: string, + ): Promise { + if (pageId === shareRootPageId) { + const hasRestriction = await this.db + .selectFrom('pageAccess') + .select('id') + .where('pageId', '=', pageId) + .executeTakeFirst(); + return !hasRestriction; + } + + // Get the depth from share root to the requested page + const shareToPage = await this.db + .selectFrom('pageHierarchy') + .select('depth') + .where('ancestorId', '=', shareRootPageId) + .where('descendantId', '=', pageId) + .executeTakeFirst(); + + if (!shareToPage) { + return false; + } + + // Get all ancestor IDs in the chain from pageId to shareRootPageId + const chainPageIds = await this.db + .selectFrom('pageHierarchy') + .select('ancestorId') + .where('descendantId', '=', pageId) + .where('depth', '<=', shareToPage.depth) + .where('depth', '>', 0) + .execute(); + + const idsToCheck = [pageId, ...chainPageIds.map((c) => c.ancestorId)]; + + // Check if any page in the chain has a restriction + const hasRestricted = await this.db + .selectFrom('pageAccess') + .select('pageId') + .where('pageId', 'in', idsToCheck) + .executeTakeFirst(); + + return !hasRestricted; + } + async createShare(opts: { authUserId: string; workspaceId: string; @@ -103,6 +203,17 @@ export class ShareService { throw new NotFoundException('Shared page not found'); } + // For descendant pages, verify the ancestor chain has no restrictions + if (share.level > 0) { + const isAccessible = await this.isPagePubliclyAccessible( + dto.pageId, + share.pageId, + ); + if (!isAccessible) { + throw new NotFoundException('Shared page not found'); + } + } + const page = await this.pageRepo.findById(dto.pageId, { includeContent: true, includeCreator: true, diff --git a/apps/server/src/database/repos/page/page-permission.repo.ts b/apps/server/src/database/repos/page/page-permission.repo.ts index 07c5c724..7ad08307 100644 --- a/apps/server/src/database/repos/page/page-permission.repo.ts +++ b/apps/server/src/database/repos/page/page-permission.repo.ts @@ -624,4 +624,69 @@ export class PagePermissionRepo { return results.map((r) => r.parentPageId); } + + /** + * Check if any descendant of a page has restrictions that the user cannot access. + * Used to determine if includeSubPages can be enabled for sharing. + */ + async hasInaccessibleDescendants( + pageId: string, + userId: string, + ): Promise { + // Get all descendant page IDs (excluding the root page itself) + const descendants = await this.db + .selectFrom('pageHierarchy') + .select('descendantId') + .where('ancestorId', '=', pageId) + .where('depth', '>', 0) + .execute(); + + if (descendants.length === 0) { + return false; + } + + const descendantIds = descendants.map((d) => d.descendantId); + + // Check if any descendant has a restriction the user cannot access + const inaccessible = await this.db + .selectFrom('pageAccess') + .leftJoin('pagePermissions', (join) => + join + .onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id') + .on((eb) => + eb.or([ + eb('pagePermissions.userId', '=', userId), + eb( + 'pagePermissions.groupId', + 'in', + eb + .selectFrom('groupUsers') + .select('groupUsers.groupId') + .where('groupUsers.userId', '=', userId), + ), + ]), + ), + ) + .select('pageAccess.pageId') + .where('pageAccess.pageId', 'in', descendantIds) + .where('pagePermissions.id', 'is', null) + .executeTakeFirst(); + + return !!inaccessible; + } + + /** + * Get all descendant page IDs that have restrictions (page_access entries). + * Used to filter restricted pages from public share trees. + */ + async getRestrictedDescendantIds(pageId: string): Promise { + const results = await this.db + .selectFrom('pageHierarchy') + .innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.descendantId') + .select('pageHierarchy.descendantId') + .where('pageHierarchy.ancestorId', '=', pageId) + .execute(); + + return results.map((r) => r.descendantId); + } }