feat(ee): page-level access/permissions (#1971)

* Add page_hierarchy table

* feat(ee): page-level permissions

* pagination

* rename migration
fixes

* fix

* tabs

* fix theme

* cleanup

* sync

* page permissions notification
* other fixes

* sharing disbled

* fix column nodes

* toggle error handling
This commit is contained in:
Philip Okugbe
2026-02-26 19:49:10 +00:00
committed by GitHub
parent 22f33bab7c
commit 59e945562d
75 changed files with 4235 additions and 363 deletions
+15
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EnvironmentService } from './integrations/environment/environment.service';
import { CoreModule } from './core/core.module';
import { EnvironmentModule } from './integrations/environment/environment.module';
import { CollaborationModule } from './collaboration/collaboration.module';
@@ -18,6 +19,8 @@ import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
const enterpriseModules = [];
@@ -43,6 +46,18 @@ try {
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
CacheModule.registerAsync({
isGlobal: true,
useFactory: async (environmentService: EnvironmentService) => {
const redisUrl = environmentService.getRedisUrl();
return {
ttl: 5 * 1000,
stores: [new KeyvRedis(redisUrl)],
};
},
inject: [EnvironmentService],
}),
CollaborationModule,
WsModule,
QueueModule,
@@ -33,10 +33,10 @@ import {
Subpages,
Highlight,
UniqueID,
addUniqueIdsToDoc,
htmlToMarkdown,
Columns,
Column,
addUniqueIdsToDoc,
htmlToMarkdown,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -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) {
@@ -52,7 +54,7 @@ export class AuthenticationExtension implements Extension {
const page = await this.pageRepo.findById(pageId);
if (!page) {
this.logger.warn(`Page not found: ${pageId}`);
this.logger.debug(`Page not found: ${pageId}`);
throw new NotFoundException('Page not found');
}
@@ -68,9 +70,34 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException();
}
if (userSpaceRole === SpaceRole.READER) {
// Check page-level permissions
const { hasAnyRestriction, canAccess, canEdit } =
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
if (hasAnyRestriction) {
if (!canAccess) {
this.logger.warn(
`User ${user.id} denied page-level access to page: ${pageId}`,
);
throw new UnauthorizedException();
}
if (!canEdit) {
data.connectionConfig.readOnly = true;
this.logger.debug(
`User ${user.id} granted readonly access to restricted page: ${pageId}`,
);
}
} else {
// No restrictions - use space-level permissions
if (userSpaceRole === SpaceRole.READER) {
data.connectionConfig.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
}
if (page.deletedAt) {
data.connectionConfig.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
@@ -14,3 +14,12 @@ export enum SpaceVisibility {
OPEN = 'open', // any workspace member can see that it exists and join.
PRIVATE = 'private', // only added space users can see
}
export enum PageAccessLevel {
RESTRICTED = 'restricted', // only specific users/groups can view or edit
}
export enum PagePermissionRole {
READER = 'reader', // can only view content and descendants
WRITER = 'writer', // can edit content, descendants, and add new users to permission
}
@@ -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 { PageAccessService } from '../page/page-access/page-access.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 pageAccessService: PageAccessService,
) {}
@UseGuards(JwtAuthGuard)
@@ -111,13 +113,7 @@ 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();
}
await this.pageAccessService.validateCanEdit(page, user);
const spaceId = page.spaceId;
@@ -172,15 +168,13 @@ 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();
}
await this.pageAccessService.validateCanView(page, user);
try {
return await this.sendFileResponse(req, res, attachment, 'private');
} catch (err) {
@@ -24,6 +24,7 @@ import {
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { PageAccessService } from '../page/page-access/page-access.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 pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@@ -47,10 +49,7 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanEdit(page, user);
return this.commentService.create(
{
@@ -75,10 +74,8 @@ 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();
}
await this.pageAccessService.validateCanView(page, user);
return this.commentService.findByPageId(page.id, pagination);
}
@@ -90,13 +87,13 @@ 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');
}
await this.pageAccessService.validateCanView(page, user);
return comment;
}
@@ -108,18 +105,13 @@ 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');
}
await this.pageAccessService.validateCanEdit(page, user);
return this.commentService.update(comment, dto, user);
}
@@ -131,41 +123,27 @@ 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();
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// Check page-level edit permission first
await this.pageAccessService.validateCanEdit(page, user);
// 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;
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
+2
View File
@@ -14,6 +14,7 @@ import { SearchModule } from './search/search.module';
import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
import { PageAccessModule } from './page/page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
@@ -31,6 +32,7 @@ import { WatcherModule } from './watcher/watcher.module';
SpaceModule,
GroupModule,
CaslModule,
PageAccessModule,
ShareModule,
NotificationModule,
WatcherModule,
@@ -3,6 +3,7 @@ export const NotificationType = {
COMMENT_CREATED: 'comment.created',
COMMENT_RESOLVED: 'comment.resolved',
PAGE_USER_MENTION: 'page.user_mention',
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
} as const;
export type NotificationType =
@@ -4,10 +4,9 @@ import { NotificationController } from './notification.controller';
import { NotificationProcessor } from './notification.processor';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
import { WsModule } from '../../ws/ws.module';
@Module({
imports: [WsModule],
imports: [],
controllers: [NotificationController],
providers: [
NotificationService,
@@ -8,6 +8,7 @@ import {
ICommentNotificationJob,
ICommentResolvedNotificationJob,
IPageMentionNotificationJob,
IPermissionGrantedNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
@@ -33,7 +34,8 @@ export class NotificationProcessor
job: Job<
| ICommentNotificationJob
| ICommentResolvedNotificationJob
| IPageMentionNotificationJob,
| IPageMentionNotificationJob
| IPermissionGrantedNotificationJob,
void
>,
): Promise<void> {
@@ -66,6 +68,14 @@ export class NotificationProcessor
break;
}
case QueueJob.PAGE_PERMISSION_GRANTED: {
await this.pageNotificationService.processPermissionGranted(
job.data as IPermissionGrantedNotificationJob,
appUrl,
);
break;
}
default:
this.logger.warn(`Unknown notification job: ${job.name}`);
}
@@ -8,6 +8,7 @@ import {
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { CommentMentionEmail } from '@docmost/transactional/emails/comment-mention-email';
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
@@ -22,6 +23,7 @@ export class CommentNotificationService {
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
) {}
@@ -59,12 +61,19 @@ export class CommentNotificationService {
const allCandidateIds = [
...new Set([...mentionedUserIds, ...recipientIds]),
];
const usersWithAccess =
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
allCandidateIds,
spaceId,
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[...usersWithSpaceAccess],
);
const usersWithAccess = new Set(usersWithPageAccess);
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
@@ -146,6 +155,13 @@ export class CommentNotificationService {
return;
}
const hasPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[commentCreatorId],
);
if (hasPageAccess.length === 0) return;
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
@@ -1,11 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { IPageMentionNotificationJob } from '../../../integrations/queue/constants/queue.interface';
import {
IPageMentionNotificationJob,
IPermissionGrantedNotificationJob,
} from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
import { getPageTitle } from '../../../common/helpers';
@Injectable()
@@ -14,6 +19,7 @@ export class PageNotificationService {
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
@@ -28,12 +34,19 @@ export class PageNotificationService {
if (newMentions.length === 0) return;
const candidateUserIds = newMentions.map((m) => m.userId);
const usersWithAccess =
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
candidateUserIds,
spaceId,
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[...usersWithSpaceAccess],
);
const usersWithAccess = new Set(usersWithPageAccess);
const accessibleMentions = newMentions.filter((m) =>
usersWithAccess.has(m.userId),
);
@@ -97,6 +110,52 @@ export class PageNotificationService {
}
}
async processPermissionGranted(
data: IPermissionGrantedNotificationJob,
appUrl: string,
) {
const { userIds, pageId, spaceId, workspaceId, actorId, role } = data;
if (userIds.length === 0) return;
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(userIds, spaceId);
if (usersWithSpaceAccess.size === 0) return;
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
if (!context) return;
const { actor, pageTitle, basePageUrl } = context;
const accessLabel = role === 'writer' ? 'edit' : 'view';
for (const userId of usersWithSpaceAccess) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_PERMISSION_GRANTED,
actorId,
pageId,
spaceId,
data: { role },
});
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
PermissionGrantedEmail({
actorName: actor.name,
pageTitle,
pageUrl: basePageUrl,
accessLabel,
}),
);
}
}
private async getPageContext(
actorId: string,
pageId: string,
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PageAccessService } from './page-access.service';
@Global()
@Module({
providers: [PageAccessService],
exports: [PageAccessService],
})
export class PageAccessModule {}
@@ -0,0 +1,102 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Page, User } from '@docmost/db/types/entity.types';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
@Injectable()
export class PageAccessService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
/**
* Validate user can view page, throws ForbiddenException if not.
* If page has restrictions: page-level permission determines access.
* If no restrictions: space-level permission determines access.
*/
async validateCanView(page: Page, user: User): Promise<void> {
// TODO: cache by pageId and userId.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const canAccess = await this.pagePermissionRepo.canUserAccessPage(
user.id,
page.id,
);
if (!canAccess) {
throw new ForbiddenException();
}
}
/**
* Validate user can view page AND return effective canEdit permission.
* Combines access check + edit permission in a single query pass.
*/
async validateCanViewWithPermissions(
page: Page,
user: User,
): Promise<{ canEdit: boolean; hasRestriction: boolean }> {
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasAnyRestriction, canAccess, canEdit } =
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
if (hasAnyRestriction && !canAccess) {
throw new ForbiddenException();
}
return {
canEdit: hasAnyRestriction
? canEdit
: ability.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
hasRestriction: hasAnyRestriction,
};
}
/**
* Validate user can edit page, throws ForbiddenException if not.
* If page has restrictions: page-level writer permission determines access.
* If no restrictions: space-level edit permission determines access.
*/
async validateCanEdit(
page: Page,
user: User,
): Promise<{ hasRestriction: boolean }> {
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasAnyRestriction, canEdit } =
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
if (hasAnyRestriction) {
// Page has restrictions - use page-level permission
if (!canEdit) {
throw new ForbiddenException();
}
} else {
// No restrictions - use space-level permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
return { hasRestriction: hasAnyRestriction };
}
}
+99 -39
View File
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { PageService } from './services/page.service';
import { PageAccessService } from './page-access/page-access.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
@@ -48,6 +49,7 @@ export class PageController {
private readonly pageRepo: PageRepo,
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@@ -65,10 +67,10 @@ 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();
}
const { canEdit, hasRestriction } =
await this.pageAccessService.validateCanViewWithPermissions(page, user);
const permissions = { canEdit, hasRestriction };
if (dto.format && dto.format !== 'json' && page.content) {
const contentOutput =
@@ -78,10 +80,11 @@ export class PageController {
return {
...page,
content: contentOutput,
permissions,
};
}
return page;
return { ...page, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -91,12 +94,28 @@ export class PageController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
createPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
if (createPageDto.parentPageId) {
// Creating under a parent page - check edit permission on parent
const parentPage = await this.pageRepo.findById(
createPageDto.parentPageId,
);
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== createPageDto.spaceId
) {
throw new NotFoundException('Parent page not found');
}
await this.pageAccessService.validateCanEdit(parentPage, user);
} else {
// Creating at root level - require space-level permission
const ability = await this.spaceAbility.createForUser(
user,
createPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
const page = await this.pageService.create(
@@ -105,6 +124,11 @@ export class PageController {
createPageDto,
);
const { canEdit, hasRestriction } =
await this.pageAccessService.validateCanViewWithPermissions(page, user);
const permissions = { canEdit, hasRestriction };
if (
createPageDto.format &&
createPageDto.format !== 'json' &&
@@ -114,10 +138,10 @@ export class PageController {
createPageDto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return { ...page, content: contentOutput };
return { ...page, content: contentOutput, permissions };
}
return page;
return { ...page, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -129,10 +153,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();
}
const { hasRestriction } =
await this.pageAccessService.validateCanEdit(page, user);
const updatedPage = await this.pageService.update(
page,
@@ -140,6 +162,8 @@ export class PageController {
user,
);
const permissions = { canEdit: true, hasRestriction };
if (
updatePageDto.format &&
updatePageDto.format !== 'json' &&
@@ -149,10 +173,10 @@ export class PageController {
updatePageDto.format === 'markdown'
? jsonToMarkdown(updatedPage.content)
: jsonToHtml(updatedPage.content);
return { ...updatedPage, content: contentOutput };
return { ...updatedPage, content: contentOutput, permissions };
}
return updatedPage;
return { ...updatedPage, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -179,10 +203,9 @@ export class PageController {
}
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
} else {
// Soft delete requires page manage permissions
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// User with edit permission can delete
await this.pageAccessService.validateCanEdit(page, user);
await this.pageService.removePage(
deletePageDto.pageId,
user.id,
@@ -204,11 +227,18 @@ export class PageController {
throw new NotFoundException('Page not found');
}
//Todo: currently, this means if they are not admins, they need to add a space admin to the page, which is not possible as it was soft-deleted
// so page is virtually lost. Fix.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
//TODO: can users with page level edit, but no space level edit restore pages they can edit?
// Check page-level edit permission (if restoring to a restricted ancestor)
await this.pageAccessService.validateCanEdit(page, user);
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
return this.pageRepo.findById(pageIdDto.pageId, {
@@ -235,6 +265,7 @@ export class PageController {
return this.pageService.getRecentSpacePages(
recentPageDto.spaceId,
user.id,
pagination,
);
}
@@ -261,6 +292,7 @@ export class PageController {
return this.pageService.getDeletedSpacePages(
deletedPageDto.spaceId,
user.id,
pagination,
);
}
@@ -278,10 +310,7 @@ 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();
}
await this.pageAccessService.validateCanView(page, user);
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
}
@@ -297,13 +326,14 @@ 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');
}
await this.pageAccessService.validateCanView(page, user);
return history;
}
@@ -335,7 +365,18 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
const spaceCanEdit = ability.can(
SpaceCaslAction.Edit,
SpaceCaslSubject.Page,
);
return this.pageService.getSidebarPages(
spaceId,
pagination,
dto.pageId,
user.id,
spaceCanEdit,
);
}
@HttpCode(HttpStatus.OK)
@@ -365,7 +406,11 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
// Check page-level edit permission on the source page
await this.pageAccessService.validateCanEdit(movedPage, user);
// Moves only accessible pages; inaccessible child pages become root pages in original space
return this.pageService.movePageToSpace(movedPage, dto.spaceId, user.id);
}
@HttpCode(HttpStatus.OK)
@@ -376,6 +421,10 @@ 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)
// Inaccessible child branches are automatically skipped during duplication
await this.pageAccessService.validateCanView(copiedPage, user);
// If spaceId is provided, it's a copy to different space
if (dto.spaceId) {
const abilities = await Promise.all([
@@ -418,10 +467,23 @@ export class PageController {
user,
movedPage.spaceId,
);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Check page-level edit permission
await this.pageAccessService.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 || targetParent.deletedAt) {
throw new NotFoundException('Target parent page not found');
}
await this.pageAccessService.validateCanEdit(targetParent, user);
}
return this.pageService.movePage(dto, movedPage);
}
@@ -433,10 +495,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();
}
await this.pageAccessService.validateCanView(page, user);
return this.pageService.getPageBreadCrumbs(page.id);
}
}
@@ -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 { 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 {
@@ -48,6 +49,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
import { markdownToHtml } from '@docmost/editor-ext';
import { WatcherService } from '../../watcher/watcher.service';
import { sql } from 'kysely';
@Injectable()
export class PageService {
@@ -55,6 +57,7 @@ export class PageService {
constructor(
private pageRepo: PageRepo,
private pagePermissionRepo: PagePermissionRepo,
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
@@ -92,7 +95,11 @@ export class PageService {
createPageDto.parentPageId,
);
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== createPageDto.spaceId
) {
throw new NotFoundException('Parent page not found');
}
@@ -262,6 +269,8 @@ export class PageService {
spaceId: string,
pagination: PaginationOptions,
pageId?: string,
userId?: string,
spaceCanEdit?: boolean,
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
let query = this.db
.selectFrom('pages')
@@ -286,8 +295,8 @@ export class PageService {
query = query.where('parentPageId', 'is', null);
}
return executeWithCursorPagination(query, {
perPage: 250,
const result = await executeWithCursorPagination(query, {
perPage: 200,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
@@ -303,10 +312,97 @@ export class PageService {
id: cursor.id,
}),
});
if (userId && result.items.length > 0) {
const hasRestrictions =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
if (!hasRestrictions) {
result.items = result.items.map((p: any) => ({
...p,
canEdit: spaceCanEdit ?? true,
}));
} else {
const pageIds = result.items.map((p: any) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const permissionMap = new Map(
accessiblePages.map((p) => [p.id, p.canEdit]),
);
result.items = result.items
.filter((p: any) => permissionMap.has(p.id))
.map((p: any) => ({
...p,
canEdit: permissionMap.get(p.id) && (spaceCanEdit ?? true),
}));
const pagesWithChildren = result.items.filter(
(p: any) => p.hasChildren,
);
if (pagesWithChildren.length > 0) {
const parentIds = pagesWithChildren.map((p: any) => p.id);
const parentsWithAccessibleChildren =
await this.pagePermissionRepo.getParentIdsWithAccessibleChildren(
parentIds,
userId,
);
const hasAccessibleChildrenSet = new Set(
parentsWithAccessibleChildren,
);
result.items = result.items.map((p: any) => ({
...p,
hasChildren: p.hasChildren && hasAccessibleChildrenSet.has(p.id),
}));
}
}
}
return result;
}
async movePageToSpace(rootPage: Page, spaceId: string) {
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: false,
});
// Filter to only accessible pages while maintaining tree integrity
const accessiblePages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
userId,
rootPage.spaceId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
// Find inaccessible pages whose parent is being moved - these need to be orphaned
const pagesToOrphan = allPages.filter(
(p) =>
!accessibleIds.has(p.id) &&
p.parentPageId &&
accessibleIds.has(p.parentPageId),
);
await executeTx(this.db, async (trx) => {
// Orphan inaccessible child pages (make them root pages in original space)
for (const page of pagesToOrphan) {
const orphanPosition = await this.nextPagePosition(
rootPage.spaceId,
null,
);
await this.pageRepo.updatePage(
{ parentPageId: null, position: orphanPosition },
page.id,
trx,
);
}
// Update root page
const nextPosition = await this.nextPagePosition(spaceId);
await this.pageRepo.updatePage(
@@ -314,48 +410,54 @@ export class PageService {
rootPage.id,
trx,
);
const pageIds = await this.pageRepo
.getPageAndDescendants(rootPage.id, { includeContent: false })
.then((pages) => pages.map((page) => page.id));
// The first id is the root page id
if (pageIds.length > 1) {
// Update sub pages
const pageIdsToMove = accessiblePages.map((p) => p.id);
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
await this.pageRepo.updatePages(
{ spaceId },
pageIds.filter((id) => id !== rootPage.id),
pageIdsToMove.filter((id) => id !== rootPage.id),
trx,
);
}
if (pageIds.length > 0) {
if (pageIdsToMove.length > 0) {
// Clear page-level permissions - moved pages inherit destination space permissions
// (page_permissions cascade deletes via foreign key)
await trx
.deleteFrom('pageAccess')
.where('pageId', 'in', pageIdsToMove)
.execute();
// update spaceId in shares
await trx
.updateTable('shares')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update comments
await trx
.updateTable('comments')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },
pageIds,
pageIdsToMove,
trx,
);
// Update watchers and remove those without access to new space
await this.watcherService.movePageWatchersToSpace(pageIds, spaceId, {
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
trx,
});
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIds,
pageId: pageIdsToMove,
workspaceId: rootPage.workspaceId,
});
}
@@ -381,10 +483,18 @@ export class PageService {
nextPosition = await this.nextPagePosition(spaceId);
}
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: true,
});
// Filter to only accessible pages while maintaining tree integrity
const pages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
authUser.id,
rootPage.spaceId,
);
const pageMap = new Map<string, CopyPageMapEntry>();
pages.forEach((page) => {
pageMap.set(page.id, {
@@ -592,7 +702,11 @@ export class PageService {
// changing the page's parent
if (dto.parentPageId) {
const parentPage = await this.pageRepo.findById(dto.parentPageId);
if (!parentPage || parentPage.spaceId !== movedPage.spaceId) {
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== movedPage.spaceId
) {
throw new NotFoundException('Parent page not found');
}
parentPageId = parentPage.id;
@@ -623,7 +737,6 @@ export class PageService {
'spaceId',
'deletedAt',
])
.select((eb) => this.pageRepo.withHasChildren(eb))
.where('id', '=', childPageId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
@@ -639,30 +752,21 @@ export class PageService {
'p.spaceId',
'p.deletedAt',
])
.select(
exp
.selectFrom('pages as child')
.select((eb) =>
eb
.case()
.when(eb.fn.countAll(), '>', 0)
.then(true)
.else(false)
.end()
.as('count'),
)
.whereRef('child.parentPageId', '=', 'id')
.where('child.deletedAt', 'is', null)
.limit(1)
.as('hasChildren'),
)
//.select((eb) => this.withHasChildren(eb))
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_ancestors')
.selectAll()
.selectAll('page_ancestors')
.select((eb) =>
eb.exists(
eb
.selectFrom('pages as child')
.select(sql`1`.as('one'))
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
.where('child.deletedAt', 'is', null),
).as('hasChildren'),
)
.execute();
return ancestors.reverse();
@@ -670,23 +774,72 @@ export class PageService {
async getRecentSpacePages(
spaceId: string,
userId: string,
pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
const result = await this.pageRepo.getRecentPagesInSpace(
spaceId,
pagination,
);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
spaceId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async getRecentPages(
userId: string,
pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getRecentPages(userId, pagination);
const result = await this.pageRepo.getRecentPages(userId, pagination);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async getDeletedSpacePages(
spaceId: string,
userId: string,
pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
const result = await this.pageRepo.getDeletedPagesInSpace(
spaceId,
pagination,
);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
spaceId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
@@ -776,4 +929,61 @@ export class PageService {
return prosemirrorJson;
}
/**
* Filters a list of pages to only those accessible to the user while maintaining tree integrity.
* A page is included only if:
* 1. The user has access to it
* 2. Its parent is also included (or it's the root page)
* This ensures that if a middle page is inaccessible, its entire subtree is excluded.
*/
private async filterAccessibleTreePages<
T extends { id: string; parentPageId: string | null },
>(
pages: T[],
rootPageId: string,
userId: string,
spaceId?: string,
): Promise<T[]> {
if (pages.length === 0) return [];
const pageIds = pages.map((p) => p.id);
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds(
{
pageIds,
userId,
spaceId,
},
);
const accessibleSet = new Set(accessibleIds);
// Prune: include a page only if it's accessible AND its parent chain to root is included
const includedIds = new Set<string>();
// Process pages in a way that ensures parents are processed before children
// We do this by iterating until no more pages can be added
let changed = true;
while (changed) {
changed = false;
for (const page of pages) {
if (includedIds.has(page.id)) continue;
if (!accessibleSet.has(page.id)) continue;
// Root page: include if accessible
if (page.id === rootPageId) {
includedIds.add(page.id);
changed = true;
continue;
}
// Non-root: include if parent is already included
if (page.parentPageId && includedIds.has(page.parentPageId)) {
includedIds.add(page.id);
changed = true;
}
}
}
return pages.filter((p) => includedIds.has(p.id));
}
}
+30 -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(
@@ -115,10 +117,23 @@ 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 accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId: opts.userId,
spaceId: searchParams.spaceId,
});
const accessibleSet = new Set(accessibleIds);
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, ' ')
@@ -207,6 +222,19 @@ 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 accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
spaceId: suggestion?.spaceId,
});
const accessibleSet = new Set(accessibleIds);
pages = pages.filter((p) => accessibleSet.has(p.id));
}
}
return { users, groups, pages };
+28 -19
View File
@@ -11,12 +11,7 @@ import {
} from '@nestjs/common';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { ShareService } from './share.service';
import {
CreateShareDto,
@@ -26,6 +21,8 @@ import {
UpdateShareDto,
} from './dto/share.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageAccessService } from '../page/page-access/page-access.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@@ -38,9 +35,10 @@ import { hasLicenseOrEE } from '../../common/helpers';
export class ShareController {
constructor(
private readonly shareService: ShareService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService,
private readonly environmentService: EnvironmentService,
) {}
@@ -119,10 +117,7 @@ export class ShareController {
throw new NotFoundException('Shared page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return this.shareService.getShareForPage(page.id, workspace.id);
}
@@ -140,9 +135,17 @@ export class ShareController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
// User must be able to edit the page to create a share
//TODO: i dont think this is neccessary if we prevent restricted pages from getting shared
// rather, use space level permission and workspace/space level sharing restriction
await this.pageAccessService.validateCanEdit(page, user);
// Prevent sharing restricted pages
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
page.id,
);
if (isRestricted) {
throw new BadRequestException('Cannot share a restricted page');
}
const sharingAllowed = await this.shareService.isSharingAllowed(
@@ -170,11 +173,14 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(share.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// User must be able to edit the page to update its share
await this.pageAccessService.validateCanEdit(page, user);
return this.shareService.updateShare(share.id, updateShareDto);
}
@@ -187,11 +193,14 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(share.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// User must be able to edit the page to delete its share
await this.pageAccessService.validateCanEdit(page, user);
await this.shareRepo.deleteShare(share.id);
}
+22 -5
View File
@@ -19,6 +19,7 @@ import {
} from '../../common/helpers/prosemirror/utils';
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 { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types';
import { validate as isValidUUID } from 'uuid';
@@ -31,6 +32,7 @@ export class ShareService {
constructor(
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly tokenService: TokenService,
) {}
@@ -41,12 +43,20 @@ export class ShareService {
throw new NotFoundException('Share not found');
}
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
includeContent: false,
});
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
if (isRestricted) {
throw new NotFoundException('Share not found');
}
return { share, pageTree: pageList };
if (share.includeSubPages) {
const pageTree =
await this.pageRepo.getPageAndDescendantsExcludingRestricted(
share.pageId,
{ includeContent: false },
);
return { share, pageTree };
} else {
return { share, pageTree: [] };
}
@@ -112,6 +122,13 @@ export class ShareService {
throw new NotFoundException('Shared page not found');
}
// Block access to restricted pages
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(page.id);
if (isRestricted) {
throw new NotFoundException('Shared page not found');
}
page.content = await this.updatePublicAttachments(page);
return { page, share };
@@ -15,6 +15,7 @@ import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PageRepo } from './repos/page/page.repo';
import { PagePermissionRepo } from './repos/page/page-permission.repo';
import { CommentRepo } from './repos/comment/comment.repo';
import { PageHistoryRepo } from './repos/page/page-history.repo';
import { AttachmentRepo } from './repos/attachment/attachment.repo';
@@ -76,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
SpaceRepo,
SpaceMemberRepo,
PageRepo,
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
@@ -94,6 +96,7 @@ import { normalizePostgresUrl } from '../common/helpers';
SpaceRepo,
SpaceMemberRepo,
PageRepo,
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
@@ -0,0 +1,90 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_access')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_id', 'uuid', (col) =>
col.notNull().unique().references('pages.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('space_id', 'uuid', (col) =>
col.notNull().references('spaces.id').onDelete('cascade'),
)
.addColumn('access_level', 'varchar', (col) => col.notNull())
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createTable('page_permissions')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_access_id', 'uuid', (col) =>
col.notNull().references('page_access.id').onDelete('cascade'),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade'),
)
.addColumn('group_id', 'uuid', (col) =>
col.references('groups.id').onDelete('cascade'),
)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('added_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('page_access_user_unique', [
'page_access_id',
'user_id',
])
.addUniqueConstraint('page_access_group_unique', [
'page_access_id',
'group_id',
])
.addCheckConstraint(
'allow_either_user_id_or_group_id_check',
sql`((user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL))`,
)
.execute();
await db.schema
.createIndex('idx_page_access_space')
.on('page_access')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_user')
.on('page_permissions')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_group')
.on('page_permissions')
.column('group_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('page_permissions').ifExists().execute();
await db.schema.dropTable('page_access').ifExists().execute();
}
@@ -306,6 +306,21 @@ export function defaultEncodeCursor<
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
}
export function emptyCursorPaginationResult<T>(
limit: number,
): CursorPaginationResult<T> {
return {
items: [],
meta: {
limit,
hasNextPage: false,
hasPrevPage: false,
nextCursor: null,
prevCursor: null,
},
};
}
export function defaultDecodeCursor<
DB,
TB extends keyof DB,
@@ -175,4 +175,14 @@ export class GroupUserRepo {
.where('groupId', '=', groupId)
.execute();
}
async getUserGroupIds(userId: string): Promise<string[]> {
const results = await this.db
.selectFrom('groupUsers')
.select('groupId')
.where('userId', '=', userId)
.execute();
return results.map((r) => r.groupId);
}
}
File diff suppressed because it is too large Load Diff
@@ -175,11 +175,13 @@ export class PageRepo {
.selectFrom('pages')
.select(['id'])
.where('id', '=', pageId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_descendants')
@@ -197,6 +199,7 @@ export class PageRepo {
deletedAt: currentDate,
})
.where('id', 'in', pageIds)
.where('deletedAt', 'is', null)
.execute();
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
@@ -472,4 +475,75 @@ export class PageRepo {
.selectAll()
.execute();
}
/**
* Get page and all descendants, excluding restricted pages and their subtrees.
* 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
*/
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()
);
}
}
@@ -0,0 +1,23 @@
type PagePermissionUserMember = {
id: string;
name: string;
email: string;
avatarUrl: string | null;
type: 'user';
role: string;
createdAt: Date;
};
type PagePermissionGroupMember = {
id: string;
name: string;
memberCount: number;
isDefault: boolean;
type: 'group';
role: string;
createdAt: Date;
};
export type PagePermissionMember =
| PagePermissionUserMember
| PagePermissionGroupMember;
+25
View File
@@ -390,6 +390,28 @@ export interface Watchers {
createdAt: Generated<Timestamp>;
}
export interface PageAccess {
id: Generated<string>;
pageId: string;
workspaceId: string;
spaceId: string;
accessLevel: string;
creatorId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PagePermissions {
id: Generated<string>;
pageAccessId: string;
userId: string | null;
groupId: string | null;
role: string;
addedById: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
@@ -402,7 +424,10 @@ export interface DB {
groups: Groups;
groupUsers: GroupUsers;
notifications: Notifications;
pageAccess: PageAccess;
pageHierarchy: PageHierarchy;
pageHistory: PageHistory;
pagePermissions: PagePermissions;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
@@ -4,6 +4,8 @@ import {
Comments,
Groups,
Notifications,
PageAccess as _PageAccess,
PagePermissions as _PagePermissions,
Pages,
Spaces,
Users,
@@ -143,3 +145,13 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
// Page Access
export type PageAccess = Selectable<_PageAccess>;
export type InsertablePageAccess = Insertable<_PageAccess>;
export type UpdatablePageAccess = Updateable<Omit<_PageAccess, 'id'>>;
// Page Permission
export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
@@ -16,6 +16,7 @@ import { User } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageAccessService } from '../../core/page/page-access/page-access.service';
import {
SpaceCaslAction,
SpaceCaslSubject,
@@ -32,6 +33,7 @@ export class ExportController {
private readonly exportService: ExportService,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
) {}
@UseGuards(JwtAuthGuard)
@@ -50,16 +52,14 @@ export class ExportController {
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();
}
await this.pageAccessService.validateCanView(page, user);
const zipFileStream = await this.exportService.exportPages(
dto.pageId,
dto.format,
dto.includeAttachments,
dto.includeChildren,
user.id,
);
const fileName = sanitize(page.title || 'untitled') + '.zip';
@@ -82,7 +82,7 @@ export class ExportController {
@Res() res: FastifyReply,
) {
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
@@ -90,6 +90,7 @@ export class ExportController {
dto.spaceId,
dto.format,
dto.includeAttachments,
user.id,
);
res.headers({
@@ -25,6 +25,7 @@ import {
ExportPageMetadata,
} from '../../common/helpers/types/export-metadata.types';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { Node } from '@tiptap/pm/model';
import { EditorState } from '@tiptap/pm/state';
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -44,6 +45,7 @@ export class ExportService {
constructor(
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
private readonly environmentService: EnvironmentService,
@@ -100,6 +102,8 @@ export class ExportService {
format: string,
includeAttachments: boolean,
includeChildren: boolean,
userId?: string,
ignorePermissions = false,
) {
let pages: Page[];
@@ -113,7 +117,7 @@ export class ExportService {
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
if (page){
if (page) {
pages = [page];
}
}
@@ -122,14 +126,38 @@ export class ExportService {
throw new BadRequestException('No pages to export');
}
if (!ignorePermissions && userId) {
pages = await this.filterPagesForExport(
pages,
pageId,
userId,
pages[0].spaceId,
);
if (pages.length === 0) {
throw new BadRequestException('No accessible pages to export');
}
}
const parentPageIndex = pages.findIndex((obj) => obj.id === pageId);
//After filtering by permissions, if the root page itself is not accessible to the user, findIndex returns -1
if (parentPageIndex === -1) {
throw new BadRequestException('Root page is not accessible');
}
// set to null to make export of pages with parentId work
pages[parentPageIndex].parentPageId = null;
const tree = buildTree(pages as Page[]);
const zip = new JSZip();
await this.zipPages(tree, format, zip, includeAttachments);
await this.zipPages(
tree,
format,
zip,
includeAttachments,
userId,
ignorePermissions,
);
const zipFile = zip.generateNodeStream({
type: 'nodebuffer',
@@ -144,10 +172,12 @@ export class ExportService {
spaceId: string,
format: string,
includeAttachments: boolean,
userId?: string,
ignorePermissions = false,
) {
const space = await this.db
.selectFrom('spaces')
.selectAll()
.select(['id', 'name'])
.where('id', '=', spaceId)
.executeTakeFirst();
@@ -155,7 +185,7 @@ export class ExportService {
throw new NotFoundException('Space not found');
}
const pages = await this.db
let pages = await this.db
.selectFrom('pages')
.select([
'pages.id',
@@ -174,11 +204,30 @@ export class ExportService {
.where('deletedAt', 'is', null)
.execute();
if (!ignorePermissions && userId) {
pages = await this.filterPagesForExport(
pages as Page[],
null,
userId,
spaceId,
);
if (pages.length === 0) {
throw new BadRequestException('No accessible pages to export');
}
}
const tree = buildTree(pages as Page[]);
const zip = new JSZip();
await this.zipPages(tree, format, zip, includeAttachments);
await this.zipPages(
tree,
format,
zip,
includeAttachments,
userId,
ignorePermissions,
);
const zipFile = zip.generateNodeStream({
type: 'nodebuffer',
@@ -198,6 +247,8 @@ export class ExportService {
format: string,
zip: JSZip,
includeAttachments: boolean,
userId?: string,
ignorePermissions = false,
): Promise<void> {
const slugIdToPath: Record<string, string> = {};
const pageIdToFilePath: Record<string, string> = {};
@@ -219,6 +270,8 @@ export class ExportService {
const prosemirrorJson = await this.turnPageMentionsToLinks(
getProsemirrorContent(page.content),
page.workspaceId,
userId,
ignorePermissions,
);
const currentPagePath = slugIdToPath[page.slugId];
@@ -303,10 +356,15 @@ export class ExportService {
}
}
async turnPageMentionsToLinks(prosemirrorJson: any, workspaceId: string) {
async turnPageMentionsToLinks(
prosemirrorJson: any,
workspaceId: string,
userId?: string,
ignorePermissions = false,
) {
const doc = jsonToNode(prosemirrorJson);
const pageMentionIds = [];
let pageMentionIds: string[] = [];
doc.descendants((node: Node) => {
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
@@ -320,13 +378,31 @@ export class ExportService {
return prosemirrorJson;
}
const pages = await this.db
.selectFrom('pages')
.select(['id', 'slugId', 'title', 'creatorId', 'spaceId', 'workspaceId'])
.select((eb) => this.pageRepo.withSpace(eb))
.where('id', 'in', pageMentionIds)
.where('workspaceId', '=', workspaceId)
.execute();
// Filter to only accessible pages if permissions are enforced
if (!ignorePermissions && userId) {
pageMentionIds = await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: pageMentionIds,
userId,
});
}
const pages =
pageMentionIds.length > 0
? await this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'creatorId',
'spaceId',
'workspaceId',
])
.select((eb) => this.pageRepo.withSpace(eb))
.where('id', 'in', pageMentionIds)
.where('workspaceId', '=', workspaceId)
.execute()
: [];
const pageMap = new Map(pages.map((page) => [page.id, page]));
@@ -398,4 +474,52 @@ export class ExportService {
return updatedDoc.toJSON();
}
private async filterPagesForExport(
pages: Page[],
rootPageId: string | null,
userId: string,
spaceId: string,
): Promise<Page[]> {
if (pages.length === 0) return [];
const pageIds = pages.map((p) => p.id);
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds(
{
pageIds,
userId,
spaceId,
},
);
const accessibleSet = new Set(accessibleIds);
const includedIds = new Set<string>();
let changed = true;
while (changed) {
changed = false;
for (const page of pages) {
if (includedIds.has(page.id)) continue;
if (!accessibleSet.has(page.id)) continue;
// Root page or top-level page in space export
if (
page.id === rootPageId ||
(rootPageId === null && page.parentPageId === null)
) {
includedIds.add(page.id);
changed = true;
continue;
}
// Non-root: include if parent is already included
if (page.parentPageId && includedIds.has(page.parentPageId)) {
includedIds.add(page.id);
changed = true;
}
}
}
return pages.filter((p) => includedIds.has(p.id));
}
}
@@ -67,4 +67,5 @@ export enum QueueJob {
COMMENT_NOTIFICATION = 'comment-notification',
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
}
@@ -1,5 +1,4 @@
import { MentionNode } from "../../../common/helpers/prosemirror/utils";
import { MentionNode } from '../../../common/helpers/prosemirror/utils';
export interface IPageBacklinkJob {
pageId: string;
@@ -60,3 +59,12 @@ export interface IPageMentionNotificationJob {
spaceId: string;
workspaceId: string;
}
export interface IPermissionGrantedNotificationJob {
userIds: string[];
pageId: string;
spaceId: string;
workspaceId: string;
actorId: string;
role: string;
}
@@ -0,0 +1,45 @@
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
accessLabel: string;
}
export const PermissionGrantedEmail = ({
actorName,
pageTitle,
pageUrl,
accessLabel,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
<strong>{actorName}</strong> gave you {accessLabel} access to{' '}
<strong>{pageTitle}</strong>.
</Text>
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
export default PermissionGrantedEmail;
+47
View File
@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { Page } from '@docmost/db/types/entity.types';
import { WsService } from './ws.service';
@Injectable()
export class WsTreeService {
constructor(private readonly wsService: WsService) {}
async notifyPageRestricted(page: Page, excludeUserId: string): Promise<void> {
await this.wsService.emitToSpaceExceptUsers(page.spaceId, [excludeUserId], {
operation: 'deleteTreeNode',
spaceId: page.spaceId,
payload: {
node: {
id: page.id,
slugId: page.slugId,
},
},
});
}
async notifyPermissionGranted(page: Page, userIds: string[]): Promise<void> {
if (userIds.length === 0) return;
await this.wsService.emitToUsers(userIds, {
operation: 'addTreeNode',
spaceId: page.spaceId,
payload: {
parentId: page.parentPageId ?? null,
index: 0,
data: {
id: page.id,
slugId: page.slugId,
name: page.title ?? '',
title: page.title,
icon: page.icon,
position: page.position,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
creatorId: page.creatorId,
hasChildren: false,
children: [],
},
},
});
}
}
+19 -18
View File
@@ -1,6 +1,7 @@
import {
MessageBody,
OnGatewayConnection,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
@@ -10,20 +11,30 @@ import { TokenService } from '../core/auth/services/token.service';
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
import { OnModuleDestroy } from '@nestjs/common';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { WsService } from './ws.service';
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
import * as cookie from 'cookie';
@WebSocketGateway({
cors: { origin: '*' },
transports: ['websocket'],
})
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
export class WsGateway
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
{
@WebSocketServer()
server: Server;
constructor(
private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo,
private wsService: WsService,
) {}
afterInit(server: Server): void {
this.wsService.setServer(server);
}
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
try {
const cookies = cookie.parse(client.handshake.headers.cookie);
@@ -35,11 +46,13 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
const userId = token.sub;
const workspaceId = token.workspaceId;
client.data.userId = userId;
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const userRoom = `user-${userId}`;
const userRoom = getUserRoomName(userId);
const workspaceRoom = `workspace-${workspaceId}`;
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
client.join([userRoom, workspaceRoom, ...spaceRooms]);
} catch (err) {
@@ -49,17 +62,9 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
}
@SubscribeMessage('message')
handleMessage(client: Socket, data: any): void {
const spaceEvents = [
'updateOne',
'addTreeNode',
'moveTreeNode',
'deleteTreeNode',
];
if (spaceEvents.includes(data?.operation) && data?.spaceId) {
const room = this.getSpaceRoomName(data.spaceId);
client.broadcast.to(room).emit('message', data);
async handleMessage(client: Socket, data: any): Promise<void> {
if (this.wsService.isTreeEvent(data)) {
await this.wsService.handleTreeEvent(client, data);
return;
}
@@ -82,8 +87,4 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
this.server.close();
}
}
getSpaceRoomName(spaceId: string): string {
return `space-${spaceId}`;
}
}
+6 -3
View File
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { WsGateway } from './ws.gateway';
import { WsService } from './ws.service';
import { WsTreeService } from './ws-tree.service';
import { TokenModule } from '../core/auth/token.module';
@Global()
@Module({
imports: [TokenModule],
providers: [WsGateway],
exports: [WsGateway],
providers: [WsGateway, WsService, WsTreeService],
exports: [WsGateway, WsService, WsTreeService],
})
export class WsModule {}
+157
View File
@@ -0,0 +1,157 @@
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Server, Socket } from 'socket.io';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import {
TREE_EVENTS,
WS_SPACE_RESTRICTION_CACHE_PREFIX,
WS_CACHE_TTL_MS,
getSpaceRoomName,
getUserRoomName,
} from './ws.utils';
@Injectable()
export class WsService {
private server: Server;
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {}
setServer(server: Server): void {
this.server = server;
}
async handleTreeEvent(client: Socket, data: any): Promise<void> {
const room = getSpaceRoomName(data.spaceId);
const hasRestrictions = await this.spaceHasRestrictions(data.spaceId);
if (!hasRestrictions) {
client.broadcast.to(room).emit('message', data);
return;
}
const pageId = this.extractPageId(data);
if (!pageId) {
client.broadcast.to(room).emit('message', data);
return;
}
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
if (!isRestricted) {
client.broadcast.to(room).emit('message', data);
return;
}
await this.broadcastToAuthorizedUsers(client, room, pageId, data);
}
async invalidateSpaceRestrictionCache(spaceId: string): Promise<void> {
await this.cacheManager.del(
`${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`,
);
}
async emitToUsers(userIds: string[], data: any): Promise<void> {
if (userIds.length === 0) return;
const rooms = userIds.map((id) => getUserRoomName(id));
this.server.to(rooms).emit('message', data);
}
async emitToSpaceExceptUsers(
spaceId: string,
excludeUserIds: string[],
data: any,
): Promise<void> {
const room = getSpaceRoomName(spaceId);
const sockets = await this.server.in(room).fetchSockets();
const excludeSet = new Set(excludeUserIds);
for (const socket of sockets) {
const userId = socket.data.userId as string;
if (userId && !excludeSet.has(userId)) {
socket.emit('message', data);
}
}
}
isTreeEvent(data: any): boolean {
return TREE_EVENTS.has(data?.operation) && !!data?.spaceId;
}
private async broadcastToAuthorizedUsers(
sender: Socket,
room: string,
pageId: string,
data: any,
): Promise<void> {
const sockets = await this.server.in(room).fetchSockets();
const otherSockets = sockets.filter((s) => s.id !== sender.id);
if (otherSockets.length === 0) return;
const userSocketMap = new Map<string, typeof otherSockets>();
for (const socket of otherSockets) {
const userId = socket.data.userId as string;
if (!userId) continue;
const existing = userSocketMap.get(userId);
if (existing) {
existing.push(socket);
} else {
userSocketMap.set(userId, [socket]);
}
}
const candidateUserIds = Array.from(userSocketMap.keys());
if (candidateUserIds.length === 0) return;
const authorizedUserIds =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
candidateUserIds,
);
const authorizedSet = new Set(authorizedUserIds);
for (const [userId, userSockets] of userSocketMap) {
if (authorizedSet.has(userId)) {
for (const socket of userSockets) {
socket.emit('message', data);
}
}
}
}
private async spaceHasRestrictions(spaceId: string): Promise<boolean> {
const cacheKey = `${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`;
const cached = await this.cacheManager.get<boolean>(cacheKey);
if (cached !== undefined && cached !== null) {
return cached;
}
const hasRestrictions =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
await this.cacheManager.set(cacheKey, hasRestrictions, WS_CACHE_TTL_MS);
return hasRestrictions;
}
private extractPageId(data: any): string | null {
switch (data.operation) {
case 'addTreeNode':
return data.payload?.data?.id ?? null;
case 'moveTreeNode':
return data.payload?.id ?? null;
case 'deleteTreeNode':
return data.payload?.node?.id ?? null;
case 'updateOne':
return data.id ?? null;
default:
return null;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
export const WS_CACHE_TTL_MS = 30_000;
export const WS_SPACE_RESTRICTION_CACHE_PREFIX = 'ws:space-restrictions:';
export function getSpaceRoomName(spaceId: string): string {
return `space-${spaceId}`;
}
export function getUserRoomName(userId: string): string {
return `user-${userId}`;
}
export const TREE_EVENTS = new Set([
'updateOne',
'addTreeNode',
'moveTreeNode',
'deleteTreeNode',
]);