diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index cabfb0c74..1983222d5 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -7,6 +7,7 @@ import { import { CreatePageDto, ContentFormat } from '../dto/create-page.dto'; import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto'; 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 { InsertablePage, Page, User } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; @@ -111,6 +112,14 @@ export class PageService { 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; } @@ -839,6 +848,18 @@ export class PageService { ) { 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; } } @@ -868,6 +889,7 @@ export class PageService { 'spaceId', 'deletedAt', ]) + .select(sql`0`.as('depth')) .where('id', '=', childPageId) .where('deletedAt', 'is', null) .unionAll((exp) => @@ -884,12 +906,24 @@ export class PageService { 'p.spaceId', 'p.deletedAt', ]) + .select(sql`pa.depth + 1`.as('depth')) .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') - .selectAll('page_ancestors') + .select([ + 'id', + 'slugId', + 'title', + 'icon', + 'isBase', + 'position', + 'parentPageId', + 'spaceId', + 'deletedAt', + ]) .select((eb) => eb .exists( @@ -1009,17 +1043,18 @@ export class PageService { .withRecursive('page_descendants', (db) => db .selectFrom('pages') - .select(['id']) + .select(['id', sql`0`.as('depth')]) .where('id', '=', pageId) .unionAll((exp) => exp .selectFrom('pages as p') - .select(['p.id']) - .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'), + .select(['p.id', sql`pd.depth + 1`.as('depth')]) + .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId') + .where('pd.depth', '<', MAX_PAGE_TREE_DEPTH), ), ) .selectFrom('page_descendants') - .selectAll() + .select(['id']) .execute(); const pageIds = descendants.map((d) => d.id); diff --git a/apps/server/src/core/page/services/trash-cleanup.service.ts b/apps/server/src/core/page/services/trash-cleanup.service.ts index 42a4a11a3..48e5da259 100644 --- a/apps/server/src/core/page/services/trash-cleanup.service.ts +++ b/apps/server/src/core/page/services/trash-cleanup.service.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { Interval } from '@nestjs/schedule'; import { InjectKysely } from 'nestjs-kysely'; 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 { Queue } from 'bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; @@ -76,17 +78,18 @@ export class TrashCleanupService { .withRecursive('page_descendants', (db) => db .selectFrom('pages') - .select(['id']) + .select(['id', sql`0`.as('depth')]) .where('id', '=', pageId) .unionAll((exp) => exp .selectFrom('pages as p') - .select(['p.id']) - .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'), + .select(['p.id', sql`pd.depth + 1`.as('depth')]) + .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId') + .where('pd.depth', '<', MAX_PAGE_TREE_DEPTH), ), ) .selectFrom('page_descendants') - .selectAll() + .select(['id']) .execute(); const pageIds = descendants.map((d) => d.id); diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 03ee31555..1fa252fc8 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -20,6 +20,7 @@ import { 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 { MAX_PAGE_TREE_DEPTH } from '@docmost/db/repos/page/constants'; import { updateAttachmentAttr } from './share.util'; import { Page } from '@docmost/db/types/entity.types'; import { validate as isValidUUID } from 'uuid'; @@ -247,6 +248,7 @@ export class ShareService { .else(false) .end() .as('found'), + sql`0`.as('depth'), ]) .where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId) .unionAll((exp) => @@ -266,14 +268,16 @@ export class ShareService { .else(false) .end() .as('found'), + sql`pa.depth + 1`.as('depth'), ]) .innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id') // 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') - .selectAll() + .select(['id', 'slugId', 'title', 'parentPageId', 'spaceId']) .where('found', '=', true) .limit(1) .executeTakeFirst(); diff --git a/apps/server/src/database/repos/page/constants.ts b/apps/server/src/database/repos/page/constants.ts new file mode 100644 index 000000000..e050c4566 --- /dev/null +++ b/apps/server/src/database/repos/page/constants.ts @@ -0,0 +1 @@ +export const MAX_PAGE_TREE_DEPTH = 100; 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 f753526cd..574bc852b 100644 --- a/apps/server/src/database/repos/page/page-permission.repo.ts +++ b/apps/server/src/database/repos/page/page-permission.repo.ts @@ -24,6 +24,7 @@ import { CacheKey, PERMISSION_CACHE_TTL_MS, } from '../../../common/helpers/cache-keys'; +import { MAX_PAGE_TREE_DEPTH } from './constants'; export { PagePermissionMember } from './types/page-permission.types'; @@ -350,7 +351,8 @@ export class PagePermissionRepo { 'pages.id as ancestorId', 'pages.parentPageId', sql`ancestors.depth + 1`.as('depth'), - ]), + ]) + .where('ancestors.depth', '<', MAX_PAGE_TREE_DEPTH), ), ) .selectFrom('ancestors') @@ -405,6 +407,7 @@ export class PagePermissionRepo { SELECT p.id, p.parent_page_id, a.depth + 1 FROM pages p JOIN ancestors a ON a.parent_page_id = p.id + WHERE a.depth < ${MAX_PAGE_TREE_DEPTH} ) SELECT bool_and(pp.id IS NOT NULL) AS "canAccess", @@ -471,7 +474,8 @@ export class PagePermissionRepo { 'pages.id as ancestorId', 'pages.parentPageId', sql`ancestors.depth + 1`.as('depth'), - ]), + ]) + .where('ancestors.depth', '<', MAX_PAGE_TREE_DEPTH), ), ) .selectFrom('pages') @@ -676,6 +680,7 @@ export class PagePermissionRepo { 'pages.id as pageId', 'pages.id as ancestorId', 'pages.parentPageId', + sql`0`.as('depth'), ]) .where(sql`pages.id = ANY(${pageIds}::uuid[])`) .unionAll((eb) => @@ -690,7 +695,9 @@ export class PagePermissionRepo { 'allAncestors.pageId', 'pages.id as ancestorId', 'pages.parentPageId', - ]), + sql`all_ancestors.depth + 1`.as('depth'), + ]) + .where('allAncestors.depth', '<', MAX_PAGE_TREE_DEPTH), ), ) .selectFrom('pages') @@ -760,7 +767,8 @@ export class PagePermissionRepo { 'pages.id as ancestorId', 'pages.parentPageId', sql`all_ancestors.depth + 1`.as('depth'), - ]), + ]) + .where('allAncestors.depth', '<', MAX_PAGE_TREE_DEPTH), ), ) .selectFrom('pages') @@ -865,13 +873,22 @@ export class PagePermissionRepo { .withRecursive('ancestors', (qb) => qb .selectFrom('pages') - .select(['pages.id as ancestorId', 'pages.parentPageId']) + .select([ + 'pages.id as ancestorId', + 'pages.parentPageId', + sql`0`.as('depth'), + ]) .where('pages.id', '=', pageId) .unionAll((eb) => eb .selectFrom('pages') .innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id') - .select(['pages.id as ancestorId', 'pages.parentPageId']), + .select([ + 'pages.id as ancestorId', + 'pages.parentPageId', + sql`ancestors.depth + 1`.as('depth'), + ]) + .where('ancestors.depth', '<', MAX_PAGE_TREE_DEPTH), ), ) .selectFrom('ancestors') @@ -921,6 +938,7 @@ export class PagePermissionRepo { 'child.id as childId', 'child.id as ancestorId', 'child.parentPageId as ancestorParentId', + sql`0`.as('depth'), ]) .where('child.parentPageId', 'in', parentIds) .where('child.deletedAt', 'is', null) @@ -936,7 +954,9 @@ export class PagePermissionRepo { 'childAncestors.childId', 'pages.id as ancestorId', 'pages.parentPageId as ancestorParentId', - ]), + sql`child_ancestors.depth + 1`.as('depth'), + ]) + .where('childAncestors.depth', '<', MAX_PAGE_TREE_DEPTH), ), ) .selectFrom('pages as child') @@ -988,7 +1008,11 @@ export class PagePermissionRepo { .withRecursive('descendants', (qb) => qb .selectFrom('pages') - .select(['pages.id as descendantId', 'pages.parentPageId']) + .select([ + 'pages.id as descendantId', + 'pages.parentPageId', + sql`0`.as('depth'), + ]) .where('pages.id', '=', rootPageId) .unionAll((eb) => eb @@ -998,8 +1022,13 @@ export class PagePermissionRepo { 'descendants.descendantId', 'pages.parentPageId', ) - .select(['pages.id as descendantId', 'pages.parentPageId']) - .where('pages.deletedAt', 'is', null), + .select([ + 'pages.id as descendantId', + 'pages.parentPageId', + sql`descendants.depth + 1`.as('depth'), + ]) + .where('pages.deletedAt', 'is', null) + .where('descendants.depth', '<', MAX_PAGE_TREE_DEPTH), ), ) .withRecursive('descendantAncestors', (qb) => @@ -1010,6 +1039,7 @@ export class PagePermissionRepo { 'descendants.descendantId', 'pages.id as ancestorId', 'pages.parentPageId as ancestorParentId', + sql`0`.as('depth'), ]) .unionAll((eb) => eb @@ -1023,7 +1053,9 @@ export class PagePermissionRepo { 'descendantAncestors.descendantId', 'pages.id as ancestorId', 'pages.parentPageId as ancestorParentId', - ]), + sql`descendant_ancestors.depth + 1`.as('depth'), + ]) + .where('descendantAncestors.depth', '<', MAX_PAGE_TREE_DEPTH), ), ) .selectFrom('descendantAncestors') @@ -1052,13 +1084,14 @@ export class PagePermissionRepo { const results = await sql<{ userId: string }>` 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 WHERE id = ${pageId}::uuid UNION ALL - SELECT p.id, p.parent_page_id + SELECT p.id, p.parent_page_id, a.depth + 1 FROM pages p JOIN ancestors a ON a.parent_page_id = p.id + WHERE a.depth < ${MAX_PAGE_TREE_DEPTH} ) SELECT cu.user_id AS "userId" FROM unnest(${userIds}::uuid[]) AS cu(user_id) diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 9eb9f3a50..145933637 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -16,6 +16,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventName } from '../../../common/events/event.contants'; +import { MAX_PAGE_TREE_DEPTH } from './constants'; @Injectable() export class PageRepo { @@ -203,19 +204,20 @@ export class PageRepo { .withRecursive('page_descendants', (db) => db .selectFrom('pages') - .select(['id']) + .select(['id', sql`0`.as('depth')]) .where('id', '=', pageId) .where('deletedAt', 'is', null) .unionAll((exp) => exp .selectFrom('pages as p') - .select(['p.id']) + .select(['p.id', sql`pd.depth + 1`.as('depth')]) .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') - .selectAll() + .select(['id']) .execute(); const pageIds = descendants.map((d) => d.id); @@ -272,17 +274,18 @@ export class PageRepo { .withRecursive('page_descendants', (db) => db .selectFrom('pages') - .select(['id']) + .select(['id', sql`0`.as('depth')]) .where('id', '=', pageId) .unionAll((exp) => exp .selectFrom('pages as p') - .select(['p.id']) - .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'), + .select(['p.id', sql`pd.depth + 1`.as('depth')]) + .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId') + .where('pd.depth', '<', MAX_PAGE_TREE_DEPTH), ), ) .selectFrom('page_descendants') - .selectAll() + .select(['id']) .execute(); const pageIds = pages.map((p) => p.id); @@ -487,6 +490,35 @@ export class PageRepo { .as('hasChildren'); } + async getAncestorPageIds( + pageId: string, + trx?: KyselyTransaction, + ): Promise { + const ancestors = await dbOrTx(this.db, trx) + .withRecursive('page_ancestors', (db) => + db + .selectFrom('pages') + .select(['id', 'parentPageId', sql`0`.as('depth')]) + .where('id', '=', pageId) + .unionAll((exp) => + exp + .selectFrom('pages as p') + .select([ + 'p.id', + 'p.parentPageId', + sql`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( parentPageId: string, opts: { includeContent: boolean }, @@ -507,6 +539,7 @@ export class PageRepo { 'createdAt', 'updatedAt', ]) + .select(sql`0`.as('depth')) .$if(opts?.includeContent, (qb) => qb.select('content')) .where('id', '=', parentPageId) .where('deletedAt', 'is', null) @@ -525,13 +558,27 @@ export class PageRepo { 'p.createdAt', 'p.updatedAt', ]) + .select(sql`ph.depth + 1`.as('depth')) .$if(opts?.includeContent, (qb) => qb.select('p.content')) .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') - .selectAll() + .select([ + 'id', + 'slugId', + 'title', + 'icon', + 'position', + 'parentPageId', + 'spaceId', + 'workspaceId', + 'createdAt', + 'updatedAt', + ]) + .$if(opts?.includeContent, (qb) => qb.select('content')) .execute(); } @@ -563,6 +610,7 @@ export class PageRepo { 'pages.workspaceId', sql`page_access.id IS NOT NULL`.as('isRestricted'), ]) + .select(sql`0`.as('depth')) .$if(opts?.includeContent, (qb) => qb.select('pages.content')) .where('pages.id', '=', parentPageId) .where('pages.deletedAt', 'is', null) @@ -582,10 +630,12 @@ export class PageRepo { 'p.workspaceId', sql`page_access.id IS NOT NULL`.as('isRestricted'), ]) + .select(sql`ph.depth + 1`.as('depth')) .$if(opts?.includeContent, (qb) => qb.select('p.content')) .where('p.deletedAt', 'is', null) // 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')