This commit is contained in:
Philipinho
2025-12-24 00:27:25 +00:00
parent f65726ae26
commit c2e722ee5c
9 changed files with 229 additions and 86 deletions
@@ -9,6 +9,7 @@ import { TokenService } from '../../core/auth/services/token.service';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission';
import { getPageId } from '../collaboration.util';
@@ -23,6 +24,7 @@ export class AuthenticationExtension implements Extension {
private userRepo: UserRepo,
private pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async onAuthenticate(data: onAuthenticatePayload) {
@@ -73,6 +75,33 @@ export class AuthenticationExtension implements Extension {
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
// Check page-level permissions (in addition to space permissions)
const canAccessPage = await this.pagePermissionRepo.canUserAccessPage(
user.id,
page.id,
);
if (!canAccessPage) {
this.logger.warn(
`User ${user.id} denied page-level access to page: ${pageId}`,
);
throw new UnauthorizedException();
}
// Check if user can edit (has writer role on all restricted ancestors)
const canEditPage = await this.pagePermissionRepo.canUserEditPage(
user.id,
page.id,
);
// If user has space edit permission but lacks page-level write permission, force readonly
if (!canEditPage && !data.connection.readOnly) {
data.connection.readOnly = true;
this.logger.debug(
`User ${user.id} granted readonly access to restricted page: ${pageId}`,
);
}
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
return {
@@ -53,6 +53,7 @@ import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import { RemoveIconDto } from './dto/attachment.dto';
import { PagePermissionService } from '../page/services/page-permission.service';
@Controller()
export class AttachmentController {
@@ -67,6 +68,7 @@ export class AttachmentController {
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
private readonly pagePermissionService: PagePermissionService,
) {}
@UseGuards(JwtAuthGuard)
@@ -111,13 +113,8 @@ export class AttachmentController {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Checks both space-level and page-level edit permissions
await this.pagePermissionService.validateCanEdit(page, user);
const spaceId = page.spaceId;
@@ -171,15 +168,14 @@ export class AttachmentController {
throw new NotFoundException();
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException();
}
// Checks both space-level and page-level view permissions
await this.pagePermissionService.validateCanView(page, user);
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
@@ -6,9 +6,10 @@ import { UserModule } from '../user/user.module';
import { WorkspaceModule } from '../workspace/workspace.module';
import { AttachmentProcessor } from './processors/attachment.processor';
import { TokenModule } from '../auth/token.module';
import { PageModule } from '../page/page.module';
@Module({
imports: [StorageModule, UserModule, WorkspaceModule, TokenModule],
imports: [StorageModule, UserModule, WorkspaceModule, TokenModule, PageModule],
controllers: [AttachmentController],
providers: [AttachmentService, AttachmentProcessor],
})
@@ -24,6 +24,7 @@ import {
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { PagePermissionService } from '../page/services/page-permission.service';
@UseGuards(JwtAuthGuard)
@Controller('comments')
@@ -33,6 +34,7 @@ export class CommentController {
private readonly commentRepo: CommentRepo,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pagePermissionService: PagePermissionService,
) {}
@HttpCode(HttpStatus.OK)
@@ -52,6 +54,9 @@ export class CommentController {
throw new ForbiddenException();
}
// Check page-level edit permission (comments require edit access)
await this.pagePermissionService.validateCanEdit(page, user);
return this.commentService.create(
{
userId: user.id,
@@ -75,10 +80,11 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
//
// Checks both space-level and page-level permissions
await this.pagePermissionService.validateCanView(page, user);
return this.commentService.findByPageId(page.id, pagination);
}
@@ -90,13 +96,14 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// Checks both space-level and page-level permissions
await this.pagePermissionService.validateCanView(page, user);
return comment;
}
@@ -108,18 +115,14 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException(
'You must have space edit permission to edit comments',
);
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// Checks both space-level and page-level edit permissions
await this.pagePermissionService.validateCanEdit(page, user);
return this.commentService.update(comment, dto, user);
}
@@ -131,37 +134,23 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// Check page-level edit permission first
await this.pagePermissionService.validateCanEdit(page, user);
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Check if user is the comment owner
const isOwner = comment.creatorId === user.id;
if (isOwner) {
/*
// Check if comment has children from other users
const hasChildrenFromOthers =
await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
// Owner can delete if no children from other users
if (!hasChildrenFromOthers) {
await this.commentRepo.deleteComment(comment.id);
return;
}
// If has children from others, only space admin can delete
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'Only space admins can delete comments with replies from other users',
);
}*/
await this.commentRepo.deleteComment(comment.id);
return;
}
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { CommentService } from './comment.service';
import { CommentController } from './comment.controller';
import { PageModule } from '../page/page.module';
@Module({
imports: [],
imports: [PageModule],
controllers: [CommentController],
providers: [CommentService],
exports: [CommentService],
+52 -24
View File
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { PageService } from './services/page.service';
import { PagePermissionService } from './services/page-permission.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
@@ -44,6 +45,7 @@ export class PageController {
private readonly pageRepo: PageRepo,
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pagePermissionService: PagePermissionService,
) {}
@HttpCode(HttpStatus.OK)
@@ -61,10 +63,8 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Checks both space-level and page-level permissions
await this.pagePermissionService.validateCanView(page, user);
return page;
}
@@ -84,6 +84,14 @@ export class PageController {
throw new ForbiddenException();
}
// If creating under a parent page, check page-level edit permission
if (createPageDto.parentPageId) {
const parentPage = await this.pageRepo.findById(createPageDto.parentPageId);
if (parentPage) {
await this.pagePermissionService.validateCanEdit(parentPage, user);
}
}
return this.pageService.create(user.id, workspace.id, createPageDto);
}
@@ -96,10 +104,8 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Checks both space-level and page-level permissions
await this.pagePermissionService.validateCanEdit(page, user);
return this.pageService.update(page, updatePageDto, user.id);
}
@@ -128,10 +134,13 @@ export class PageController {
}
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
} else {
// Soft delete requires page manage permissions
// Soft delete requires page manage permissions at space level
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Also check page-level edit permission
await this.pagePermissionService.validateCanEdit(page, user);
await this.pageService.removePage(
deletePageDto.pageId,
user.id,
@@ -158,6 +167,9 @@ export class PageController {
throw new ForbiddenException();
}
// Check page-level edit permission (if restoring to a restricted ancestor)
await this.pagePermissionService.validateCanEdit(page, user);
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
return this.pageRepo.findById(pageIdDto.pageId, {
@@ -228,10 +240,8 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Checks both space-level and page-level permissions
await this.pagePermissionService.validateCanView(page, user);
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
}
@@ -247,13 +257,15 @@ export class PageController {
throw new NotFoundException('Page history not found');
}
const ability = await this.spaceAbility.createForUser(
user,
history.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
// Get the page to check permissions
const page = await this.pageRepo.findById(history.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// Checks both space-level and page-level permissions
await this.pagePermissionService.validateCanView(page, user);
return history;
}
@@ -285,7 +297,7 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId, user.id);
}
@HttpCode(HttpStatus.OK)
@@ -315,6 +327,9 @@ export class PageController {
throw new ForbiddenException();
}
// Check page-level edit permission on the source page
await this.pagePermissionService.validateCanEdit(movedPage, user);
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
}
@@ -326,6 +341,9 @@ export class PageController {
throw new NotFoundException('Page to copy not found');
}
// Check page-level view permission on the source page (need to read to copy)
await this.pagePermissionService.validateCanView(copiedPage, user);
// If spaceId is provided, it's a copy to different space
if (dto.spaceId) {
const abilities = await Promise.all([
@@ -372,6 +390,17 @@ export class PageController {
throw new ForbiddenException();
}
// Check page-level edit permission
await this.pagePermissionService.validateCanEdit(movedPage, user);
// If moving to a new parent, check permission on the target parent
if (dto.parentPageId && dto.parentPageId !== movedPage.parentPageId) {
const targetParent = await this.pageRepo.findById(dto.parentPageId);
if (targetParent) {
await this.pagePermissionService.validateCanEdit(targetParent, user);
}
}
return this.pageService.movePage(dto, movedPage);
}
@@ -383,10 +412,9 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Checks both space-level and page-level permissions
await this.pagePermissionService.validateCanView(page, user);
return this.pageService.getPageBreadCrumbs(page.id);
}
}
@@ -7,6 +7,7 @@ import {
import { CreatePageDto } from '../dto/create-page.dto';
import { UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
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';
import {
@@ -47,6 +48,7 @@ export class PageService {
constructor(
private pageRepo: PageRepo,
private pagePermissionRepo: PagePermissionRepo,
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
@@ -180,6 +182,7 @@ export class PageService {
spaceId: string,
pagination: PaginationOptions,
pageId?: string,
userId?: string,
): Promise<any> {
let query = this.db
.selectFrom('pages')
@@ -194,7 +197,12 @@ export class PageService {
'creatorId',
'deletedAt',
])
.select((eb) => this.pageRepo.withHasChildren(eb))
.$if(Boolean(userId), (qb) =>
qb.select((eb) => this.pageRepo.withHasChildrenV2(eb, userId)),
)
//.$if(!userId, (qb) =>
// qb.select((eb) => this.pageRepo.withHasChildren(eb)),
// )
.orderBy('position', (ob) => ob.collate('C').asc())
.where('deletedAt', 'is', null)
.where('spaceId', '=', spaceId);
@@ -205,11 +213,22 @@ export class PageService {
query = query.where('parentPageId', 'is', null);
}
const result = executeWithPagination(query, {
const result = await executeWithPagination(query, {
page: pagination.page,
perPage: 250,
});
// Filter by page-level permissions
if (userId && result.items.length > 0) {
const pageIds = result.items.map((p: any) => p.id);
const accessiblePageIds = await this.pagePermissionRepo.filterAccessiblePageIds(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePageIds);
result.items = result.items.filter((p: any) => accessibleSet.has(p.id));
}
return result;
}
+26 -2
View File
@@ -7,6 +7,7 @@ import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@@ -18,6 +19,7 @@ export class SearchService {
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
private pagePermissionRepo: PagePermissionRepo,
) {}
async searchPage(
@@ -118,10 +120,21 @@ export class SearchService {
}
//@ts-ignore
queryResults = await queryResults.execute();
let results: any[] = await queryResults.execute();
// Filter results by page-level permissions (if user is authenticated)
if (opts.userId && results.length > 0) {
const pageIds = results.map((r: any) => r.id);
const accessiblePageIds = await this.pagePermissionRepo.filterAccessiblePageIds(
pageIds,
opts.userId,
);
const accessibleSet = new Set(accessiblePageIds);
results = results.filter((r: any) => accessibleSet.has(r.id));
}
//@ts-ignore
const searchResults = queryResults.map((result: SearchResponseDto) => {
const searchResults = results.map((result: SearchResponseDto) => {
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
@@ -210,6 +223,17 @@ export class SearchService {
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
pages = await pageSearch.execute();
}
// Filter by page-level permissions
if (pages.length > 0) {
const pageIds = pages.map((p) => p.id);
const accessiblePageIds = await this.pagePermissionRepo.filterAccessiblePageIds(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePageIds);
pages = pages.filter((p) => accessibleSet.has(p.id));
}
}
return { users, groups, pages };
@@ -411,6 +411,62 @@ export class PageRepo {
.as('hasChildren');
}
/**
* Permission-aware version of withHasChildren.
* Returns true only if there are children the user can access.
* Uses page_hierarchy closure table to check all restricted ancestors.
*/
withHasChildrenV2(eb: ExpressionBuilder<DB, 'pages'>, userId: string) {
return eb
.selectFrom('pages as child')
.select((eb) =>
eb
.case()
.when(eb.fn.countAll(), '>', 0)
.then(true)
.else(false)
.end()
.as('count'),
)
.whereRef('child.parentPageId', '=', 'pages.id')
.where('child.deletedAt', 'is', null)
// Only count children that the user can access
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.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')
.whereRef('pageHierarchy.descendantId', '=', 'child.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.limit(1)
.as('hasChildren');
}
async getPageAndDescendants(
parentPageId: string,
opts: { includeContent: boolean },