diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 370cb4db..af08e26e 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -286,6 +286,7 @@ export class PageService { allPages, rootPage.id, userId, + rootPage.spaceId, ); const accessibleIds = new Set(accessiblePages.map((p) => p.id)); @@ -389,6 +390,7 @@ export class PageService { allPages, rootPage.id, authUser.id, + rootPage.spaceId, ); const pageMap = new Map(); @@ -683,12 +685,13 @@ export class PageService { if (result.items.length > 0) { const pageIds = result.items.map((p) => p.id); - const accessiblePages = - await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId, - ); - const accessibleSet = new Set(accessiblePages.map((p) => p.id)); + spaceId, + }); + const accessibleSet = new Set(accessibleIds); result.items = result.items.filter((p) => accessibleSet.has(p.id)); } @@ -703,12 +706,12 @@ export class PageService { if (result.items.length > 0) { const pageIds = result.items.map((p) => p.id); - const accessiblePages = - await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId, - ); - const accessibleSet = new Set(accessiblePages.map((p) => p.id)); + }); + const accessibleSet = new Set(accessibleIds); result.items = result.items.filter((p) => accessibleSet.has(p.id)); } @@ -727,12 +730,13 @@ export class PageService { if (result.items.length > 0) { const pageIds = result.items.map((p) => p.id); - const accessiblePages = - await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId, - ); - const accessibleSet = new Set(accessiblePages.map((p) => p.id)); + spaceId, + }); + const accessibleSet = new Set(accessibleIds); result.items = result.items.filter((p) => accessibleSet.has(p.id)); } @@ -806,19 +810,18 @@ export class PageService { pages: T[], rootPageId: string, userId: string, + spaceId?: string, ): Promise { if (pages.length === 0) return []; const pageIds = pages.map((p) => p.id); - const accessiblePages = - await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId, - ); - const accessibleSet = new Set(accessiblePages.map((p) => p.id)); - - // Build a map for quick lookup - const pageMap = new Map(pages.map((p) => [p.id, p])); + spaceId, + }); + const accessibleSet = new Set(accessibleIds); // Prune: include a page only if it's accessible AND its parent chain to root is included const includedIds = new Set(); diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 403354b3..c5c94af7 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -122,12 +122,13 @@ export class SearchService { // Filter results by page-level permissions (if user is authenticated) if (opts.userId && results.length > 0) { const pageIds = results.map((r: any) => r.id); - const accessiblePages = - await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ pageIds, - opts.userId, - ); - const accessibleSet = new Set(accessiblePages.map((p) => p.id)); + userId: opts.userId, + spaceId: searchParams.spaceId, + }); + const accessibleSet = new Set(accessibleIds); results = results.filter((r: any) => accessibleSet.has(r.id)); } @@ -225,12 +226,13 @@ export class SearchService { // Filter by page-level permissions if (pages.length > 0) { const pageIds = pages.map((p) => p.id); - const accessiblePages = - await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId, - ); - const accessibleSet = new Set(accessiblePages.map((p) => p.id)); + spaceId: suggestion?.spaceId, + }); + const accessibleSet = new Set(accessibleIds); pages = pages.filter((p) => accessibleSet.has(p.id)); } } 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 f776a683..6d5f308c 100644 --- a/apps/server/src/database/repos/page/page-permission.repo.ts +++ b/apps/server/src/database/repos/page/page-permission.repo.ts @@ -612,6 +612,83 @@ export class PagePermissionRepo { * Returns page IDs with their permission level (canEdit). * Single query implementation for efficiency. */ + async filterAccessiblePageIds(opts: { + pageIds: string[]; + userId: string; + spaceId?: string; + }): Promise { + const { pageIds, userId, spaceId } = opts; + if (pageIds.length === 0) return []; + + if (spaceId) { + const hasRestrictions = await this.hasRestrictedPagesInSpace(spaceId); + if (!hasRestrictions) { + return pageIds; + } + } + + const results = await this.db + .withRecursive('allAncestors', (qb) => + qb + .selectFrom('pages') + .select([ + 'pages.id as pageId', + 'pages.id as ancestorId', + 'pages.parentPageId', + ]) + .where(sql`pages.id = ANY(${pageIds}::uuid[])`) + .unionAll((eb) => + eb + .selectFrom('pages') + .innerJoin( + 'allAncestors', + 'allAncestors.parentPageId', + 'pages.id', + ) + .select([ + 'allAncestors.pageId', + 'pages.id as ancestorId', + 'pages.parentPageId', + ]), + ), + ) + .selectFrom('pages') + .select('pages.id') + .where(sql`pages.id = ANY(${pageIds}::uuid[])`) + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('allAncestors') + .innerJoin( + 'pageAccess', + 'pageAccess.pageId', + 'allAncestors.ancestorId', + ) + .leftJoin('pagePermissions', (join) => + join + .onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id') + .on((eb) => + eb.or([ + eb('pagePermissions.userId', '=', userId), + eb( + 'pagePermissions.groupId', + 'in', + this.userGroupIdsSubquery(eb, userId), + ), + ]), + ), + ) + .select('pageAccess.pageId') + .whereRef('allAncestors.pageId', '=', 'pages.id') + .where('pagePermissions.id', 'is', null), + ), + ), + ) + .execute(); + + return results.map((r) => r.id); + } + async filterAccessiblePageIdsWithPermissions( pageIds: string[], userId: string, @@ -645,7 +722,6 @@ export class PagePermissionRepo { ) .selectFrom('pages') .select('pages.id') - // Check if user lacks writer permission on any restricted ancestor .select((eb) => eb .case() @@ -690,7 +766,6 @@ export class PagePermissionRepo { .as('canEdit'), ) .where(sql`pages.id = ANY(${pageIds}::uuid[])`) - // Filter: user must have access (any permission on all restricted ancestors) .where(({ not, exists, selectFrom }) => not( exists( diff --git a/apps/server/src/ee b/apps/server/src/ee index 05d3f55c..9da47700 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 05d3f55c78c28c7cfc6ae1c5204b207bce6de2c6 +Subproject commit 9da4770025216e34e974da329f6d86d2d0dd626d diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index 466a453b..5de0e92f 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -380,12 +380,11 @@ export class ExportService { // Filter to only accessible pages if permissions are enforced if (!ignorePermissions && userId) { - const accessiblePages = - await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( - pageMentionIds, + pageMentionIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ + pageIds: pageMentionIds, userId, - ); - pageMentionIds = accessiblePages.map((p) => p.id); + }); } const pages = @@ -485,20 +484,14 @@ export class ExportService { ): Promise { if (pages.length === 0) return []; - // skip heavy filtering if no restrictions exist in this space - const hasRestrictions = - await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId); - if (!hasRestrictions) { - return pages; - } - const pageIds = pages.map((p) => p.id); - const accessiblePages = - await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId, - ); - const accessibleSet = new Set(accessiblePages.map((p) => p.id)); + spaceId, + }); + const accessibleSet = new Set(accessibleIds); const includedIds = new Set();