Compare commits

...
Author SHA1 Message Date
Philipinho 8d7302adc6 fix: tighten page move 2026-08-24 20:24:33 +01:00
6 changed files with 162 additions and 36 deletions
@@ -7,6 +7,7 @@ import {
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto'; import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto'; import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { MAX_PAGE_TREE_DEPTH } from '@docmost/db/repos/page/constants';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types'; import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
@@ -111,6 +112,14 @@ export class PageService {
throw new NotFoundException('Parent page not found'); throw new NotFoundException('Parent page not found');
} }
const ancestorIds = await this.pageRepo.getAncestorPageIds(
parentPage.id,
trx,
);
if (ancestorIds.length >= MAX_PAGE_TREE_DEPTH) {
throw new BadRequestException('Page nesting is too deep');
}
parentPageId = parentPage.id; parentPageId = parentPage.id;
} }
@@ -839,6 +848,18 @@ export class PageService {
) { ) {
throw new NotFoundException('Parent page not found'); throw new NotFoundException('Parent page not found');
} }
const ancestorIds = await this.pageRepo.getAncestorPageIds(
parentPage.id,
);
if (ancestorIds.includes(movedPage.id)) {
throw new BadRequestException(
'Cannot move a page under its own descendant',
);
}
if (ancestorIds.length >= MAX_PAGE_TREE_DEPTH) {
throw new BadRequestException('Page nesting is too deep');
}
parentPageId = parentPage.id; parentPageId = parentPage.id;
} }
} }
@@ -868,6 +889,7 @@ export class PageService {
'spaceId', 'spaceId',
'deletedAt', 'deletedAt',
]) ])
.select(sql<number>`0`.as('depth'))
.where('id', '=', childPageId) .where('id', '=', childPageId)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.unionAll((exp) => .unionAll((exp) =>
@@ -884,12 +906,24 @@ export class PageService {
'p.spaceId', 'p.spaceId',
'p.deletedAt', 'p.deletedAt',
]) ])
.select(sql<number>`pa.depth + 1`.as('depth'))
.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.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('page_ancestors') .selectFrom('page_ancestors')
.selectAll('page_ancestors') .select([
'id',
'slugId',
'title',
'icon',
'isBase',
'position',
'parentPageId',
'spaceId',
'deletedAt',
])
.select((eb) => .select((eb) =>
eb eb
.exists( .exists(
@@ -1009,17 +1043,18 @@ export class PageService {
.withRecursive('page_descendants', (db) => .withRecursive('page_descendants', (db) =>
db db
.selectFrom('pages') .selectFrom('pages')
.select(['id']) .select(['id', sql<number>`0`.as('depth')])
.where('id', '=', pageId) .where('id', '=', pageId)
.unionAll((exp) => .unionAll((exp) =>
exp exp
.selectFrom('pages as p') .selectFrom('pages as p')
.select(['p.id']) .select(['p.id', sql<number>`pd.depth + 1`.as('depth')])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'), .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('pd.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('page_descendants') .selectFrom('page_descendants')
.selectAll() .select(['id'])
.execute(); .execute();
const pageIds = descendants.map((d) => d.id); const pageIds = descendants.map((d) => d.id);
@@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule'; import { Interval } from '@nestjs/schedule';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { MAX_PAGE_TREE_DEPTH } from '@docmost/db/repos/page/constants';
import { sql } from 'kysely';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@@ -76,17 +78,18 @@ export class TrashCleanupService {
.withRecursive('page_descendants', (db) => .withRecursive('page_descendants', (db) =>
db db
.selectFrom('pages') .selectFrom('pages')
.select(['id']) .select(['id', sql<number>`0`.as('depth')])
.where('id', '=', pageId) .where('id', '=', pageId)
.unionAll((exp) => .unionAll((exp) =>
exp exp
.selectFrom('pages as p') .selectFrom('pages as p')
.select(['p.id']) .select(['p.id', sql<number>`pd.depth + 1`.as('depth')])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'), .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('pd.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('page_descendants') .selectFrom('page_descendants')
.selectAll() .select(['id'])
.execute(); .execute();
const pageIds = descendants.map((d) => d.id); const pageIds = descendants.map((d) => d.id);
+6 -2
View File
@@ -20,6 +20,7 @@ import {
import { Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { MAX_PAGE_TREE_DEPTH } from '@docmost/db/repos/page/constants';
import { updateAttachmentAttr } from './share.util'; import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
import { validate as isValidUUID } from 'uuid'; import { validate as isValidUUID } from 'uuid';
@@ -247,6 +248,7 @@ export class ShareService {
.else(false) .else(false)
.end() .end()
.as('found'), .as('found'),
sql<number>`0`.as('depth'),
]) ])
.where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId) .where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId)
.unionAll((exp) => .unionAll((exp) =>
@@ -266,14 +268,16 @@ export class ShareService {
.else(false) .else(false)
.end() .end()
.as('found'), .as('found'),
sql<number>`pa.depth + 1`.as('depth'),
]) ])
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id') .innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
// Continue recursing only when the target ancestor hasn't been found on that branch. // Continue recursing only when the target ancestor hasn't been found on that branch.
.where('pa.found', '=', false), .where('pa.found', '=', false)
.where('pa.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('page_ancestors') .selectFrom('page_ancestors')
.selectAll() .select(['id', 'slugId', 'title', 'parentPageId', 'spaceId'])
.where('found', '=', true) .where('found', '=', true)
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
@@ -0,0 +1 @@
export const MAX_PAGE_TREE_DEPTH = 100;
@@ -24,6 +24,7 @@ import {
CacheKey, CacheKey,
PERMISSION_CACHE_TTL_MS, PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys'; } from '../../../common/helpers/cache-keys';
import { MAX_PAGE_TREE_DEPTH } from './constants';
export { PagePermissionMember } from './types/page-permission.types'; export { PagePermissionMember } from './types/page-permission.types';
@@ -350,7 +351,8 @@ export class PagePermissionRepo {
'pages.id as ancestorId', 'pages.id as ancestorId',
'pages.parentPageId', 'pages.parentPageId',
sql<number>`ancestors.depth + 1`.as('depth'), sql<number>`ancestors.depth + 1`.as('depth'),
]), ])
.where('ancestors.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('ancestors') .selectFrom('ancestors')
@@ -405,6 +407,7 @@ export class PagePermissionRepo {
SELECT p.id, p.parent_page_id, a.depth + 1 SELECT p.id, p.parent_page_id, a.depth + 1
FROM pages p FROM pages p
JOIN ancestors a ON a.parent_page_id = p.id JOIN ancestors a ON a.parent_page_id = p.id
WHERE a.depth < ${MAX_PAGE_TREE_DEPTH}
) )
SELECT SELECT
bool_and(pp.id IS NOT NULL) AS "canAccess", bool_and(pp.id IS NOT NULL) AS "canAccess",
@@ -471,7 +474,8 @@ export class PagePermissionRepo {
'pages.id as ancestorId', 'pages.id as ancestorId',
'pages.parentPageId', 'pages.parentPageId',
sql<number>`ancestors.depth + 1`.as('depth'), sql<number>`ancestors.depth + 1`.as('depth'),
]), ])
.where('ancestors.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('pages') .selectFrom('pages')
@@ -676,6 +680,7 @@ export class PagePermissionRepo {
'pages.id as pageId', 'pages.id as pageId',
'pages.id as ancestorId', 'pages.id as ancestorId',
'pages.parentPageId', 'pages.parentPageId',
sql<number>`0`.as('depth'),
]) ])
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`) .where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
.unionAll((eb) => .unionAll((eb) =>
@@ -690,7 +695,9 @@ export class PagePermissionRepo {
'allAncestors.pageId', 'allAncestors.pageId',
'pages.id as ancestorId', 'pages.id as ancestorId',
'pages.parentPageId', 'pages.parentPageId',
]), sql<number>`all_ancestors.depth + 1`.as('depth'),
])
.where('allAncestors.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('pages') .selectFrom('pages')
@@ -760,7 +767,8 @@ export class PagePermissionRepo {
'pages.id as ancestorId', 'pages.id as ancestorId',
'pages.parentPageId', 'pages.parentPageId',
sql<number>`all_ancestors.depth + 1`.as('depth'), sql<number>`all_ancestors.depth + 1`.as('depth'),
]), ])
.where('allAncestors.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('pages') .selectFrom('pages')
@@ -865,13 +873,22 @@ export class PagePermissionRepo {
.withRecursive('ancestors', (qb) => .withRecursive('ancestors', (qb) =>
qb qb
.selectFrom('pages') .selectFrom('pages')
.select(['pages.id as ancestorId', 'pages.parentPageId']) .select([
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`0`.as('depth'),
])
.where('pages.id', '=', pageId) .where('pages.id', '=', pageId)
.unionAll((eb) => .unionAll((eb) =>
eb eb
.selectFrom('pages') .selectFrom('pages')
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id') .innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
.select(['pages.id as ancestorId', 'pages.parentPageId']), .select([
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`ancestors.depth + 1`.as('depth'),
])
.where('ancestors.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('ancestors') .selectFrom('ancestors')
@@ -921,6 +938,7 @@ export class PagePermissionRepo {
'child.id as childId', 'child.id as childId',
'child.id as ancestorId', 'child.id as ancestorId',
'child.parentPageId as ancestorParentId', 'child.parentPageId as ancestorParentId',
sql<number>`0`.as('depth'),
]) ])
.where('child.parentPageId', 'in', parentIds) .where('child.parentPageId', 'in', parentIds)
.where('child.deletedAt', 'is', null) .where('child.deletedAt', 'is', null)
@@ -936,7 +954,9 @@ export class PagePermissionRepo {
'childAncestors.childId', 'childAncestors.childId',
'pages.id as ancestorId', 'pages.id as ancestorId',
'pages.parentPageId as ancestorParentId', 'pages.parentPageId as ancestorParentId',
]), sql<number>`child_ancestors.depth + 1`.as('depth'),
])
.where('childAncestors.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('pages as child') .selectFrom('pages as child')
@@ -988,7 +1008,11 @@ export class PagePermissionRepo {
.withRecursive('descendants', (qb) => .withRecursive('descendants', (qb) =>
qb qb
.selectFrom('pages') .selectFrom('pages')
.select(['pages.id as descendantId', 'pages.parentPageId']) .select([
'pages.id as descendantId',
'pages.parentPageId',
sql<number>`0`.as('depth'),
])
.where('pages.id', '=', rootPageId) .where('pages.id', '=', rootPageId)
.unionAll((eb) => .unionAll((eb) =>
eb eb
@@ -998,8 +1022,13 @@ export class PagePermissionRepo {
'descendants.descendantId', 'descendants.descendantId',
'pages.parentPageId', 'pages.parentPageId',
) )
.select(['pages.id as descendantId', 'pages.parentPageId']) .select([
.where('pages.deletedAt', 'is', null), 'pages.id as descendantId',
'pages.parentPageId',
sql<number>`descendants.depth + 1`.as('depth'),
])
.where('pages.deletedAt', 'is', null)
.where('descendants.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.withRecursive('descendantAncestors', (qb) => .withRecursive('descendantAncestors', (qb) =>
@@ -1010,6 +1039,7 @@ export class PagePermissionRepo {
'descendants.descendantId', 'descendants.descendantId',
'pages.id as ancestorId', 'pages.id as ancestorId',
'pages.parentPageId as ancestorParentId', 'pages.parentPageId as ancestorParentId',
sql<number>`0`.as('depth'),
]) ])
.unionAll((eb) => .unionAll((eb) =>
eb eb
@@ -1023,7 +1053,9 @@ export class PagePermissionRepo {
'descendantAncestors.descendantId', 'descendantAncestors.descendantId',
'pages.id as ancestorId', 'pages.id as ancestorId',
'pages.parentPageId as ancestorParentId', 'pages.parentPageId as ancestorParentId',
]), sql<number>`descendant_ancestors.depth + 1`.as('depth'),
])
.where('descendantAncestors.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('descendantAncestors') .selectFrom('descendantAncestors')
@@ -1052,13 +1084,14 @@ export class PagePermissionRepo {
const results = await sql<{ userId: string }>` const results = await sql<{ userId: string }>`
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id AS ancestor_id, parent_page_id SELECT id AS ancestor_id, parent_page_id, 0 AS depth
FROM pages FROM pages
WHERE id = ${pageId}::uuid WHERE id = ${pageId}::uuid
UNION ALL UNION ALL
SELECT p.id, p.parent_page_id SELECT p.id, p.parent_page_id, a.depth + 1
FROM pages p FROM pages p
JOIN ancestors a ON a.parent_page_id = p.id JOIN ancestors a ON a.parent_page_id = p.id
WHERE a.depth < ${MAX_PAGE_TREE_DEPTH}
) )
SELECT cu.user_id AS "userId" SELECT cu.user_id AS "userId"
FROM unnest(${userIds}::uuid[]) AS cu(user_id) FROM unnest(${userIds}::uuid[]) AS cu(user_id)
@@ -16,6 +16,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants'; import { EventName } from '../../../common/events/event.contants';
import { MAX_PAGE_TREE_DEPTH } from './constants';
@Injectable() @Injectable()
export class PageRepo { export class PageRepo {
@@ -203,19 +204,20 @@ export class PageRepo {
.withRecursive('page_descendants', (db) => .withRecursive('page_descendants', (db) =>
db db
.selectFrom('pages') .selectFrom('pages')
.select(['id']) .select(['id', sql<number>`0`.as('depth')])
.where('id', '=', pageId) .where('id', '=', pageId)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.unionAll((exp) => .unionAll((exp) =>
exp exp
.selectFrom('pages as p') .selectFrom('pages as p')
.select(['p.id']) .select(['p.id', sql<number>`pd.depth + 1`.as('depth')])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId') .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('p.deletedAt', 'is', null), .where('p.deletedAt', 'is', null)
.where('pd.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('page_descendants') .selectFrom('page_descendants')
.selectAll() .select(['id'])
.execute(); .execute();
const pageIds = descendants.map((d) => d.id); const pageIds = descendants.map((d) => d.id);
@@ -272,17 +274,18 @@ export class PageRepo {
.withRecursive('page_descendants', (db) => .withRecursive('page_descendants', (db) =>
db db
.selectFrom('pages') .selectFrom('pages')
.select(['id']) .select(['id', sql<number>`0`.as('depth')])
.where('id', '=', pageId) .where('id', '=', pageId)
.unionAll((exp) => .unionAll((exp) =>
exp exp
.selectFrom('pages as p') .selectFrom('pages as p')
.select(['p.id']) .select(['p.id', sql<number>`pd.depth + 1`.as('depth')])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'), .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('pd.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('page_descendants') .selectFrom('page_descendants')
.selectAll() .select(['id'])
.execute(); .execute();
const pageIds = pages.map((p) => p.id); const pageIds = pages.map((p) => p.id);
@@ -487,6 +490,35 @@ export class PageRepo {
.as('hasChildren'); .as('hasChildren');
} }
async getAncestorPageIds(
pageId: string,
trx?: KyselyTransaction,
): Promise<string[]> {
const ancestors = await dbOrTx(this.db, trx)
.withRecursive('page_ancestors', (db) =>
db
.selectFrom('pages')
.select(['id', 'parentPageId', sql<number>`0`.as('depth')])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
'p.parentPageId',
sql<number>`pa.depth + 1`.as('depth'),
])
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
.where('pa.depth', '<', MAX_PAGE_TREE_DEPTH),
),
)
.selectFrom('page_ancestors')
.select(['id'])
.execute();
return ancestors.map((ancestor) => ancestor.id);
}
async getPageAndDescendants( async getPageAndDescendants(
parentPageId: string, parentPageId: string,
opts: { includeContent: boolean }, opts: { includeContent: boolean },
@@ -507,6 +539,7 @@ export class PageRepo {
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
]) ])
.select(sql<number>`0`.as('depth'))
.$if(opts?.includeContent, (qb) => qb.select('content')) .$if(opts?.includeContent, (qb) => qb.select('content'))
.where('id', '=', parentPageId) .where('id', '=', parentPageId)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
@@ -525,13 +558,27 @@ export class PageRepo {
'p.createdAt', 'p.createdAt',
'p.updatedAt', 'p.updatedAt',
]) ])
.select(sql<number>`ph.depth + 1`.as('depth'))
.$if(opts?.includeContent, (qb) => qb.select('p.content')) .$if(opts?.includeContent, (qb) => qb.select('p.content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.where('p.deletedAt', 'is', null), .where('p.deletedAt', 'is', null)
.where('ph.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('page_hierarchy') .selectFrom('page_hierarchy')
.selectAll() .select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'workspaceId',
'createdAt',
'updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.execute(); .execute();
} }
@@ -563,6 +610,7 @@ export class PageRepo {
'pages.workspaceId', 'pages.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'), sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
]) ])
.select(sql<number>`0`.as('depth'))
.$if(opts?.includeContent, (qb) => qb.select('pages.content')) .$if(opts?.includeContent, (qb) => qb.select('pages.content'))
.where('pages.id', '=', parentPageId) .where('pages.id', '=', parentPageId)
.where('pages.deletedAt', 'is', null) .where('pages.deletedAt', 'is', null)
@@ -582,10 +630,12 @@ export class PageRepo {
'p.workspaceId', 'p.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'), sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
]) ])
.select(sql<number>`ph.depth + 1`.as('depth'))
.$if(opts?.includeContent, (qb) => qb.select('p.content')) .$if(opts?.includeContent, (qb) => qb.select('p.content'))
.where('p.deletedAt', 'is', null) .where('p.deletedAt', 'is', null)
// Only recurse into children of non-restricted pages // Only recurse into children of non-restricted pages
.where('ph.isRestricted', '=', false), .where('ph.isRestricted', '=', false)
.where('ph.depth', '<', MAX_PAGE_TREE_DEPTH),
), ),
) )
.selectFrom('page_hierarchy') .selectFrom('page_hierarchy')