fix: stop cyclic breadcrumb and share traversal

This commit is contained in:
Philipinho
2026-09-01 22:42:16 +01:00
parent 3e87ce9514
commit 8e35bd0a62
3 changed files with 249 additions and 49 deletions
@@ -55,6 +55,10 @@ import { markdownToHtml } from '@docmost/editor-ext';
import { WatcherService } from '../../watcher/watcher.service';
import { sql } from 'kysely';
import { TransclusionService } from '../transclusion/transclusion.service';
import {
assertAcyclicPageTraversal,
stripPageTraversalMetadata,
} from '../../../database/helpers/page-hierarchy-cycle';
@Injectable()
export class PageService {
@@ -867,6 +871,8 @@ export class PageService {
'parentPageId',
'spaceId',
'deletedAt',
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where('id', '=', childPageId)
.where('deletedAt', 'is', null)
@@ -883,13 +889,27 @@ export class PageService {
'p.parentPageId',
'p.spaceId',
'p.deletedAt',
sql<string[]>`pa.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pa.traversal_path)`.as('isCycle'),
])
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
.where('p.deletedAt', 'is', null)
.where('pa.isCycle', '=', false),
),
)
.selectFrom('page_ancestors')
.selectAll('page_ancestors')
.select([
'id',
'slugId',
'title',
'icon',
'isBase',
'position',
'parentPageId',
'spaceId',
'deletedAt',
'isCycle',
])
.select((eb) =>
eb
.exists(
@@ -903,7 +923,9 @@ export class PageService {
)
.execute();
return ancestors.reverse();
assertAcyclicPageTraversal(ancestors, childPageId);
return ancestors.reverse().map(stripPageTraversalMetadata);
}
async getRecentSpacePages(
+57 -30
View File
@@ -26,6 +26,7 @@ import { validate as isValidUUID } from 'uuid';
import { sql } from 'kysely';
import { TransclusionService } from '../page/transclusion/transclusion.service';
import { TransclusionLookup } from '../page/transclusion/transclusion.types';
import { stripPageTraversalMetadata } from '../../database/helpers/page-hierarchy-cycle';
@Injectable()
export class ShareService {
@@ -144,7 +145,7 @@ export class ShareService {
async getShareForPage(pageId: string, workspaceId: string) {
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
const share = await this.db
const traversal = await this.db
.withRecursive('page_hierarchy', (cte) =>
cte
.selectFrom('pages')
@@ -164,41 +165,67 @@ export class ShareService {
'shares.spaceId',
'shares.workspaceId',
'shares.createdAt',
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
.where('pages.deletedAt', 'is', null)
.unionAll(
(union) =>
union
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
.leftJoin('shares as s', 's.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
sql`ph.level + 1`.as('level'),
's.id as shareId',
's.key as shareKey',
's.includeSubPages',
's.searchIndexing',
's.creatorId',
's.spaceId',
's.workspaceId',
's.createdAt',
])
.where('p.deletedAt', 'is', null)
.where(sql`ph.share_id`, 'is', null) // stop if share found
.where(sql`ph.level`, '<', sql`25`), // prevent loop
.unionAll((union) =>
union
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
.leftJoin('shares as s', 's.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
sql`ph.level + 1`.as('level'),
's.id as shareId',
's.key as shareKey',
's.includeSubPages',
's.searchIndexing',
's.creatorId',
's.spaceId',
's.workspaceId',
's.createdAt',
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
])
.where('p.deletedAt', 'is', null)
.where(sql`ph.share_id`, 'is', null) // stop if share found
.where('ph.isCycle', '=', false),
),
)
.selectFrom('page_hierarchy')
.selectAll()
.where('shareId', 'is not', null)
.limit(1)
.executeTakeFirst();
.select([
'id',
'slugId',
'title',
'icon',
'parentPageId',
'level',
'shareId',
'shareKey',
'includeSubPages',
'searchIndexing',
'creatorId',
'spaceId',
'workspaceId',
'createdAt',
'isCycle',
])
.execute();
if (traversal.some((row) => row.isCycle)) {
return undefined;
}
const matchedShare = traversal.find((row) => row.shareId !== null);
const share = matchedShare
? stripPageTraversalMetadata(matchedShare)
: undefined;
if (!share || share.workspaceId !== workspaceId) {
return undefined;