mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
perm share
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user