mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
fix: stop cyclic breadcrumb and share traversal
This commit is contained in:
@@ -55,6 +55,10 @@ import { markdownToHtml } from '@docmost/editor-ext';
|
|||||||
import { WatcherService } from '../../watcher/watcher.service';
|
import { WatcherService } from '../../watcher/watcher.service';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||||
|
import {
|
||||||
|
assertAcyclicPageTraversal,
|
||||||
|
stripPageTraversalMetadata,
|
||||||
|
} from '../../../database/helpers/page-hierarchy-cycle';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageService {
|
export class PageService {
|
||||||
@@ -867,6 +871,8 @@ export class PageService {
|
|||||||
'parentPageId',
|
'parentPageId',
|
||||||
'spaceId',
|
'spaceId',
|
||||||
'deletedAt',
|
'deletedAt',
|
||||||
|
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||||
|
sql<boolean>`false`.as('isCycle'),
|
||||||
])
|
])
|
||||||
.where('id', '=', childPageId)
|
.where('id', '=', childPageId)
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
@@ -883,13 +889,27 @@ export class PageService {
|
|||||||
'p.parentPageId',
|
'p.parentPageId',
|
||||||
'p.spaceId',
|
'p.spaceId',
|
||||||
'p.deletedAt',
|
'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')
|
.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')
|
.selectFrom('page_ancestors')
|
||||||
.selectAll('page_ancestors')
|
.select([
|
||||||
|
'id',
|
||||||
|
'slugId',
|
||||||
|
'title',
|
||||||
|
'icon',
|
||||||
|
'isBase',
|
||||||
|
'position',
|
||||||
|
'parentPageId',
|
||||||
|
'spaceId',
|
||||||
|
'deletedAt',
|
||||||
|
'isCycle',
|
||||||
|
])
|
||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
eb
|
eb
|
||||||
.exists(
|
.exists(
|
||||||
@@ -903,7 +923,9 @@ export class PageService {
|
|||||||
)
|
)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return ancestors.reverse();
|
assertAcyclicPageTraversal(ancestors, childPageId);
|
||||||
|
|
||||||
|
return ancestors.reverse().map(stripPageTraversalMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentSpacePages(
|
async getRecentSpacePages(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { validate as isValidUUID } from 'uuid';
|
|||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { TransclusionService } from '../page/transclusion/transclusion.service';
|
import { TransclusionService } from '../page/transclusion/transclusion.service';
|
||||||
import { TransclusionLookup } from '../page/transclusion/transclusion.types';
|
import { TransclusionLookup } from '../page/transclusion/transclusion.types';
|
||||||
|
import { stripPageTraversalMetadata } from '../../database/helpers/page-hierarchy-cycle';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareService {
|
export class ShareService {
|
||||||
@@ -144,7 +145,7 @@ export class ShareService {
|
|||||||
|
|
||||||
async getShareForPage(pageId: string, workspaceId: string) {
|
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
|
// 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) =>
|
.withRecursive('page_hierarchy', (cte) =>
|
||||||
cte
|
cte
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
@@ -164,11 +165,12 @@ export class ShareService {
|
|||||||
'shares.spaceId',
|
'shares.spaceId',
|
||||||
'shares.workspaceId',
|
'shares.workspaceId',
|
||||||
'shares.createdAt',
|
'shares.createdAt',
|
||||||
|
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||||
|
sql<boolean>`false`.as('isCycle'),
|
||||||
])
|
])
|
||||||
.where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
|
.where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
|
||||||
.where('pages.deletedAt', 'is', null)
|
.where('pages.deletedAt', 'is', null)
|
||||||
.unionAll(
|
.unionAll((union) =>
|
||||||
(union) =>
|
|
||||||
union
|
union
|
||||||
.selectFrom('pages as p')
|
.selectFrom('pages as p')
|
||||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
||||||
@@ -188,17 +190,42 @@ export class ShareService {
|
|||||||
's.spaceId',
|
's.spaceId',
|
||||||
's.workspaceId',
|
's.workspaceId',
|
||||||
's.createdAt',
|
'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('p.deletedAt', 'is', null)
|
||||||
.where(sql`ph.share_id`, 'is', null) // stop if share found
|
.where(sql`ph.share_id`, 'is', null) // stop if share found
|
||||||
.where(sql`ph.level`, '<', sql`25`), // prevent loop
|
.where('ph.isCycle', '=', false),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.selectFrom('page_hierarchy')
|
.selectFrom('page_hierarchy')
|
||||||
.selectAll()
|
.select([
|
||||||
.where('shareId', 'is not', null)
|
'id',
|
||||||
.limit(1)
|
'slugId',
|
||||||
.executeTakeFirst();
|
'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) {
|
if (!share || share.workspaceId !== workspaceId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -1,21 +1,172 @@
|
|||||||
import { db } from './support/database';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { seedAcyclicPageChain } from './support/page-hierarchy-fixtures';
|
import { PageService } from '../src/core/page/services/page.service';
|
||||||
|
import { ShareService } from '../src/core/share/share.service';
|
||||||
|
import { PageHierarchyCycleError } from '../src/database/helpers/page-hierarchy-cycle';
|
||||||
|
import { KyselyDB } from '../src/database/types/kysely.types';
|
||||||
|
import { db, withStatementTimeout } from './support/database';
|
||||||
|
import {
|
||||||
|
seedAcyclicPageChain,
|
||||||
|
seedSelfCycle,
|
||||||
|
seedTwoPageCycle,
|
||||||
|
} from './support/page-hierarchy-fixtures';
|
||||||
|
|
||||||
describe('page hierarchy cycle integration harness', () => {
|
function createPageService(connection: KyselyDB): PageService {
|
||||||
it('inserts and reads an acyclic page chain through the test Kysely instance', async () => {
|
return new PageService(
|
||||||
const { root, child, grandchild } = await seedAcyclicPageChain();
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
connection,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const pages = await db
|
function createShareService(connection: KyselyDB): ShareService {
|
||||||
|
return new ShareService(
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
connection,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertShare(pageId: string, includeSubPages: boolean) {
|
||||||
|
const page = await db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(['id', 'parentPageId', 'title'])
|
.select(['spaceId', 'workspaceId'])
|
||||||
.where('id', 'in', [root.id, child.id, grandchild.id])
|
.where('id', '=', pageId)
|
||||||
.orderBy('title')
|
.executeTakeFirstOrThrow();
|
||||||
.execute();
|
|
||||||
|
|
||||||
expect(pages).toEqual([
|
return db
|
||||||
{ id: child.id, parentPageId: root.id, title: 'Child' },
|
.insertInto('shares')
|
||||||
{ id: grandchild.id, parentPageId: child.id, title: 'Grandchild' },
|
.values({
|
||||||
{ id: root.id, parentPageId: null, title: 'Root' },
|
includeSubPages,
|
||||||
|
key: `cycle-test-${randomUUID()}`,
|
||||||
|
pageId,
|
||||||
|
searchIndexing: false,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
})
|
||||||
|
.returning(['id', 'workspaceId'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cycle-safe page hierarchy reads', () => {
|
||||||
|
describe('PageService.getPageBreadCrumbs', () => {
|
||||||
|
it('returns every acyclic breadcrumb exactly once in root-to-child order', async () => {
|
||||||
|
const { root, child, grandchild } = await seedAcyclicPageChain();
|
||||||
|
const pageService = createPageService(db);
|
||||||
|
|
||||||
|
const breadcrumbs = await pageService.getPageBreadCrumbs(grandchild.id);
|
||||||
|
|
||||||
|
expect(breadcrumbs.map(({ id, title }) => ({ id, title }))).toEqual([
|
||||||
|
{ id: root.id, title: 'Root' },
|
||||||
|
{ id: child.id, title: 'Child' },
|
||||||
|
{ id: grandchild.id, title: 'Grandchild' },
|
||||||
]);
|
]);
|
||||||
|
expect(breadcrumbs).toHaveLength(3);
|
||||||
|
for (const breadcrumb of breadcrumbs) {
|
||||||
|
expect(breadcrumb).not.toHaveProperty('isCycle');
|
||||||
|
expect(breadcrumb).not.toHaveProperty('traversalPath');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises PageHierarchyCycleError for a self-cycle', async () => {
|
||||||
|
const { self } = await seedSelfCycle();
|
||||||
|
|
||||||
|
await withStatementTimeout(async (connection) => {
|
||||||
|
const pageService = createPageService(connection);
|
||||||
|
|
||||||
|
await expect(pageService.getPageBreadCrumbs(self.id)).rejects.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'PAGE_HIERARCHY_CYCLE',
|
||||||
|
rootPageId: self.id,
|
||||||
|
}) satisfies Partial<PageHierarchyCycleError>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises PageHierarchyCycleError for a two-page cycle', async () => {
|
||||||
|
const { a } = await seedTwoPageCycle();
|
||||||
|
|
||||||
|
await withStatementTimeout(async (connection) => {
|
||||||
|
const pageService = createPageService(connection);
|
||||||
|
|
||||||
|
await expect(pageService.getPageBreadCrumbs(a.id)).rejects.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'PAGE_HIERARCHY_CYCLE',
|
||||||
|
rootPageId: a.id,
|
||||||
|
}) satisfies Partial<PageHierarchyCycleError>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ShareService.getShareForPage', () => {
|
||||||
|
it('returns the nearest valid inherited share without a depth limit', async () => {
|
||||||
|
const { root, grandchild } = await seedAcyclicPageChain();
|
||||||
|
const context = await db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['spaceId', 'workspaceId'])
|
||||||
|
.where('id', '=', root.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
let descendantId = grandchild.id;
|
||||||
|
|
||||||
|
for (let depth = 3; depth <= 26; depth += 1) {
|
||||||
|
const descendant = await db
|
||||||
|
.insertInto('pages')
|
||||||
|
.values({
|
||||||
|
parentPageId: descendantId,
|
||||||
|
slugId: randomUUID(),
|
||||||
|
spaceId: context.spaceId,
|
||||||
|
title: `Descendant ${depth}`,
|
||||||
|
workspaceId: context.workspaceId,
|
||||||
|
})
|
||||||
|
.returning('id')
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
descendantId = descendant.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedShare = await insertShare(root.id, true);
|
||||||
|
const shareService = createShareService(db);
|
||||||
|
|
||||||
|
const share = await shareService.getShareForPage(
|
||||||
|
descendantId,
|
||||||
|
context.workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(share).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: storedShare.id,
|
||||||
|
pageId: root.id,
|
||||||
|
level: 26,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for a cyclic chain with no reachable share', async () => {
|
||||||
|
const { a } = await seedTwoPageCycle();
|
||||||
|
const { workspaceId } = await db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select('workspaceId')
|
||||||
|
.where('id', '=', a.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
await withStatementTimeout(async (connection) => {
|
||||||
|
const shareService = createShareService(connection);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
shareService.getShareForPage(a.id, workspaceId),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user