perm share

This commit is contained in:
Philipinho
2026-01-02 01:39:11 +00:00
parent 8eb698648e
commit 8a64c43c71
3 changed files with 208 additions and 2 deletions
@@ -26,6 +26,7 @@ import {
UpdateShareDto, UpdateShareDto,
} from './dto/share.dto'; } from './dto/share.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageAccessService } from '../page-access/page-access.service'; import { PageAccessService } from '../page-access/page-access.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
@@ -42,6 +43,7 @@ export class ShareController {
private readonly spaceAbility: SpaceAbilityFactory, private readonly spaceAbility: SpaceAbilityFactory,
private readonly shareRepo: ShareRepo, private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService, private readonly pageAccessService: PageAccessService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
) {} ) {}
@@ -128,6 +130,20 @@ export class ShareController {
// User must be able to edit the page to create a share // User must be able to edit the page to create a share
await this.pageAccessService.validateCanEdit(page, user); await this.pageAccessService.validateCanEdit(page, user);
// Block includeSubPages if user cannot access all descendants
if (createShareDto.includeSubPages) {
const hasInaccessible =
await this.pagePermissionRepo.hasInaccessibleDescendants(
page.id,
user.id,
);
if (hasInaccessible) {
throw new BadRequestException(
'Cannot share subpages: restricted pages found',
);
}
}
return this.shareService.createShare({ return this.shareService.createShare({
page, page,
authUserId: user.id, authUserId: user.id,
@@ -153,6 +169,20 @@ export class ShareController {
// User must be able to edit the page to update its share // User must be able to edit the page to update its share
await this.pageAccessService.validateCanEdit(page, user); await this.pageAccessService.validateCanEdit(page, user);
// Block includeSubPages if user cannot access all descendants
if (updateShareDto.includeSubPages) {
const hasInaccessible =
await this.pagePermissionRepo.hasInaccessibleDescendants(
page.id,
user.id,
);
if (hasInaccessible) {
throw new BadRequestException(
'Cannot share subpages: restricted pages found',
);
}
}
return this.shareService.updateShare(share.id, updateShareDto); return this.shareService.updateShare(share.id, updateShareDto);
} }
+113 -2
View File
@@ -19,6 +19,7 @@ import {
} from '../../common/helpers/prosemirror/utils'; } from '../../common/helpers/prosemirror/utils';
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 { 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';
@@ -31,6 +32,7 @@ export class ShareService {
constructor( constructor(
private readonly shareRepo: ShareRepo, private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly tokenService: TokenService, private readonly tokenService: TokenService,
) {} ) {}
@@ -42,16 +44,114 @@ export class ShareService {
} }
if (share.includeSubPages) { if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, { const allPages = await this.pageRepo.getPageAndDescendants(share.pageId, {
includeContent: false, includeContent: false,
}); });
return { share, pageTree: pageList }; // Filter out restricted pages and maintain tree integrity
const filteredPages = await this.filterPublicPages(allPages, share.pageId);
return { share, pageTree: filteredPages };
} else { } else {
return { share, pageTree: [] }; return { share, pageTree: [] };
} }
} }
/**
* Filter pages for public share - exclude restricted pages.
* A page is included only if:
* 1. It has no page_access restriction AND
* 2. Its parent is also included (or it's the root)
*/
private async filterPublicPages<
T extends { id: string; parentPageId: string | null },
>(pages: T[], rootPageId: string): Promise<T[]> {
if (pages.length === 0) return [];
// Get all restricted page IDs
const restrictedIds =
await this.pagePermissionRepo.getRestrictedDescendantIds(rootPageId);
const restrictedSet = new Set(restrictedIds);
// Include pages that are NOT restricted and have valid parent chain
const includedIds = new Set<string>();
let changed = true;
while (changed) {
changed = false;
for (const page of pages) {
if (includedIds.has(page.id)) continue;
if (restrictedSet.has(page.id)) continue;
// Root page: include if not restricted
if (page.id === rootPageId) {
includedIds.add(page.id);
changed = true;
continue;
}
// Non-root: include if parent is included
if (page.parentPageId && includedIds.has(page.parentPageId)) {
includedIds.add(page.id);
changed = true;
}
}
}
return pages.filter((p) => includedIds.has(p.id));
}
/**
* Check if a specific page is accessible within a public share.
* A page is accessible if no page in its ancestor chain
* (from the page up to and including the share root) has a page_access restriction.
*/
private async isPagePubliclyAccessible(
pageId: string,
shareRootPageId: string,
): Promise<boolean> {
if (pageId === shareRootPageId) {
const hasRestriction = await this.db
.selectFrom('pageAccess')
.select('id')
.where('pageId', '=', pageId)
.executeTakeFirst();
return !hasRestriction;
}
// Get the depth from share root to the requested page
const shareToPage = await this.db
.selectFrom('pageHierarchy')
.select('depth')
.where('ancestorId', '=', shareRootPageId)
.where('descendantId', '=', pageId)
.executeTakeFirst();
if (!shareToPage) {
return false;
}
// Get all ancestor IDs in the chain from pageId to shareRootPageId
const chainPageIds = await this.db
.selectFrom('pageHierarchy')
.select('ancestorId')
.where('descendantId', '=', pageId)
.where('depth', '<=', shareToPage.depth)
.where('depth', '>', 0)
.execute();
const idsToCheck = [pageId, ...chainPageIds.map((c) => c.ancestorId)];
// Check if any page in the chain has a restriction
const hasRestricted = await this.db
.selectFrom('pageAccess')
.select('pageId')
.where('pageId', 'in', idsToCheck)
.executeTakeFirst();
return !hasRestricted;
}
async createShare(opts: { async createShare(opts: {
authUserId: string; authUserId: string;
workspaceId: string; workspaceId: string;
@@ -103,6 +203,17 @@ export class ShareService {
throw new NotFoundException('Shared page not found'); throw new NotFoundException('Shared page not found');
} }
// For descendant pages, verify the ancestor chain has no restrictions
if (share.level > 0) {
const isAccessible = await this.isPagePubliclyAccessible(
dto.pageId,
share.pageId,
);
if (!isAccessible) {
throw new NotFoundException('Shared page not found');
}
}
const page = await this.pageRepo.findById(dto.pageId, { const page = await this.pageRepo.findById(dto.pageId, {
includeContent: true, includeContent: true,
includeCreator: true, includeCreator: true,
@@ -624,4 +624,69 @@ export class PagePermissionRepo {
return results.map((r) => r.parentPageId); return results.map((r) => r.parentPageId);
} }
/**
* Check if any descendant of a page has restrictions that the user cannot access.
* Used to determine if includeSubPages can be enabled for sharing.
*/
async hasInaccessibleDescendants(
pageId: string,
userId: string,
): Promise<boolean> {
// Get all descendant page IDs (excluding the root page itself)
const descendants = await this.db
.selectFrom('pageHierarchy')
.select('descendantId')
.where('ancestorId', '=', pageId)
.where('depth', '>', 0)
.execute();
if (descendants.length === 0) {
return false;
}
const descendantIds = descendants.map((d) => d.descendantId);
// Check if any descendant has a restriction the user cannot access
const inaccessible = await this.db
.selectFrom('pageAccess')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pageAccess.pageId', 'in', descendantIds)
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !!inaccessible;
}
/**
* Get all descendant page IDs that have restrictions (page_access entries).
* Used to filter restricted pages from public share trees.
*/
async getRestrictedDescendantIds(pageId: string): Promise<string[]> {
const results = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.descendantId')
.select('pageHierarchy.descendantId')
.where('pageHierarchy.ancestorId', '=', pageId)
.execute();
return results.map((r) => r.descendantId);
}
} }