mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
poc via visited ids
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
CacheKey,
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
} from '../../../common/helpers/cache-keys';
|
||||
import { assertAcyclicPageTraversal } from '../../helpers/page-hierarchy-cycle';
|
||||
|
||||
export { PagePermissionMember } from './types/page-permission.types';
|
||||
|
||||
@@ -332,7 +333,7 @@ export class PagePermissionRepo {
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
return this.db
|
||||
const ancestors = await this.db
|
||||
.withRecursive('ancestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
@@ -340,6 +341,8 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
@@ -350,19 +353,41 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`ancestors.depth + 1`.as('depth'),
|
||||
]),
|
||||
sql<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('ancestors')
|
||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select([
|
||||
'pageAccess.id as pageAccessId',
|
||||
'pageAccess.pageId',
|
||||
'pageAccess.accessLevel',
|
||||
'ancestors.depth',
|
||||
'ancestors.isCycle',
|
||||
])
|
||||
.orderBy('ancestors.depth', 'asc')
|
||||
.executeTakeFirst();
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(ancestors, pageId);
|
||||
|
||||
const restrictedAncestor = ancestors.find(
|
||||
(ancestor) => ancestor.pageAccessId !== null,
|
||||
);
|
||||
if (!restrictedAncestor) return undefined;
|
||||
|
||||
return {
|
||||
pageAccessId: restrictedAncestor.pageAccessId,
|
||||
pageId: restrictedAncestor.pageId,
|
||||
accessLevel: restrictedAncestor.accessLevel,
|
||||
depth: restrictedAncestor.depth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,17 +421,30 @@ export class PagePermissionRepo {
|
||||
const result = await sql<{
|
||||
canAccess: boolean | null;
|
||||
canEdit: boolean | null;
|
||||
hasHierarchyCycle: boolean | null;
|
||||
}>`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id AS ancestor_id, parent_page_id, 0 AS depth
|
||||
SELECT
|
||||
id AS ancestor_id,
|
||||
parent_page_id,
|
||||
0 AS depth,
|
||||
ARRAY[id]::uuid[] AS traversal_path,
|
||||
false AS is_cycle
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT p.id, p.parent_page_id, a.depth + 1
|
||||
SELECT
|
||||
p.id,
|
||||
p.parent_page_id,
|
||||
a.depth + 1,
|
||||
a.traversal_path || p.id,
|
||||
p.id = ANY(a.traversal_path) AS is_cycle
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
WHERE NOT a.is_cycle
|
||||
)
|
||||
SELECT
|
||||
(SELECT bool_or(is_cycle) FROM ancestors) AS "hasHierarchyCycle",
|
||||
bool_and(pp.id IS NOT NULL) AS "canAccess",
|
||||
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
|
||||
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
|
||||
@@ -422,6 +460,13 @@ export class PagePermissionRepo {
|
||||
`.execute(this.db);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (row?.hasHierarchyCycle) {
|
||||
return {
|
||||
hasAnyRestriction: true,
|
||||
canAccess: false,
|
||||
canEdit: false,
|
||||
};
|
||||
}
|
||||
if (!row || row.canAccess === null) {
|
||||
return { hasAnyRestriction: false, canAccess: true, canEdit: true };
|
||||
}
|
||||
@@ -461,6 +506,8 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
@@ -471,7 +518,14 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`ancestors.depth + 1`.as('depth'),
|
||||
]),
|
||||
sql<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
@@ -511,6 +565,14 @@ export class PagePermissionRepo {
|
||||
.else(false)
|
||||
.end()
|
||||
.as('hasInheritedRestriction'),
|
||||
eb
|
||||
.exists(
|
||||
eb
|
||||
.selectFrom('ancestors')
|
||||
.select('ancestors.ancestorId')
|
||||
.where('ancestors.isCycle', '=', true),
|
||||
)
|
||||
.as('hasHierarchyCycle'),
|
||||
// canAccess: no restricted ancestor without ANY permission
|
||||
eb
|
||||
.case()
|
||||
@@ -638,13 +700,15 @@ export class PagePermissionRepo {
|
||||
|
||||
const hasDirectRestriction = Boolean(result?.hasDirectRestriction);
|
||||
const hasInheritedRestriction = Boolean(result?.hasInheritedRestriction);
|
||||
const hasHierarchyCycle = Boolean(result?.hasHierarchyCycle);
|
||||
|
||||
return {
|
||||
hasDirectRestriction,
|
||||
hasInheritedRestriction,
|
||||
hasAnyRestriction: hasDirectRestriction || hasInheritedRestriction,
|
||||
canAccess: Boolean(result?.canAccess),
|
||||
canEdit: Boolean(result?.canEdit),
|
||||
hasAnyRestriction:
|
||||
hasDirectRestriction || hasInheritedRestriction || hasHierarchyCycle,
|
||||
canAccess: !hasHierarchyCycle && Boolean(result?.canAccess),
|
||||
canEdit: !hasHierarchyCycle && Boolean(result?.canEdit),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -676,6 +740,8 @@ export class PagePermissionRepo {
|
||||
'pages.id as pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.unionAll((eb) =>
|
||||
@@ -690,12 +756,29 @@ export class PagePermissionRepo {
|
||||
'allAncestors.pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
]),
|
||||
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('allAncestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
.select('pages.id')
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('allAncestors')
|
||||
.select('allAncestors.ancestorId')
|
||||
.whereRef('allAncestors.pageId', '=', 'pages.id')
|
||||
.where('allAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
@@ -745,6 +828,8 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.unionAll((eb) =>
|
||||
@@ -760,7 +845,14 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`all_ancestors.depth + 1`.as('depth'),
|
||||
]),
|
||||
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('allAncestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
@@ -821,6 +913,16 @@ export class PagePermissionRepo {
|
||||
.as('canEdit'),
|
||||
)
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('allAncestors')
|
||||
.select('allAncestors.ancestorId')
|
||||
.whereRef('allAncestors.pageId', '=', 'pages.id')
|
||||
.where('allAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
// view filter: no restricted ancestor without any permission
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
@@ -865,21 +967,39 @@ export class PagePermissionRepo {
|
||||
.withRecursive('ancestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select(['pages.id as ancestorId', 'pages.parentPageId'])
|
||||
.select([
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.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<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('ancestors')
|
||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select('pageAccess.id')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select([
|
||||
sql<boolean>`bool_or(ancestors.is_cycle)`.as('hasHierarchyCycle'),
|
||||
sql<boolean>`bool_or(page_access.id IS NOT NULL)`.as('hasPageAccess'),
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
return !!result;
|
||||
return Boolean(result?.hasHierarchyCycle || result?.hasPageAccess);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -921,6 +1041,8 @@ export class PagePermissionRepo {
|
||||
'child.id as childId',
|
||||
'child.id as ancestorId',
|
||||
'child.parentPageId as ancestorParentId',
|
||||
sql<string[]>`ARRAY[child.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('child.parentPageId', 'in', parentIds)
|
||||
.where('child.deletedAt', 'is', null)
|
||||
@@ -936,7 +1058,14 @@ export class PagePermissionRepo {
|
||||
'childAncestors.childId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
]),
|
||||
sql<string[]>`child_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(child_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('childAncestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages as child')
|
||||
@@ -944,6 +1073,16 @@ export class PagePermissionRepo {
|
||||
.distinct()
|
||||
.where('child.parentPageId', 'in', parentIds)
|
||||
.where('child.deletedAt', 'is', null)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('childAncestors')
|
||||
.select('childAncestors.ancestorId')
|
||||
.whereRef('childAncestors.childId', '=', 'child.id')
|
||||
.where('childAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
@@ -978,67 +1117,6 @@ export class PagePermissionRepo {
|
||||
return results.map((r) => r.parentPageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all page IDs within a subtree that are restricted OR are descendants of restricted pages.
|
||||
* Used to filter pages from public shares - if a page is restricted, it and all its
|
||||
* children should be hidden.
|
||||
*/
|
||||
async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> {
|
||||
const results = await this.db
|
||||
.withRecursive('descendants', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select(['pages.id as descendantId', 'pages.parentPageId'])
|
||||
.where('pages.id', '=', rootPageId)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin(
|
||||
'descendants',
|
||||
'descendants.descendantId',
|
||||
'pages.parentPageId',
|
||||
)
|
||||
.select(['pages.id as descendantId', 'pages.parentPageId'])
|
||||
.where('pages.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.withRecursive('descendantAncestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('descendants')
|
||||
.innerJoin('pages', 'pages.id', 'descendants.descendantId')
|
||||
.select([
|
||||
'descendants.descendantId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
])
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin(
|
||||
'descendantAncestors',
|
||||
'descendantAncestors.ancestorParentId',
|
||||
'pages.id',
|
||||
)
|
||||
.select([
|
||||
'descendantAncestors.descendantId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('descendantAncestors')
|
||||
.innerJoin(
|
||||
'pageAccess',
|
||||
'pageAccess.pageId',
|
||||
'descendantAncestors.ancestorId',
|
||||
)
|
||||
.select('descendantAncestors.descendantId')
|
||||
.distinct()
|
||||
.execute();
|
||||
|
||||
return results.map((r) => r.descendantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a pageId and a set of candidate userIds, return the subset who can
|
||||
* access the page (have permission on ALL restricted ancestors).
|
||||
@@ -1052,17 +1130,27 @@ 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,
|
||||
ARRAY[id]::uuid[] AS traversal_path,
|
||||
false AS is_cycle
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT p.id, p.parent_page_id
|
||||
SELECT
|
||||
p.id,
|
||||
p.parent_page_id,
|
||||
a.traversal_path || p.id,
|
||||
p.id = ANY(a.traversal_path) AS is_cycle
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
WHERE NOT a.is_cycle
|
||||
)
|
||||
SELECT cu.user_id AS "userId"
|
||||
FROM unnest(${userIds}::uuid[]) AS cu(user_id)
|
||||
WHERE NOT EXISTS (
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ancestors WHERE is_cycle)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ancestors a
|
||||
JOIN page_access pa ON pa.page_id = a.ancestor_id
|
||||
|
||||
@@ -16,6 +16,10 @@ 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 {
|
||||
assertAcyclicPageTraversal,
|
||||
stripPageTraversalMetadata,
|
||||
} from '../../helpers/page-hierarchy-cycle';
|
||||
|
||||
@Injectable()
|
||||
export class PageRepo {
|
||||
@@ -203,21 +207,31 @@ export class PageRepo {
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id'])
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('id', '=', pageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select(['p.id'])
|
||||
.select([
|
||||
'p.id',
|
||||
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where('p.deletedAt', 'is', null),
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('pd.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.selectAll()
|
||||
.select(['id', 'isCycle'])
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(descendants, pageId);
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
|
||||
if (pageIds.length > 0) {
|
||||
@@ -272,19 +286,29 @@ export class PageRepo {
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id'])
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.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<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where('pd.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.selectAll()
|
||||
.select(['id', 'isCycle'])
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, pageId);
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
|
||||
// Restore all pages, but only detach the root page if its parent is deleted
|
||||
@@ -354,7 +378,12 @@ export class PageRepo {
|
||||
});
|
||||
}
|
||||
|
||||
async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) {
|
||||
async getCreatedByPages(
|
||||
creatorId: string,
|
||||
requestingUserId: string,
|
||||
pagination: PaginationOptions,
|
||||
spaceId?: string,
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
@@ -365,7 +394,11 @@ export class PageRepo {
|
||||
if (spaceId) {
|
||||
query = query.where('spaceId', '=', spaceId);
|
||||
} else {
|
||||
query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId));
|
||||
query = query.where(
|
||||
'spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId),
|
||||
);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
@@ -491,7 +524,7 @@ export class PageRepo {
|
||||
parentPageId: string,
|
||||
opts: { includeContent: boolean },
|
||||
) {
|
||||
return this.db
|
||||
const pages = await this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
@@ -506,6 +539,8 @@ export class PageRepo {
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.where('id', '=', parentPageId)
|
||||
@@ -524,15 +559,34 @@ export class PageRepo {
|
||||
'p.workspaceId',
|
||||
'p.createdAt',
|
||||
'p.updatedAt',
|
||||
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.$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.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.selectAll()
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'isCycle',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, parentPageId);
|
||||
return pages.map((page) => stripPageTraversalMetadata(page));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,69 +594,82 @@ export class PageRepo {
|
||||
* More efficient than getPageAndDescendants + filtering because:
|
||||
* 1. Single DB query (no separate restricted IDs query)
|
||||
* 2. Stops traversing at restricted pages (doesn't fetch data to discard)
|
||||
* 3. No in-memory filtering needed
|
||||
* 3. Filters the bounded traversal only after hierarchy validation
|
||||
*/
|
||||
async getPageAndDescendantsExcludingRestricted(
|
||||
parentPageId: string,
|
||||
opts: { includeContent: boolean },
|
||||
) {
|
||||
return (
|
||||
this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.position',
|
||||
'pages.parentPageId',
|
||||
'pages.spaceId',
|
||||
'pages.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
|
||||
.where('pages.id', '=', parentPageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.$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),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
// Filter out restricted pages from the result
|
||||
.where('isRestricted', '=', false)
|
||||
.execute()
|
||||
);
|
||||
const pages = await this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.position',
|
||||
'pages.parentPageId',
|
||||
'pages.spaceId',
|
||||
'pages.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
|
||||
.where('pages.id', '=', parentPageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.$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.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'isRestricted',
|
||||
'isCycle',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, parentPageId);
|
||||
return pages
|
||||
.filter((page) => !page.isRestricted)
|
||||
.map((page) => {
|
||||
const withoutCycleMetadata = stripPageTraversalMetadata(page);
|
||||
const { isRestricted: _isRestricted, ...publicPage } =
|
||||
withoutCycleMetadata;
|
||||
return publicPage;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user