Merge branch 'main' into base

This commit is contained in:
Philipinho
2026-03-08 01:57:17 +00:00
284 changed files with 15522 additions and 3285 deletions
+31 -1
View File
@@ -1,6 +1,9 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EnvironmentService } from './integrations/environment/environment.service';
import { AuditActorInterceptor } from './common/interceptors/audit-actor.interceptor';
import { CoreModule } from './core/core.module';
import { EnvironmentModule } from './integrations/environment/environment.module';
import { CollaborationModule } from './collaboration/collaboration.module';
@@ -18,7 +21,11 @@ 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';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
const enterpriseModules = [];
try {
@@ -36,13 +43,30 @@ try {
@Module({
imports: [
ClsModule.forRoot({
global: true,
middleware: { mount: true },
}),
LoggerModule,
NoopAuditModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
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,
@@ -62,6 +86,12 @@ try {
...enterpriseModules,
],
controllers: [AppController],
providers: [AppService],
providers: [
AppService,
{
provide: APP_INTERCEPTOR,
useClass: AuditActorInterceptor,
},
],
})
export class AppModule {}
@@ -33,6 +33,9 @@ import {
Subpages,
Highlight,
UniqueID,
Columns,
Column,
Status,
addUniqueIdsToDoc,
htmlToMarkdown,
} from '@docmost/editor-ext';
@@ -91,6 +94,9 @@ export const tiptapExtensions = [
Embed,
Mention,
Subpages,
Columns,
Column,
Status,
] as any;
export function jsonToHtml(tiptapJson: any) {
@@ -9,8 +9,10 @@ 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 { isUserDisabled } from '../../common/helpers';
import { getPageId } from '../collaboration.util';
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
@@ -23,6 +25,7 @@ export class AuthenticationExtension implements Extension {
private userRepo: UserRepo,
private pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async onAuthenticate(data: onAuthenticatePayload) {
@@ -46,13 +49,13 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException();
}
if (user.deactivatedAt || user.deletedAt) {
if (isUserDisabled(user)) {
throw new UnauthorizedException();
}
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 +71,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}`);
@@ -0,0 +1,143 @@
export const AuditEvent = {
// Workspace
WORKSPACE_CREATED: 'workspace.created',
WORKSPACE_UPDATED: 'workspace.updated',
WORKSPACE_INVITE_CREATED: 'workspace.invite_created',
WORKSPACE_INVITE_RESENT: 'workspace.invite_resent',
WORKSPACE_INVITE_REVOKED: 'workspace.invite_revoked',
// User
USER_CREATED: 'user.created',
USER_DELETED: 'user.deleted',
USER_LOGIN: 'user.login',
USER_LOGOUT: 'user.logout',
USER_ROLE_CHANGED: 'user.role_changed',
USER_PASSWORD_CHANGED: 'user.password_changed',
USER_PASSWORD_RESET: 'user.password_reset',
USER_UPDATED: 'user.updated',
USER_DEACTIVATED: 'user.deactivated',
USER_ACTIVATED: 'user.activated',
// API Keys
API_KEY_CREATED: 'api_key.created',
API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted',
// Space
SPACE_CREATED: 'space.created',
SPACE_UPDATED: 'space.updated',
SPACE_DELETED: 'space.deleted',
SPACE_MEMBER_ADDED: 'space.member_added',
SPACE_MEMBER_REMOVED: 'space.member_removed',
SPACE_MEMBER_ROLE_CHANGED: 'space.member_role_changed',
// Group
GROUP_CREATED: 'group.created',
GROUP_UPDATED: 'group.updated',
GROUP_DELETED: 'group.deleted',
GROUP_MEMBER_ADDED: 'group.member_added',
GROUP_MEMBER_REMOVED: 'group.member_removed',
// Comment
COMMENT_CREATED: 'comment.created',
COMMENT_DELETED: 'comment.deleted',
// Comment updates / resolve
COMMENT_UPDATED: 'comment.updated',
COMMENT_RESOLVED: 'comment.resolved',
COMMENT_REOPENED: 'comment.reopened',
// Page
PAGE_CREATED: 'page.created',
PAGE_TRASHED: 'page.trashed',
PAGE_DELETED: 'page.deleted',
PAGE_RESTORED: 'page.restored',
PAGE_MOVED_TO_SPACE: 'page.moved_to_space',
PAGE_DUPLICATED: 'page.duplicated',
// Page permission
PAGE_RESTRICTED: 'page.restricted',
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
PAGE_PERMISSION_ADDED: 'page.permission_added',
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
// Share
SHARE_CREATED: 'share.created',
SHARE_DELETED: 'share.deleted',
// Import / Export
PAGE_IMPORTED: 'page.imported',
PAGE_EXPORTED: 'page.exported',
SPACE_EXPORTED: 'space.exported',
// SSO provider management
SSO_PROVIDER_CREATED: 'sso.provider_created',
SSO_PROVIDER_UPDATED: 'sso.provider_updated',
SSO_PROVIDER_DELETED: 'sso.provider_deleted',
// MFA
USER_MFA_ENABLED: 'user.mfa_enabled',
USER_MFA_DISABLED: 'user.mfa_disabled',
USER_MFA_BACKUP_CODE_GENERATED: 'user.mfa_backup_code_generated',
// License
LICENSE_ACTIVATED: 'license.activated',
LICENSE_REMOVED: 'license.removed',
// Attachment
ATTACHMENT_UPLOADED: 'attachment.uploaded',
// ATTACHMENT_DELETED: 'attachment.deleted',
} as const;
export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent];
export const EXCLUDED_AUDIT_EVENTS: Set<string> = new Set([
AuditEvent.PAGE_CREATED,
AuditEvent.PAGE_MOVED_TO_SPACE,
AuditEvent.PAGE_DUPLICATED,
AuditEvent.COMMENT_CREATED,
AuditEvent.COMMENT_UPDATED,
AuditEvent.COMMENT_RESOLVED,
AuditEvent.COMMENT_REOPENED,
AuditEvent.ATTACHMENT_UPLOADED
]);
export const AuditResource = {
WORKSPACE: 'workspace',
USER: 'user',
PAGE: 'page',
SPACE: 'space',
SPACE_MEMBER: 'space_member',
GROUP: 'group',
COMMENT: 'comment',
SHARE: 'share',
API_KEY: 'api_key',
SSO_PROVIDER: 'sso_provider',
WORKSPACE_INVITATION: 'workspace_invitation',
ATTACHMENT: 'attachment',
LICENSE: 'license',
} as const;
export type AuditResourceType =
(typeof AuditResource)[keyof typeof AuditResource];
export type ActorType = 'user' | 'system' | 'api_key';
export interface AuditLogPayload {
event: AuditEventType;
resourceType: AuditResourceType;
resourceId?: string;
spaceId?: string;
changes?: {
before?: Record<string, any>;
after?: Record<string, any>;
};
metadata?: Record<string, any>;
}
export interface AuditLogData extends AuditLogPayload {
workspaceId: string;
actorId?: string;
actorType: ActorType;
ipAddress?: string;
userAgent?: string;
}
@@ -0,0 +1,3 @@
export const CacheKey = {
LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
};
@@ -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
}
+31
View File
@@ -120,6 +120,37 @@ export function normalizePostgresUrl(url: string): string {
return parsed.toString();
}
export function diffAuditTrackedFields(
fields: readonly string[],
dto: Record<string, any>,
before: Record<string, any> | undefined | null,
after: Record<string, any> | undefined | null,
): { before: Record<string, any>; after: Record<string, any> } | null {
const beforeDiff: Record<string, any> = {};
const afterDiff: Record<string, any> = {};
let hasChanges = false;
for (const field of fields) {
if (typeof dto[field] === 'undefined') continue;
const oldVal = JSON.stringify(before?.[field] ?? null);
const newVal = JSON.stringify(after?.[field] ?? null);
if (oldVal !== newVal) {
beforeDiff[field] = before?.[field];
afterDiff[field] = after?.[field];
hasChanges = true;
}
}
return hasChanges ? { before: beforeDiff, after: afterDiff } : null;
}
export function isUserDisabled(user: {
deactivatedAt?: Date | null;
deletedAt?: Date | null;
}): boolean {
return !!(user.deactivatedAt || user.deletedAt);
}
export function createByteCountingStream(source: Readable) {
let bytesRead = 0;
const stream = new Transform({
@@ -0,0 +1,29 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { ClsService } from 'nestjs-cls';
import { AuditContext, AUDIT_CONTEXT_KEY } from '../middlewares/audit-context.middleware';
@Injectable()
export class AuditActorInterceptor implements NestInterceptor {
constructor(private readonly cls: ClsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const user = request.user?.user;
if (user?.id) {
const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (auditContext) {
auditContext.actorId = user.id;
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
}
}
return next.handle();
}
}
@@ -0,0 +1,50 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { ClsService } from 'nestjs-cls';
export interface AuditContext {
workspaceId: string | null;
actorId: string | null;
actorType: 'user' | 'system' | 'api_key';
ipAddress: string | null;
}
export const AUDIT_CONTEXT_KEY = 'auditContext';
@Injectable()
export class AuditContextMiddleware implements NestMiddleware {
constructor(private readonly cls: ClsService) {}
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
const workspaceId = (req as any).workspaceId ?? null;
const ipAddress = this.extractIpAddress(req);
const auditContext: AuditContext = {
workspaceId,
actorId: null,
actorType: 'user',
ipAddress,
};
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
next();
}
private extractIpAddress(req: FastifyRequest['raw']): string | null {
const xForwardedFor = req.headers['x-forwarded-for'];
if (xForwardedFor) {
const ips = Array.isArray(xForwardedFor)
? xForwardedFor[0]
: xForwardedFor.split(',')[0];
return ips?.trim() ?? null;
}
const xRealIp = req.headers['x-real-ip'];
if (xRealIp) {
return Array.isArray(xRealIp) ? xRealIp[0] : xRealIp;
}
return (req as any).socket?.remoteAddress ?? null;
}
}
@@ -6,6 +6,7 @@ import {
Get,
HttpCode,
HttpStatus,
Inject,
Logger,
NotFoundException,
Param,
@@ -52,7 +53,13 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
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 { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page/page-access/page-access.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
@Controller()
export class AttachmentController {
@@ -67,6 +74,8 @@ export class AttachmentController {
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@UseGuards(JwtAuthGuard)
@@ -111,13 +120,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;
@@ -136,6 +139,18 @@ export class AttachmentController {
attachmentId: attachmentId,
});
this.auditService.log({
event: AuditEvent.ATTACHMENT_UPLOADED,
resourceType: AuditResource.ATTACHMENT,
resourceId: fileResponse?.id ?? attachmentId,
spaceId,
metadata: {
fileName: fileResponse?.fileName,
pageId,
spaceId,
},
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
@@ -172,15 +187,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) {
@@ -355,6 +368,34 @@ export class AttachmentController {
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/info')
async getAttachmentInfo(
@Body() dto: AttachmentInfoDto,
@AuthWorkspace() workspace: Workspace,
@AuthUser() user: User,
) {
const attachment = await this.attachmentRepo.findById(dto.attachmentId);
if (
!attachment ||
!attachment.pageId ||
attachment.workspaceId !== workspace.id ||
attachment.type !== AttachmentType.File
) {
throw new NotFoundException('File not found');
}
const page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException('File not found');
}
await this.pageAccessService.validateCanView(page, user);
return attachment;
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/remove-icon')
@@ -2,6 +2,7 @@ import { MultipartFile } from '@fastify/multipart';
import * as path from 'path';
import { AttachmentType } from './attachment.constants';
import { sanitizeFileName } from '../../common/helpers';
import { getMimeType } from '../../common/helpers';
export interface PreparedFile {
buffer?: Buffer;
@@ -40,7 +41,7 @@ export async function prepareFile(
fileName,
fileSize,
fileExtension,
mimeType: file.mimetype,
mimeType: getMimeType(file.filename),
multiPartFile: file,
};
} catch (error) {
@@ -1,6 +1,12 @@
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { AttachmentType } from '../attachment.constants';
export class AttachmentInfoDto {
@IsNotEmpty()
@IsUUID()
attachmentId: string;
}
export class RemoveIconDto {
@IsEnum(AttachmentType)
@IsIn([
+17 -1
View File
@@ -3,6 +3,7 @@ import {
Controller,
HttpCode,
HttpStatus,
Inject,
Post,
Res,
UseGuards,
@@ -24,6 +25,11 @@ import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
import { FastifyReply } from 'fastify';
import { validateSsoEnforcement } from './auth.util';
import { ModuleRef } from '@nestjs/core';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
@Controller('auth')
export class AuthController {
@@ -33,6 +39,7 @@ export class AuthController {
private authService: AuthService,
private environmentService: EnvironmentService,
private moduleRef: ModuleRef,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -169,8 +176,17 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('logout')
async logout(@Res({ passthrough: true }) res: FastifyReply) {
async logout(
@AuthUser() user: User,
@Res({ passthrough: true }) res: FastifyReply,
) {
res.clearCookie('authToken');
this.auditService.log({
event: AuditEvent.USER_LOGOUT,
resourceType: AuditResource.USER,
resourceId: user.id,
});
}
setAuthCookie(res: FastifyReply, token: string) {
@@ -1,5 +1,6 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
UnauthorizedException,
@@ -13,6 +14,7 @@ import { UserRepo } from '@docmost/db/repos/user/user.repo';
import {
comparePasswordHash,
hashPassword,
isUserDisabled,
nanoIdGen,
} from '../../../common/helpers';
import { ChangePasswordDto } from '../dto/change-password.dto';
@@ -29,6 +31,11 @@ import { InjectKysely } from 'nestjs-kysely';
import { executeTx } from '@docmost/db/utils';
import { VerifyUserTokenDto } from '../dto/verify-user-token.dto';
import { DomainService } from '../../../integrations/environment/domain.service';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class AuthService {
@@ -40,6 +47,7 @@ export class AuthService {
private mailService: MailService,
private domainService: DomainService,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async login(loginDto: LoginDto, workspaceId: string) {
@@ -48,7 +56,7 @@ export class AuthService {
});
const errorMessage = 'Email or password does not match';
if (!user || user?.deletedAt) {
if (!user || isUserDisabled(user)) {
throw new UnauthorizedException(errorMessage);
}
@@ -64,6 +72,13 @@ export class AuthService {
user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId);
this.auditService.log({
event: AuditEvent.USER_LOGIN,
resourceType: AuditResource.USER,
resourceId: user.id,
metadata: { source: 'password' },
});
return this.tokenService.generateAccessToken(user);
}
@@ -89,7 +104,7 @@ export class AuthService {
includePassword: true,
});
if (!user || user.deletedAt) {
if (!user || isUserDisabled(user)) {
throw new NotFoundException('User not found');
}
@@ -112,6 +127,12 @@ export class AuthService {
workspaceId,
);
this.auditService.log({
event: AuditEvent.USER_PASSWORD_CHANGED,
resourceType: AuditResource.USER,
resourceId: userId,
});
const emailTemplate = ChangePasswordEmail({ username: user.name });
await this.mailService.sendToQueue({
to: user.email,
@@ -129,22 +150,33 @@ export class AuthService {
workspace.id,
);
if (!user || user.deletedAt) {
if (!user || isUserDisabled(user)) {
return;
}
const token = nanoIdGen(16);
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
await executeTx(this.db, async (trx) => {
await trx
.deleteFrom('userTokens')
.where('userId', '=', user.id)
.where('type', '=', UserTokenType.FORGOT_PASSWORD)
.execute();
await this.userTokenRepo.insertUserToken({
token: token,
userId: user.id,
workspaceId: user.workspaceId,
expiresAt: new Date(new Date().getTime() + 60 * 60 * 1000), // 1 hour
type: UserTokenType.FORGOT_PASSWORD,
await this.userTokenRepo.insertUserToken(
{
token,
userId: user.id,
workspaceId: user.workspaceId,
expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes
type: UserTokenType.FORGOT_PASSWORD,
},
{ trx },
);
});
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
const emailTemplate = ForgotPasswordEmail({
username: user.name,
resetLink: resetLink,
@@ -177,7 +209,7 @@ export class AuthService {
const user = await this.userRepo.findById(userToken.userId, workspace.id, {
includeUserMfa: true,
});
if (!user || user.deletedAt) {
if (!user || isUserDisabled(user)) {
throw new NotFoundException('User not found');
}
@@ -201,6 +233,13 @@ export class AuthService {
.execute();
});
this.auditService.setActorId(user.id);
this.auditService.log({
event: AuditEvent.USER_PASSWORD_RESET,
resourceType: AuditResource.USER,
resourceId: user.id,
});
const emailTemplate = ChangePasswordEmail({ username: user.name });
await this.mailService.sendToQueue({
to: user.email,
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { CreateUserDto } from '../dto/create-user.dto';
import { WorkspaceService } from '../../workspace/services/workspace.service';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
@@ -10,6 +10,11 @@ import { InjectKysely } from 'nestjs-kysely';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { UserRole } from '../../../common/helpers/types/permission';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class SignupService {
@@ -18,6 +23,7 @@ export class SignupService {
private workspaceService: WorkspaceService,
private groupUserRepo: GroupUserRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async signup(
@@ -36,7 +42,7 @@ export class SignupService {
);
}
return await executeTx(
const user = await executeTx(
this.db,
async (trx) => {
// create user
@@ -66,6 +72,24 @@ export class SignupService {
},
trx,
);
this.auditService.log({
event: AuditEvent.USER_CREATED,
resourceType: AuditResource.USER,
resourceId: user.id,
changes: {
after: {
name: user.name,
email: user.email,
role: user.role,
},
},
metadata: {
source: 'signup',
},
});
return user;
}
async initialSetup(
@@ -15,6 +15,7 @@ import {
JwtType,
} from '../dto/jwt-payload';
import { User } from '@docmost/db/types/entity.types';
import { isUserDisabled } from '../../../common/helpers';
@Injectable()
export class TokenService {
@@ -24,7 +25,7 @@ export class TokenService {
) {}
async generateAccessToken(user: User): Promise<string> {
if (user.deactivatedAt || user.deletedAt) {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -38,7 +39,7 @@ export class TokenService {
}
async generateCollabToken(user: User, workspaceId: string): Promise<string> {
if (user.deactivatedAt || user.deletedAt) {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -79,7 +80,7 @@ export class TokenService {
}
async generateMfaToken(user: User, workspaceId: string): Promise<string> {
if (user.deactivatedAt || user.deletedAt) {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -98,7 +99,7 @@ export class TokenService {
expiresIn?: string | number;
}): Promise<string> {
const { apiKeyId, user, workspaceId, expiresIn } = opts;
if (user.deactivatedAt || user.deletedAt) {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -6,7 +6,7 @@ import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { FastifyRequest } from 'fastify';
import { extractBearerTokenFromHeader } from '../../../common/helpers';
import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers';
import { ModuleRef } from '@nestjs/core';
@Injectable()
@@ -53,7 +53,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
if (!user || user.deactivatedAt || user.deletedAt) {
if (!user || isUserDisabled(user)) {
throw new UnauthorizedException();
}
@@ -41,6 +41,7 @@ function buildWorkspaceOwnerAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Audit);
return build();
}
@@ -12,6 +12,7 @@ export enum WorkspaceCaslSubject {
Group = 'group',
Attachment = 'attachment',
API = 'api_key',
Audit = 'audit',
}
export type IWorkspaceAbility =
@@ -20,4 +21,5 @@ export type IWorkspaceAbility =
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
| [WorkspaceCaslAction, WorkspaceCaslSubject.API];
| [WorkspaceCaslAction, WorkspaceCaslSubject.API]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Audit];
@@ -5,6 +5,7 @@ import {
HttpCode,
HttpStatus,
UseGuards,
Inject,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
@@ -24,6 +25,13 @@ 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';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
import { WsService } from '../../ws/ws.service';
@UseGuards(JwtAuthGuard)
@Controller('comments')
@@ -33,6 +41,9 @@ export class CommentController {
private readonly commentRepo: CommentRepo,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
private readonly wsService: WsService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -47,12 +58,9 @@ 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(
const comment = await this.commentService.create(
{
userId: user.id,
page,
@@ -60,6 +68,18 @@ export class CommentController {
},
createCommentDto,
);
this.auditService.log({
event: AuditEvent.COMMENT_CREATED,
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: page.spaceId,
metadata: {
pageId: page.id,
},
});
return comment;
}
@HttpCode(HttpStatus.OK)
@@ -75,10 +95,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,36 +108,34 @@ 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;
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
const comment = await this.commentRepo.findById(dto.commentId);
const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true,
includeResolvedBy: true,
});
if (!comment) {
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,47 +147,51 @@ 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);
await this.commentRepo.deleteComment(comment.id);
} else {
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// 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
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'Only space admins can delete comments with replies from other users',
'You can only delete your own comments or must be a space admin',
);
}*/
}
await this.commentRepo.deleteComment(comment.id);
return;
}
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'You can only delete your own comments or must be a space admin',
);
}
await this.commentRepo.deleteComment(comment.id);
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentDeleted',
pageId: comment.pageId,
commentId: comment.id,
});
this.auditService.log({
event: AuditEvent.COMMENT_DELETED,
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
changes: {
before: {
pageId: comment.pageId,
creatorId: comment.creatorId,
},
},
});
}
}
@@ -17,6 +17,7 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface';
import { WsService } from '../../ws/ws.service';
@Injectable()
export class CommentService {
@@ -25,6 +26,7 @@ export class CommentService {
constructor(
private commentRepo: CommentRepo,
private pageRepo: PageRepo,
private wsService: WsService,
@InjectQueue(QueueName.GENERAL_QUEUE)
private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
@@ -63,17 +65,22 @@ export class CommentService {
}
}
const comment = await this.commentRepo.insertComment({
const inserted = await this.commentRepo.insertComment({
pageId: page.id,
content: commentContent,
selection: createCommentDto?.selection?.substring(0, 250),
type: 'inline',
selection: createCommentDto?.selection?.substring(0, 250) ?? null,
type: createCommentDto.type ?? 'page',
parentCommentId: createCommentDto?.parentCommentId,
creatorId: userId,
workspaceId: workspaceId,
spaceId: page.spaceId,
});
const comment = await this.commentRepo.findById(inserted.id, {
includeCreator: true,
includeResolvedBy: true,
});
this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [userId],
@@ -99,6 +106,12 @@ export class CommentService {
createCommentDto.parentCommentId,
);
this.wsService.emitCommentEvent(page.spaceId, page.id, {
operation: 'commentCreated',
pageId: page.id,
comment,
});
return comment;
}
@@ -154,6 +167,12 @@ export class CommentService {
comment.editedAt = editedAt;
comment.updatedAt = editedAt;
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment,
});
return comment;
}
@@ -1,4 +1,4 @@
import { IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsIn, IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateCommentDto {
@IsString()
@@ -11,6 +11,10 @@ export class CreateCommentDto {
@IsString()
selection: string;
@IsOptional()
@IsIn(['inline', 'page'])
type: string;
@IsOptional()
@IsUUID()
parentCommentId: string;
+17 -6
View File
@@ -14,11 +14,14 @@ 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 { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
import { BaseModule } from './base/base.module';
import { ClsMiddleware } from 'nestjs-cls';
@Module({
imports: [
@@ -32,6 +35,7 @@ import { BaseModule } from './base/base.module';
SpaceModule,
GroupModule,
CaslModule,
PageAccessModule,
ShareModule,
NotificationModule,
WatcherModule,
@@ -40,14 +44,21 @@ import { BaseModule } from './base/base.module';
})
export class CoreModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
const excludedRoutes = [
{ path: 'auth/setup', method: RequestMethod.POST },
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/live', method: RequestMethod.GET },
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
];
consumer
.apply(DomainMiddleware)
.exclude(
{ path: 'auth/setup', method: RequestMethod.POST },
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/live', method: RequestMethod.GET },
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
)
.exclude(...excludedRoutes)
.forRoutes('*');
consumer
.apply(AuditContextMiddleware)
.exclude(...excludedRoutes)
.forRoutes('*');
}
}
@@ -14,6 +14,11 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { executeTx } from '@docmost/db/utils';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class GroupUserService {
@@ -25,6 +30,7 @@ export class GroupUserService {
private groupService: GroupService,
private readonly watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getGroupUsers(
@@ -72,6 +78,20 @@ export class GroupUserService {
.values(groupUsersToInsert)
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
.execute();
for (const user of validUsers) {
this.auditService.log({
event: AuditEvent.GROUP_MEMBER_ADDED,
resourceType: AuditResource.GROUP,
resourceId: groupId,
changes: {
after: {
userId: user.id,
userName: user.name,
},
},
});
}
}
async removeUserFromGroup(
@@ -115,8 +135,24 @@ export class GroupUserService {
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
[userId],
spaceId,
{ trx },
);
}
});
this.auditService.log({
event: AuditEvent.GROUP_MEMBER_REMOVED,
resourceType: AuditResource.GROUP,
resourceId: groupId,
changes: {
before: {
userId: user.id,
userName: user.name,
},
},
metadata: {
groupName: group.name,
},
});
}
}
@@ -18,6 +18,12 @@ import { GroupUserService } from './group-user.service';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import { diffAuditTrackedFields } from '../../../common/helpers';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class GroupService {
@@ -29,6 +35,7 @@ export class GroupService {
private groupUserService: GroupUserService,
private readonly watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getGroupInfo(groupId: string, workspaceId: string): Promise<Group> {
@@ -74,6 +81,18 @@ export class GroupService {
);
}
this.auditService.log({
event: AuditEvent.GROUP_CREATED,
resourceType: AuditResource.GROUP,
resourceId: createdGroup.id,
changes: {
after: {
name: createdGroup.name,
description: createdGroup.description,
},
},
});
return createdGroup;
}
@@ -95,6 +114,8 @@ export class GroupService {
throw new BadRequestException('You cannot update a default group');
}
const groupBefore = { name: group.name, description: group.description };
if (updateGroupDto.name) {
const existingGroup = await this.groupRepo.findByName(
updateGroupDto.name,
@@ -121,6 +142,22 @@ export class GroupService {
workspaceId,
);
const changes = diffAuditTrackedFields(
['name', 'description'],
updateGroupDto,
groupBefore,
group,
);
if (changes) {
this.auditService.log({
event: AuditEvent.GROUP_UPDATED,
resourceType: AuditResource.GROUP,
resourceId: group.id,
changes,
});
}
return group;
}
@@ -154,6 +191,18 @@ export class GroupService {
);
}
});
this.auditService.log({
event: AuditEvent.GROUP_DELETED,
resourceType: AuditResource.GROUP,
resourceId: groupId,
changes: {
before: {
name: group.name,
description: group.description,
},
},
});
}
async findAndValidateGroup(
@@ -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 };
}
}
+228 -44
View File
@@ -5,11 +5,13 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Post,
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';
@@ -24,7 +26,7 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
import { SidebarPageDto } from './dto/sidebar-page.dto';
import {
SpaceCaslAction,
@@ -39,6 +41,12 @@ import {
jsonToHtml,
jsonToMarkdown,
} from '../../collaboration/collaboration.util';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
import { getPageTitle } from '../../common/helpers';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@@ -48,6 +56,8 @@ export class PageController {
private readonly pageRepo: PageRepo,
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -65,10 +75,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 +88,11 @@ export class PageController {
return {
...page,
content: contentOutput,
permissions,
};
}
return page;
return { ...page, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -91,12 +102,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 +132,24 @@ export class PageController {
createPageDto,
);
const { canEdit, hasRestriction } =
await this.pageAccessService.validateCanViewWithPermissions(page, user);
const permissions = { canEdit, hasRestriction };
this.auditService.log({
event: AuditEvent.PAGE_CREATED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
after: {
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
if (
createPageDto.format &&
createPageDto.format !== 'json' &&
@@ -114,10 +159,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 +174,10 @@ 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 +185,8 @@ export class PageController {
user,
);
const permissions = { canEdit: true, hasRestriction };
if (
updatePageDto.format &&
updatePageDto.format !== 'json' &&
@@ -149,10 +196,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)
@@ -178,16 +225,45 @@ export class PageController {
);
}
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
this.auditService.log({
event: AuditEvent.PAGE_DELETED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
before: {
pageId: page.id,
slugId: page.slugId,
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
} 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,
workspace.id,
);
this.auditService.log({
event: AuditEvent.PAGE_TRASHED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
before: {
pageId: page.id,
slugId: page.slugId,
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
}
}
@@ -204,13 +280,30 @@ export class PageController {
throw new NotFoundException('Page not found');
}
// only users with "can edit" space level permission can restore pages
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// make sure they have page level access to the page
await this.pageAccessService.validateCanEdit(page, user);
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
this.auditService.log({
event: AuditEvent.PAGE_RESTORED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
after: {
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
return this.pageRepo.findById(pageIdDto.pageId, {
includeHasChildren: true,
});
@@ -235,6 +328,7 @@ export class PageController {
return this.pageService.getRecentSpacePages(
recentPageDto.spaceId,
user.id,
pagination,
);
}
@@ -255,12 +349,13 @@ export class PageController {
deletedPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.getDeletedSpacePages(
deletedPageDto.spaceId,
user.id,
pagination,
);
}
@@ -278,10 +373,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 +389,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 +428,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 +469,30 @@ 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
const { childPageIds } = await this.pageService.movePageToSpace(
movedPage,
dto.spaceId,
user.id,
);
this.auditService.log({
event: AuditEvent.PAGE_MOVED_TO_SPACE,
resourceType: AuditResource.PAGE,
resourceId: movedPage.id,
spaceId: movedPage.spaceId,
changes: {
before: { spaceId: movedPage.spaceId },
after: { spaceId: dto.spaceId },
},
metadata: {
title: getPageTitle(movedPage.title),
...(childPageIds.length > 0 && { childPageIds }),
},
});
}
@HttpCode(HttpStatus.OK)
@@ -376,6 +503,12 @@ 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);
let result;
// If spaceId is provided, it's a copy to different space
if (dto.spaceId) {
const abilities = await Promise.all([
@@ -391,7 +524,27 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.duplicatePage(copiedPage, dto.spaceId, user);
result = await this.pageService.duplicatePage(
copiedPage,
dto.spaceId,
user,
);
this.auditService.log({
event: AuditEvent.PAGE_DUPLICATED,
resourceType: AuditResource.PAGE,
resourceId: result.id,
spaceId: dto.spaceId,
metadata: {
sourcePageId: copiedPage.id,
title: getPageTitle(copiedPage.title),
sourceSpaceId: copiedPage.spaceId,
targetSpaceId: dto.spaceId,
...(result.childPageIds.length > 0 && {
childPageIds: result.childPageIds,
}),
},
});
} else {
// If no spaceId, it's a duplicate in same space
const ability = await this.spaceAbility.createForUser(
@@ -402,8 +555,28 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.duplicatePage(copiedPage, undefined, user);
result = await this.pageService.duplicatePage(
copiedPage,
undefined,
user,
);
this.auditService.log({
event: AuditEvent.PAGE_DUPLICATED,
resourceType: AuditResource.PAGE,
resourceId: result.id,
spaceId: copiedPage.spaceId,
metadata: {
sourcePageId: copiedPage.id,
title: getPageTitle(copiedPage.title),
...(result.childPageIds.length > 0 && {
childPageIds: result.childPageIds,
}),
},
});
}
return result;
}
@HttpCode(HttpStatus.OK)
@@ -418,10 +591,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 +619,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,99 @@ 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) {
let childPageIds: 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,52 +412,62 @@ 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);
childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id);
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
await this.pageRepo.updatePages(
{ spaceId },
pageIds.filter((id) => id !== rootPage.id),
childPageIds,
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,
});
}
});
return { childPageIds };
}
async duplicatePage(
@@ -381,10 +489,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, {
@@ -570,10 +686,12 @@ export class PageService {
});
const hasChildren = pages.length > 1;
const childPageIds = insertedPageIds.filter((id) => id !== newPageId);
return {
...duplicatedPage,
hasChildren,
childPageIds,
};
}
@@ -592,7 +710,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 +745,6 @@ export class PageService {
'spaceId',
'deletedAt',
])
.select((eb) => this.pageRepo.withHasChildren(eb))
.where('id', '=', childPageId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
@@ -639,30 +760,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 +782,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 +937,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));
}
}
@@ -6,10 +6,11 @@ import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
const DEFAULT_RETENTION_DAYS = 30;
@Injectable()
export class TrashCleanupService {
private readonly logger = new Logger(TrashCleanupService.name);
private readonly RETENTION_DAYS = 30;
constructor(
@InjectKysely() private readonly db: KyselyDB,
@@ -21,36 +22,46 @@ export class TrashCleanupService {
try {
this.logger.debug('Starting trash cleanup job');
const retentionDate = new Date();
retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
// Get all pages that were deleted more than 30 days ago
const oldDeletedPages = await this.db
.selectFrom('pages')
.select(['id', 'spaceId', 'workspaceId'])
.where('deletedAt', '<', retentionDate)
const workspaces = await this.db
.selectFrom('workspaces')
.select(['id', 'trashRetentionDays'])
.where('deletedAt', 'is', null)
.execute();
if (oldDeletedPages.length === 0) {
this.logger.debug('No old trash items to clean up');
return;
}
let totalCleaned = 0;
this.logger.debug(`Found ${oldDeletedPages.length} pages to clean up`);
for (const workspace of workspaces) {
const retentionDays =
workspace.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
// Process each page
for (const page of oldDeletedPages) {
try {
await this.cleanupPage(page.id);
} catch (error) {
this.logger.error(
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error.stack : undefined,
);
const retentionDate = new Date();
retentionDate.setDate(retentionDate.getDate() - retentionDays);
const oldDeletedPages = await this.db
.selectFrom('pages')
.select(['id'])
.where('workspaceId', '=', workspace.id)
.where('deletedAt', '<', retentionDate)
.execute();
for (const page of oldDeletedPages) {
try {
await this.cleanupPage(page.id);
totalCleaned++;
} catch (error) {
this.logger.error(
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error.stack : undefined,
);
}
}
}
this.logger.debug('Trash cleanup job completed');
this.logger.debug(
totalCleaned > 0
? `Trash cleanup completed: ${totalCleaned} pages cleaned`
: 'No old trash items to clean up',
);
} catch (error) {
this.logger.error(
'Trash cleanup job failed',
@@ -5,5 +5,6 @@ import { SearchService } from './search.service';
@Module({
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],
})
export class SearchModule {}
+40 -10
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, ' ')
@@ -183,6 +198,7 @@ export class SearchService {
let pageSearch = this.db
.selectFrom('pages')
.select(['id', 'slugId', 'title', 'icon', 'spaceId'])
.select((eb) => this.pageRepo.withSpace(eb))
.where((eb) =>
eb(
sql`LOWER(f_unaccent(pages.title))`,
@@ -194,19 +210,33 @@ export class SearchService {
.where('workspaceId', '=', workspaceId)
.limit(limit);
// only search spaces the user has access to
// search all spaces the user has access to, prioritizing the current space
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
if (suggestion?.spaceId) {
if (userSpaceIds.includes(suggestion.spaceId)) {
pageSearch = pageSearch.where('spaceId', '=', suggestion.spaceId);
pages = await pageSearch.execute();
}
} else if (userSpaceIds?.length > 0) {
// we need this check or the query will throw an error if the userSpaceIds array is empty
if (userSpaceIds?.length > 0) {
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
if (suggestion?.spaceId) {
pageSearch = pageSearch.orderBy(
sql`CASE WHEN pages."space_id" = ${suggestion.spaceId} THEN 0 ELSE 1 END`,
'asc',
);
}
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,
});
const accessibleSet = new Set(accessibleIds);
pages = pages.filter((p) => accessibleSet.has(p.id));
}
}
return { users, groups, pages };
+62 -20
View File
@@ -5,18 +5,14 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Post,
UseGuards,
} 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,22 +22,31 @@ 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';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { hasLicenseOrEE } from '../../common/helpers';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
@UseGuards(JwtAuthGuard)
@Controller('shares')
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,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -119,10 +124,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 +142,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(
@@ -153,12 +163,25 @@ export class ShareController {
throw new ForbiddenException('Public sharing is disabled');
}
return this.shareService.createShare({
const share = await this.shareService.createShare({
page,
authUserId: user.id,
workspaceId: workspace.id,
createShareDto,
});
this.auditService.log({
event: AuditEvent.SHARE_CREATED,
resourceType: AuditResource.SHARE,
resourceId: share.id,
spaceId: page.spaceId,
metadata: {
pageId: page.id,
spaceId: page.spaceId,
},
});
return share;
}
@HttpCode(HttpStatus.OK)
@@ -170,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.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,12 +213,28 @@ 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);
this.auditService.log({
event: AuditEvent.SHARE_DELETED,
resourceType: AuditResource.SHARE,
resourceId: share.id,
spaceId: share.spaceId,
changes: {
before: {
pageId: share.pageId,
spaceId: share.spaceId,
},
},
});
}
@Public()
+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 };
@@ -1,5 +1,6 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -17,6 +18,11 @@ import { SpaceRole } from '../../../common/helpers/types/permission';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { executeTx } from '@docmost/db/utils';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class SpaceMemberService {
@@ -26,6 +32,7 @@ export class SpaceMemberService {
private spaceRepo: SpaceRepo,
private watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async addUserToSpace(
@@ -90,7 +97,6 @@ export class SpaceMemberService {
authUser: User,
workspaceId: string,
): Promise<void> {
// await this.spaceService.findAndValidateSpace(spaceId, workspaceId);
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
@@ -164,8 +170,45 @@ export class SpaceMemberService {
if (membersToAdd.length > 0) {
await this.spaceMemberRepo.insertSpaceMember(membersToAdd);
} else {
// either they are already members or do not exist on the workspace
// Audit log for each member added
for (const user of validUsers) {
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ADDED,
resourceType: AuditResource.SPACE_MEMBER,
resourceId: dto.spaceId,
spaceId: dto.spaceId,
changes: {
after: { role: dto.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
userId: user.id,
userName: user.name,
memberType: 'user',
},
});
}
for (const group of validGroups) {
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ADDED,
resourceType: AuditResource.SPACE_MEMBER,
resourceId: dto.spaceId,
spaceId: dto.spaceId,
changes: {
after: { role: dto.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
groupId: group.id,
groupName: group.name,
memberType: 'group',
},
});
}
}
}
@@ -230,6 +273,23 @@ export class SpaceMemberService {
{ trx },
);
});
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_REMOVED,
resourceType: AuditResource.SPACE_MEMBER,
resourceId: dto.spaceId,
spaceId: dto.spaceId,
changes: {
before: { role: spaceMember.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
userId: spaceMember.userId,
groupId: spaceMember.groupId,
memberType: spaceMember.userId ? 'user' : 'group',
},
});
}
async updateSpaceMemberRole(
@@ -280,6 +340,24 @@ export class SpaceMemberService {
spaceMember.id,
dto.spaceId,
);
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
resourceType: AuditResource.SPACE_MEMBER,
resourceId: dto.spaceId,
spaceId: dto.spaceId,
changes: {
before: { role: spaceMember.role },
after: { role: dto.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
userId: spaceMember.userId,
groupId: spaceMember.groupId,
memberType: spaceMember.userId ? 'user' : 'group',
},
});
}
async validateLastAdmin(spaceId: string): Promise<void> {
@@ -1,6 +1,7 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -21,6 +22,12 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import { diffAuditTrackedFields } from '../../../common/helpers';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class SpaceService {
@@ -32,6 +39,7 @@ export class SpaceService {
private licenseCheckService: LicenseCheckService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async createSpace(
@@ -63,6 +71,19 @@ export class SpaceService {
trx,
);
this.auditService.log({
event: AuditEvent.SPACE_CREATED,
resourceType: AuditResource.SPACE,
resourceId: space.id,
spaceId: space.id,
changes: {
after: {
name: space.name,
slug: space.slug,
},
},
});
return { ...space, memberCount: 1 };
}
@@ -124,28 +145,74 @@ export class SpaceService {
'This feature requires a valid enterprise license',
);
}
await this.spaceRepo.updateSharingSettings(
updateSpaceDto.spaceId,
workspaceId,
'disabled',
updateSpaceDto.disablePublicSharing,
);
if (updateSpaceDto.disablePublicSharing) {
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId);
}
}
return await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
description: updateSpaceDto.description,
slug: updateSpaceDto.slug,
},
const spaceBefore = await this.spaceRepo.findById(
updateSpaceDto.spaceId,
workspaceId,
);
const settingsBefore = (spaceBefore?.settings ?? {}) as Record<string, any>;
const before: Record<string, any> = {};
const after: Record<string, any> = {};
let updatedSpace: Space;
await executeTx(this.db, async (trx) => {
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
const prev = settingsBefore?.sharing?.disabled ?? false;
if (prev !== updateSpaceDto.disablePublicSharing) {
before.disablePublicSharing = prev;
after.disablePublicSharing = updateSpaceDto.disablePublicSharing;
}
await this.spaceRepo.updateSharingSettings(
updateSpaceDto.spaceId,
workspaceId,
'disabled',
updateSpaceDto.disablePublicSharing,
trx,
);
if (updateSpaceDto.disablePublicSharing) {
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId, trx);
}
}
updatedSpace = await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
description: updateSpaceDto.description,
slug: updateSpaceDto.slug,
},
updateSpaceDto.spaceId,
workspaceId,
trx,
);
});
const columnChanges = diffAuditTrackedFields(
['name', 'slug', 'description'],
updateSpaceDto,
spaceBefore,
updatedSpace,
);
if (columnChanges) {
Object.assign(before, columnChanges.before);
Object.assign(after, columnChanges.after);
}
if (Object.keys(after).length > 0) {
this.auditService.log({
event: AuditEvent.SPACE_UPDATED,
resourceType: AuditResource.SPACE,
resourceId: updateSpaceDto.spaceId,
spaceId: updateSpaceDto.spaceId,
changes: { before, after },
});
}
return updatedSpace;
}
async getSpaceInfo(spaceId: string, workspaceId: string): Promise<Space> {
@@ -174,5 +241,19 @@ export class SpaceService {
await this.spaceRepo.deleteSpace(spaceId, workspaceId);
await this.attachmentQueue.add(QueueJob.DELETE_SPACE_ATTACHMENTS, space);
this.auditService.log({
event: AuditEvent.SPACE_DELETED,
resourceType: AuditResource.SPACE,
resourceId: spaceId,
spaceId: spaceId,
changes: {
before: {
name: space.name,
slug: space.slug,
description: space.description,
},
},
});
}
}
+30 -2
View File
@@ -1,18 +1,27 @@
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { UpdateUserDto } from './dto/update-user.dto';
import { comparePasswordHash } from 'src/common/helpers/utils';
import { comparePasswordHash, diffAuditTrackedFields } from 'src/common/helpers/utils';
import { Workspace } from '@docmost/db/types/entity.types';
import { validateSsoEnforcement } from '../auth/auth.util';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
@Injectable()
export class UserService {
constructor(private userRepo: UserRepo) {}
constructor(
private userRepo: UserRepo,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async findById(userId: string, workspaceId: string) {
return this.userRepo.findById(userId, workspaceId);
@@ -51,6 +60,8 @@ export class UserService {
);
}
const userBefore = { name: user.name, email: user.email, locale: user.locale };
if (updateUserDto.name) {
user.name = updateUserDto.name;
}
@@ -91,6 +102,23 @@ export class UserService {
delete updateUserDto.confirmPassword;
await this.userRepo.updateUser(updateUserDto, userId, workspace.id);
const changes = diffAuditTrackedFields(
['name', 'email'],
updateUserDto,
userBefore,
user,
);
if (changes) {
this.auditService.log({
event: AuditEvent.USER_UPDATED,
resourceType: AuditResource.USER,
resourceId: userId,
changes,
});
}
return user;
}
}
@@ -109,6 +109,7 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK)
@Post('members/deactivate')
async deactivateWorkspaceMember(
@Body() dto: RemoveWorkspaceUserDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
@@ -118,6 +119,23 @@ export class WorkspaceController {
) {
throw new ForbiddenException();
}
await this.workspaceService.deactivateUser(user, dto.userId, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('members/activate')
async activateWorkspaceMember(
@Body() dto: RemoveWorkspaceUserDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member)
) {
throw new ForbiddenException();
}
await this.workspaceService.activateUser(user, dto.userId, workspace.id);
}
@HttpCode(HttpStatus.OK)
@@ -1,6 +1,13 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateWorkspaceDto } from './create-workspace.dto';
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
import {
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@@ -34,4 +41,13 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
@IsOptional()
@IsBoolean()
mcpEnabled: boolean;
@IsOptional()
@IsInt()
@Min(1)
trashRetentionDays: number;
}
@@ -1,5 +1,6 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
@@ -33,6 +34,11 @@ import {
validateAllowedEmail,
validateSsoEnforcement,
} from '../../auth/auth.util';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class WorkspaceInvitationService {
@@ -46,6 +52,7 @@ export class WorkspaceInvitationService {
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
private readonly environmentService: EnvironmentService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
@@ -180,6 +187,24 @@ export class WorkspaceInvitationService {
workspace.hostname,
);
});
// Audit log for each invitation created
for (const invitation of invites) {
this.auditService.log({
event: AuditEvent.WORKSPACE_INVITE_CREATED,
resourceType: AuditResource.WORKSPACE_INVITATION,
resourceId: invitation.id,
changes: {
after: {
email: invitation.email,
role: invitation.role,
},
},
metadata: {
groupIds: invitation.groupIds,
},
});
}
}
}
@@ -296,6 +321,23 @@ export class WorkspaceInvitationService {
});
}
this.auditService.log({
event: AuditEvent.USER_CREATED,
resourceType: AuditResource.USER,
resourceId: newUser.id,
changes: {
after: {
name: newUser.name,
email: newUser.email,
role: invitation.role,
},
},
metadata: {
source: 'invitation',
invitationId: invitation.id,
},
});
if (this.environmentService.isCloud()) {
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, {
workspaceId: workspace.id,
@@ -339,17 +381,48 @@ export class WorkspaceInvitationService {
invitedByUser.name,
workspace.hostname,
);
this.auditService.log({
event: AuditEvent.WORKSPACE_INVITE_RESENT,
resourceType: AuditResource.WORKSPACE_INVITATION,
resourceId: invitation.id,
metadata: {
email: invitation.email,
role: invitation.role,
},
});
}
async revokeInvitation(
invitationId: string,
workspaceId: string,
): Promise<void> {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.select(['id', 'email', 'role'])
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
await this.db
.deleteFrom('workspaceInvitations')
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.execute();
if (invitation) {
this.auditService.log({
event: AuditEvent.WORKSPACE_INVITE_REVOKED,
resourceType: AuditResource.WORKSPACE_INVITATION,
resourceId: invitation.id,
changes: {
before: {
email: invitation.email,
role: invitation.role,
},
},
});
}
}
async getInvitationLinkById(
@@ -1,6 +1,7 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
@@ -31,11 +32,19 @@ import { v4 } from 'uuid';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers';
import {
generateRandomSuffixNumbers,
diffAuditTrackedFields,
} from '../../../common/helpers';
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class WorkspaceService {
@@ -57,6 +66,7 @@ export class WorkspaceService {
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async findById(workspaceId: string) {
@@ -280,7 +290,7 @@ export class WorkspaceService {
if (updateWorkspaceDto.enforceSso) {
const sso = await this.db
.selectFrom('authProviders')
.selectAll()
.select(['id'])
.where('isEnabled', '=', true)
.where('workspaceId', '=', workspaceId)
.execute();
@@ -295,9 +305,7 @@ export class WorkspaceService {
if (updateWorkspaceDto.emailDomains) {
const regex =
/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/;
const emailDomains = updateWorkspaceDto.emailDomains || [];
updateWorkspaceDto.emailDomains = emailDomains
.map((domain) => regex.exec(domain)?.[0])
.filter(Boolean);
@@ -313,93 +321,187 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
await this.workspaceRepo.updateApiSettings(
workspaceId,
'restrictToAdmins',
updateWorkspaceDto.restrictApiToAdmins,
);
delete updateWorkspaceDto.restrictApiToAdmins;
}
const before: Record<string, any> = {};
const after: Record<string, any> = {};
if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
await this.workspaceRepo.updateAiSettings(
workspaceId,
'search',
updateWorkspaceDto.aiSearch,
);
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
.select(['id', 'licenseKey', 'trashRetentionDays'])
.where('id', '=', workspaceId)
.executeTakeFirst();
if (updateWorkspaceDto.aiSearch) {
const tableExists = await isPageEmbeddingsTableExists(this.db);
if (!tableExists) {
throw new BadRequestException(
'Failed to activate. Make sure pgvector postgres extension is installed.',
);
}
await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
workspaceId,
});
} else {
// Schedule deletion after 24 hours
const deleteJobId = `ai-search-disabled-${workspaceId}`;
await this.aiQueue.add(
QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
{ workspaceId },
{
jobId: deleteJobId,
delay: 24 * 60 * 60 * 1000,
removeOnComplete: true,
removeOnFail: true,
},
);
}
delete updateWorkspaceDto.aiSearch;
}
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
await this.workspaceRepo.updateAiSettings(
workspaceId,
'generative',
updateWorkspaceDto.generativeAi,
);
delete updateWorkspaceDto.generativeAi;
}
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey)
) {
if (!this.licenseCheckService.isValidEELicense(ws.licenseKey)) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
await this.workspaceRepo.updateSharingSettings(
workspaceId,
'disabled',
updateWorkspaceDto.disablePublicSharing,
);
if (updateWorkspaceDto.disablePublicSharing) {
await this.shareRepo.deleteByWorkspaceId(workspaceId);
if (
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' &&
updateWorkspaceDto.trashRetentionDays !== ws.trashRetentionDays
) {
before.trashRetentionDays = ws.trashRetentionDays;
after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays;
}
delete updateWorkspaceDto.disablePublicSharing;
}
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
if (updateWorkspaceDto.aiSearch) {
const tableExists = await isPageEmbeddingsTableExists(this.db);
if (!tableExists) {
throw new BadRequestException(
'Failed to activate. Make sure pgvector postgres extension is installed.',
);
}
}
const workspaceBefore = await this.workspaceRepo.findById(workspaceId);
const settingsBefore = (workspaceBefore?.settings ?? {}) as Record<
string,
any
>;
await executeTx(this.db, async (trx) => {
if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
const prev = settingsBefore?.api?.restrictToAdmins ?? false;
if (prev !== updateWorkspaceDto.restrictApiToAdmins) {
before.restrictApiToAdmins = prev;
after.restrictApiToAdmins = updateWorkspaceDto.restrictApiToAdmins;
}
await this.workspaceRepo.updateApiSettings(
workspaceId,
'restrictToAdmins',
updateWorkspaceDto.restrictApiToAdmins,
trx,
);
}
if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
const prev = settingsBefore?.ai?.search ?? false;
if (prev !== updateWorkspaceDto.aiSearch) {
before.aiSearch = prev;
after.aiSearch = updateWorkspaceDto.aiSearch;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
'search',
updateWorkspaceDto.aiSearch,
trx,
);
}
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
const prev = settingsBefore?.ai?.generative ?? false;
if (prev !== updateWorkspaceDto.generativeAi) {
before.generativeAi = prev;
after.generativeAi = updateWorkspaceDto.generativeAi;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
'generative',
updateWorkspaceDto.generativeAi,
trx,
);
}
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const prev = settingsBefore?.sharing?.disabled ?? false;
if (prev !== updateWorkspaceDto.disablePublicSharing) {
before.disablePublicSharing = prev;
after.disablePublicSharing = updateWorkspaceDto.disablePublicSharing;
}
await this.workspaceRepo.updateSharingSettings(
workspaceId,
'disabled',
updateWorkspaceDto.disablePublicSharing,
trx,
);
if (updateWorkspaceDto.disablePublicSharing) {
await this.shareRepo.deleteByWorkspaceId(workspaceId, trx);
}
}
if (typeof updateWorkspaceDto.mcpEnabled !== 'undefined') {
const prev = settingsBefore?.ai?.mcp ?? false;
if (prev !== updateWorkspaceDto.mcpEnabled) {
before.mcpEnabled = prev;
after.mcpEnabled = updateWorkspaceDto.mcpEnabled;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
'mcp',
updateWorkspaceDto.mcpEnabled,
trx,
);
}
delete updateWorkspaceDto.restrictApiToAdmins;
delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
delete updateWorkspaceDto.disablePublicSharing;
delete updateWorkspaceDto.mcpEnabled;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
workspaceId,
trx,
);
});
if (after.aiSearch === true) {
await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
workspaceId,
});
} else if (after.aiSearch === false) {
const deleteJobId = `ai-search-disabled-${workspaceId}`;
await this.aiQueue.add(
QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
{ workspaceId },
{
jobId: deleteJobId,
delay: 24 * 60 * 60 * 1000,
removeOnComplete: true,
removeOnFail: true,
},
);
}
const workspace = await this.workspaceRepo.findById(workspaceId, {
withMemberCount: true,
withLicenseKey: true,
});
const columnChanges = diffAuditTrackedFields(
[
'name',
'logo',
'enforceSso',
'enforceMfa',
'emailDomains',
],
updateWorkspaceDto,
workspaceBefore,
workspace,
);
if (columnChanges) {
Object.assign(before, columnChanges.before);
Object.assign(after, columnChanges.after);
}
if (Object.keys(after).length > 0) {
this.auditService.log({
event: AuditEvent.WORKSPACE_UPDATED,
resourceType: AuditResource.WORKSPACE,
resourceId: workspaceId,
changes: { before, after },
});
}
const { licenseKey, ...rest } = workspace;
return {
...rest,
@@ -457,6 +559,16 @@ export class WorkspaceService {
user.id,
workspaceId,
);
this.auditService.log({
event: AuditEvent.USER_ROLE_CHANGED,
resourceType: AuditResource.USER,
resourceId: user.id,
changes: {
before: { role: user.role },
after: { role: newRole },
},
});
}
async generateHostname(
@@ -505,6 +617,105 @@ export class WorkspaceService {
return { hostname: this.domainService.getUrl(hostname) };
}
async deactivateUser(
authUser: User,
userId: string,
workspaceId: string,
): Promise<void> {
const user = await this.userRepo.findById(userId, workspaceId);
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
if (user.deactivatedAt) {
throw new BadRequestException('User is already deactivated');
}
if (authUser.id === userId) {
throw new BadRequestException('You cannot deactivate yourself');
}
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
throw new BadRequestException(
'You cannot deactivate a user with owner role',
);
}
if (user.role === UserRole.OWNER) {
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
}
await this.userRepo.updateUser(
{ deactivatedAt: new Date() },
userId,
workspaceId,
);
this.auditService.log({
event: AuditEvent.USER_DEACTIVATED,
resourceType: AuditResource.USER,
resourceId: user.id,
changes: {
before: {
name: user.name,
email: user.email,
role: user.role,
},
},
});
}
async activateUser(
authUser: User,
userId: string,
workspaceId: string,
): Promise<void> {
const user = await this.userRepo.findById(userId, workspaceId);
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
if (!user.deactivatedAt) {
throw new BadRequestException('User is not deactivated');
}
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
throw new BadRequestException(
'You cannot activate a user with owner role',
);
}
await this.userRepo.updateUser(
{ deactivatedAt: null },
userId,
workspaceId,
);
this.auditService.log({
event: AuditEvent.USER_ACTIVATED,
resourceType: AuditResource.USER,
resourceId: user.id,
changes: {
before: {
name: user.name,
email: user.email,
role: user.role,
},
},
});
}
async deleteUser(
authUser: User,
userId: string,
@@ -564,6 +775,19 @@ export class WorkspaceService {
});
});
this.auditService.log({
event: AuditEvent.USER_DELETED,
resourceType: AuditResource.USER,
resourceId: user.id,
changes: {
before: {
name: user.name,
email: user.email,
role: user.role,
},
},
});
try {
await this.attachmentQueue.add(QueueJob.DELETE_USER_AVATARS, user);
} catch (err) {
+5 -16
View File
@@ -1,10 +1,4 @@
import {
Global,
Logger,
Module,
OnApplicationBootstrap,
BeforeApplicationShutdown,
} from '@nestjs/common';
import { Global, Logger, Module, OnApplicationBootstrap } from '@nestjs/common';
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
import { EnvironmentService } from '../integrations/environment/environment.service';
import { CamelCasePlugin, LogEvent, sql } from 'kysely';
@@ -15,6 +9,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';
@@ -80,6 +75,7 @@ import { normalizePostgresUrl } from '../common/helpers';
SpaceRepo,
SpaceMemberRepo,
PageRepo,
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
@@ -102,6 +98,7 @@ import { normalizePostgresUrl } from '../common/helpers';
SpaceRepo,
SpaceMemberRepo,
PageRepo,
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
@@ -116,9 +113,7 @@ import { normalizePostgresUrl } from '../common/helpers';
BaseViewRepo,
],
})
export class DatabaseModule
implements OnApplicationBootstrap, BeforeApplicationShutdown
{
export class DatabaseModule implements OnApplicationBootstrap {
private readonly logger = new Logger(DatabaseModule.name);
constructor(
@@ -135,12 +130,6 @@ export class DatabaseModule
}
}
async beforeApplicationShutdown(): Promise<void> {
if (this.db) {
await this.db.destroy();
}
}
async establishConnection() {
const retryAttempts = 15;
const retryDelay = 3000;
@@ -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();
}
@@ -0,0 +1,60 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('audit')
.ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('actor_id', 'uuid')
.addColumn('actor_type', 'varchar', (col) =>
col.notNull().defaultTo('user'),
)
.addColumn('event', 'varchar', (col) => col.notNull())
.addColumn('resource_type', 'varchar', (col) => col.notNull())
.addColumn('resource_id', 'uuid')
.addColumn('space_id', 'uuid')
.addColumn('changes', 'jsonb')
.addColumn('metadata', 'jsonb')
.addColumn('ip_address', sql`inet`)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_audit_workspace_id')
.ifNotExists()
.on('audit')
.columns(['workspace_id', 'id desc'])
.execute();
// add new workspace columns
await db.schema
.alterTable('workspaces')
.addColumn('audit_retention_days', 'int8', (col) => col)
.execute();
await db.schema
.alterTable('workspaces')
.addColumn('trash_retention_days', 'int8', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspaces')
.dropColumn('audit_retention_days')
.execute();
await db.schema
.alterTable('workspaces')
.dropColumn('trash_retention_days')
.execute();
await db.schema.dropTable('audit').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);
}
}
@@ -135,10 +135,12 @@ export class GroupRepo {
direction: 'desc',
key: 'memberCount',
},
{ expression: 'sub.name', direction: 'asc', key: 'name' },
{ expression: 'sub.id', direction: 'asc', key: 'id' },
],
parseCursor: (cursor) => ({
memberCount: parseInt(cursor.memberCount, 10),
name: cursor.name,
id: cursor.id,
}),
});
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;
@@ -136,15 +136,23 @@ export class ShareRepo {
await query.execute();
}
async deleteBySpaceId(spaceId: string): Promise<void> {
await this.db
async deleteBySpaceId(
spaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('shares')
.where('spaceId', '=', spaceId)
.execute();
}
async deleteByWorkspaceId(workspaceId: string): Promise<void> {
await this.db
async deleteByWorkspaceId(
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('shares')
.where('workspaceId', '=', workspaceId)
.execute();
@@ -104,6 +104,7 @@ export class SpaceMemberRepo {
.leftJoin('users', 'users.id', 'spaceMembers.userId')
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
.select([
'spaceMembers.id as id',
'users.id as userId',
'users.name as userName',
'users.avatarUrl as userAvatarUrl',
@@ -120,6 +121,12 @@ export class SpaceMemberRepo {
'isGroup',
),
)
.select(
sql<number>`case "space_members"."role" when 'admin' then 1 when 'writer' then 2 when 'reader' then 3 else 4 end`.as(
'roleOrder',
),
)
.select(sql<string>`coalesce(users.name, groups.name)`.as('memberName'))
.where('spaceId', '=', spaceId);
if (pagination.query) {
@@ -149,12 +156,16 @@ export class SpaceMemberRepo {
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'sub.roleOrder', direction: 'asc', key: 'roleOrder' },
{ expression: 'sub.isGroup', direction: 'desc', key: 'isGroup' },
{ expression: 'sub.createdAt', direction: 'asc', key: 'createdAt' },
{ expression: 'sub.memberName', direction: 'asc', key: 'memberName' },
{ expression: 'sub.id', direction: 'asc', key: 'id' },
],
parseCursor: (cursor) => ({
roleOrder: parseInt(cursor.roleOrder, 10),
isGroup: parseInt(cursor.isGroup, 10),
createdAt: new Date(cursor.createdAt),
memberName: cursor.memberName,
id: cursor.id,
}),
});
@@ -304,8 +315,11 @@ export class SpaceMemberRepo {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
fields: [
{ expression: 'name', direction: 'asc' },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({ name: cursor.name, id: cursor.id }),
});
}
}
@@ -94,8 +94,10 @@ export class SpaceRepo {
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
return this.db
const db = dbOrTx(this.db, trx);
return db
.updateTable('spaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
@@ -150,8 +152,11 @@ export class SpaceRepo {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
fields: [
{ expression: 'name', direction: 'asc' },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({ name: cursor.name, id: cursor.id }),
});
}
@@ -38,9 +38,9 @@ export class UserTokenRepo {
async insertUserToken(
insertableUserToken: InsertableUserToken,
trx?: KyselyTransaction,
opts?: { trx?: KyselyTransaction },
) {
const db = dbOrTx(this.db, trx);
const db = dbOrTx(this.db, opts?.trx);
return db
.insertInto('userTokens')
.values(insertableUserToken)
@@ -165,8 +165,11 @@ export class UserRepo {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
fields: [
{ expression: 'name', direction: 'asc' },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({ name: cursor.name, id: cursor.id }),
});
}
@@ -33,6 +33,7 @@ export class WorkspaceRepo {
'enforceSso',
'plan',
'enforceMfa',
'trashRetentionDays',
];
constructor(@InjectKysely() private readonly db: KyselyDB) {}
@@ -67,6 +68,17 @@ export class WorkspaceRepo {
return query.executeTakeFirst();
}
async findLicenseKeyById(
workspaceId: string,
): Promise<string | undefined> {
const row = await this.db
.selectFrom('workspaces')
.select('licenseKey')
.where('id', '=', workspaceId)
.executeTakeFirst();
return row?.licenseKey;
}
async findFirst(): Promise<Workspace> {
return await this.db
.selectFrom('workspaces')
@@ -162,8 +174,10 @@ export class WorkspaceRepo {
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
return this.db
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
@@ -180,8 +194,10 @@ export class WorkspaceRepo {
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
return this.db
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
@@ -198,8 +214,10 @@ export class WorkspaceRepo {
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
return this.db
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
@@ -211,4 +229,5 @@ export class WorkspaceRepo {
.returning(this.baseFields)
.executeTakeFirst();
}
}
+42
View File
@@ -61,6 +61,21 @@ export interface Attachments {
workspaceId: string;
}
export interface Audit {
actorId: string | null;
actorType: Generated<string>;
changes: Json | null;
createdAt: Generated<Timestamp>;
event: string;
id: Generated<string>;
ipAddress: string | null;
metadata: Json | null;
resourceId: string | null;
resourceType: string;
spaceId: string | null;
workspaceId: string;
}
export interface AuthAccounts {
authProviderId: string | null;
createdAt: Generated<Timestamp>;
@@ -339,6 +354,8 @@ export interface WorkspaceInvitations {
}
export interface Workspaces {
auditRetentionDays: Generated<number>;
trashRetentionDays: Generated<number>;
billingEmail: string | null;
createdAt: Generated<Timestamp>;
customDomain: string | null;
@@ -443,9 +460,32 @@ export interface BaseViews {
updatedAt: 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;
audit: Audit;
baseProperties: BaseProperties;
baseRows: BaseRows;
baseViews: BaseViews;
@@ -459,6 +499,8 @@ export interface DB {
groups: Groups;
groupUsers: GroupUsers;
notifications: Notifications;
pageAccess: PageAccess;
pagePermissions: PagePermissions;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;
+2 -55
View File
@@ -1,59 +1,6 @@
import {
ApiKeys,
Attachments,
AuthAccounts,
AuthProviders,
Backlinks,
BaseProperties,
BaseRows,
BaseViews,
Bases,
Billing,
Comments,
FileTasks,
Groups,
GroupUsers,
Notifications,
PageHistory,
Pages,
Shares,
SpaceMembers,
Spaces,
UserMfa,
Users,
UserTokens,
Watchers,
WorkspaceInvitations,
Workspaces,
} from '@docmost/db/types/db';
import { DB } from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
export interface DbInterface {
attachments: Attachments;
authAccounts: AuthAccounts;
baseProperties: BaseProperties;
baseRows: BaseRows;
baseViews: BaseViews;
bases: Bases;
authProviders: AuthProviders;
backlinks: Backlinks;
billing: Billing;
comments: Comments;
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
notifications: Notifications;
export interface DbInterface extends DB {
pageEmbeddings: PageEmbeddings;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
userMfa: UserMfa;
users: Users;
userTokens: UserTokens;
watchers: Watchers;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
apiKeys: ApiKeys;
}
@@ -8,6 +8,8 @@ import {
Comments,
Groups,
Notifications,
PageAccess as _PageAccess,
PagePermissions as _PagePermissions,
Pages,
Spaces,
Users,
@@ -26,6 +28,7 @@ import {
UserMfa as _UserMFA,
ApiKeys,
Watchers,
Audit as _Audit,
} from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
@@ -167,3 +170,19 @@ export type UpdatableBaseRow = Updateable<Omit<BaseRows, 'id'>>;
export type BaseView = Selectable<BaseViews>;
export type InsertableBaseView = Insertable<BaseViews>;
export type UpdatableBaseView = Updateable<Omit<BaseViews, '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'>>;
// Audit
export type Audit = Selectable<_Audit>;
export type InsertableAudit = Insertable<_Audit>;
export type UpdatableAudit = Updateable<Omit<_Audit, 'id'>>;
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
@Global()
@Module({
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class NoopAuditModule {}
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { AuditLogPayload, ActorType } from '../../common/events/audit-events';
export type AuditLogContext = {
workspaceId: string;
actorId?: string;
actorType?: ActorType;
ipAddress?: string;
userAgent?: string;
};
export type IAuditService = {
log(payload: AuditLogPayload): void | Promise<void>;
logWithContext(
payload: AuditLogPayload,
context: AuditLogContext,
): void | Promise<void>;
logBatchWithContext(
payloads: AuditLogPayload[],
context: AuditLogContext,
): void | Promise<void>;
setActorId(actorId: string): void;
setActorType(actorType: ActorType): void;
updateRetention(
workspaceId: string,
retentionDays: number,
): void | Promise<void>;
};
export const AUDIT_SERVICE = Symbol('AUDIT_SERVICE');
@Injectable()
export class NoopAuditService implements IAuditService {
log(_payload: AuditLogPayload): void {
// No-op: swallow the log when EE module is not available
}
logWithContext(_payload: AuditLogPayload, _context: AuditLogContext): void {
// No-op: swallow the log when EE module is not available
}
logBatchWithContext(
_payloads: AuditLogPayload[],
_context: AuditLogContext,
): void {
// No-op: swallow the log when EE module is not available
}
setActorId(_actorId: string): void {
// No-op
}
setActorType(_actorType: ActorType): void {
// No-op
}
updateRetention(
_workspaceId: string,
_retentionDays: number,
): void {
// No-op
}
}
@@ -277,4 +277,14 @@ export class EnvironmentService {
'http://localhost:11434',
);
}
getEventStoreDriver(): string {
return this.configService
.get<string>('EVENT_STORE_DRIVER', 'postgres')
.toLowerCase();
}
getClickHouseUrl(): string {
return this.configService.get<string>('CLICKHOUSE_URL');
}
}
@@ -148,6 +148,22 @@ export class EnvironmentVariables {
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'ollama')
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OLLAMA_API_URL: string;
@IsOptional()
@IsIn(['postgres', 'clickhouse'])
@IsString()
EVENT_STORE_DRIVER: string;
@ValidateIf((obj) => obj.EVENT_STORE_DRIVER === 'clickhouse')
@IsNotEmpty()
@IsUrl(
{ protocols: ['http', 'https'], require_tld: false },
{
message:
'CLICKHOUSE_URL must be a valid URL e.g http://user:password@localhost:8123/docmost',
},
)
CLICKHOUSE_URL: string;
}
export function validate(config: Record<string, any>) {
@@ -4,6 +4,7 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Post,
Res,
@@ -16,6 +17,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,
@@ -23,8 +25,13 @@ import {
import { FastifyReply } from 'fastify';
import { sanitize } from 'sanitize-filename-ts';
import { getExportExtension } from './utils';
import { getMimeType } from '../../common/helpers';
import { getMimeType, getPageTitle } from '../../common/helpers';
import * as path from 'path';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
@Controller()
export class ExportController {
@@ -32,6 +39,8 @@ export class ExportController {
private readonly exportService: ExportService,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@UseGuards(JwtAuthGuard)
@@ -50,18 +59,30 @@ 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,
);
this.auditService.log({
event: AuditEvent.PAGE_EXPORTED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
metadata: {
title: getPageTitle(page.title),
format: dto.format,
includeChildren: dto.includeChildren,
includeAttachments: dto.includeAttachments,
spaceId: page.spaceId,
},
});
const fileName = sanitize(page.title || 'untitled') + '.zip';
res.headers({
@@ -82,7 +103,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,8 +111,21 @@ export class ExportController {
dto.spaceId,
dto.format,
dto.includeAttachments,
user.id,
);
this.auditService.log({
event: AuditEvent.SPACE_EXPORTED,
resourceType: AuditResource.SPACE,
resourceId: dto.spaceId,
spaceId: dto.spaceId,
metadata: {
format: dto.format,
includeAttachments: dto.includeAttachments ?? false,
spaceName: exportFile.spaceName,
},
});
res.headers({
'Content-Type': 'application/zip',
'Content-Disposition':
@@ -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
@@ -32,6 +33,7 @@ import slugify = require('@sindresorhus/slugify');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const packageJson = require('../../../package.json');
import { EnvironmentService } from '../environment/environment.service';
import { DomainService } from '../environment/domain.service';
import {
getAttachmentIds,
getProsemirrorContent,
@@ -44,9 +46,11 @@ 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,
private readonly domainService: DomainService,
) {}
async exportPage(format: string, page: Page, singlePage?: boolean) {
@@ -59,9 +63,11 @@ export class ExportService {
let prosemirrorJson: any;
if (singlePage) {
const baseUrl = await this.getWorkspaceBaseUrl(page.workspaceId);
prosemirrorJson = await this.turnPageMentionsToLinks(
getProsemirrorContent(page.content),
page.workspaceId,
baseUrl,
);
} else {
// mentions is already turned to links during the zip process
@@ -100,6 +106,8 @@ export class ExportService {
format: string,
includeAttachments: boolean,
includeChildren: boolean,
userId?: string,
ignorePermissions = false,
) {
let pages: Page[];
@@ -113,7 +121,7 @@ export class ExportService {
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
if (page){
if (page) {
pages = [page];
}
}
@@ -122,14 +130,40 @@ 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 baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId);
const zip = new JSZip();
await this.zipPages(tree, format, zip, includeAttachments);
await this.zipPages(
tree,
format,
zip,
includeAttachments,
baseUrl,
userId,
ignorePermissions,
);
const zipFile = zip.generateNodeStream({
type: 'nodebuffer',
@@ -144,10 +178,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 +191,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 +210,32 @@ 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 baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId);
const zip = new JSZip();
await this.zipPages(tree, format, zip, includeAttachments);
await this.zipPages(
tree,
format,
zip,
includeAttachments,
baseUrl,
userId,
ignorePermissions,
);
const zipFile = zip.generateNodeStream({
type: 'nodebuffer',
@@ -190,6 +247,7 @@ export class ExportService {
return {
fileStream: zipFile,
fileName,
spaceName: space.name,
};
}
@@ -198,6 +256,9 @@ export class ExportService {
format: string,
zip: JSZip,
includeAttachments: boolean,
baseUrl: string,
userId?: string,
ignorePermissions = false,
): Promise<void> {
const slugIdToPath: Record<string, string> = {};
const pageIdToFilePath: Record<string, string> = {};
@@ -219,6 +280,9 @@ export class ExportService {
const prosemirrorJson = await this.turnPageMentionsToLinks(
getProsemirrorContent(page.content),
page.workspaceId,
baseUrl,
userId,
ignorePermissions,
);
const currentPagePath = slugIdToPath[page.slugId];
@@ -303,10 +367,16 @@ export class ExportService {
}
}
async turnPageMentionsToLinks(prosemirrorJson: any, workspaceId: string) {
async turnPageMentionsToLinks(
prosemirrorJson: any,
workspaceId: string,
baseUrl: 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 +390,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]));
@@ -352,8 +440,7 @@ export class ExportService {
const truncatedTitle = linkTitle?.substring(0, 70);
const pageSlug = `${slugify(truncatedTitle)}-${slugId}`;
// Create the link URL
const link = `${this.environmentService.getAppUrl()}/s/${spaceSlug}/p/${pageSlug}`;
const link = `${baseUrl}/s/${spaceSlug}/p/${pageSlug}`;
// Create a link mark and a text node with that mark
const linkMark = editorState.schema.marks.link.create({ href: link });
@@ -398,4 +485,62 @@ export class ExportService {
return updatedDoc.toJSON();
}
private async getWorkspaceBaseUrl(workspaceId: string): Promise<string> {
const workspace = await this.db
.selectFrom('workspaces')
.select('hostname')
.where('id', '=', workspaceId)
.executeTakeFirst();
return this.domainService.getUrl(workspace?.hostname);
}
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));
}
}
@@ -4,6 +4,7 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
Inject,
Logger,
Post,
Req,
@@ -24,6 +25,11 @@ import * as path from 'path';
import { ImportService } from './services/import.service';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { EnvironmentService } from '../environment/environment.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
@Controller()
export class ImportController {
@@ -33,6 +39,7 @@ export class ImportController {
private readonly importService: ImportService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly environmentService: EnvironmentService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@UseInterceptors(FileInterceptor)
@@ -46,7 +53,7 @@ export class ImportController {
) {
const validFileExtensions = ['.md', '.html', '.docx'];
const maxFileSize = bytes('10mb');
const maxFileSize = bytes('20mb');
let file = null;
try {
@@ -83,7 +90,34 @@ export class ImportController {
throw new ForbiddenException();
}
return this.importService.importPage(file, user.id, spaceId, workspace.id);
const createdPage = await this.importService.importPage(
file,
user.id,
spaceId,
workspace.id,
);
const ext = path.extname(file.filename).toLowerCase();
const sourceMap: Record<string, string> = {
'.md': 'markdown',
'.html': 'html',
'.docx': 'docx',
};
if (createdPage) {
this.auditService.log({
event: AuditEvent.PAGE_CREATED,
resourceType: AuditResource.PAGE,
resourceId: createdPage.id,
spaceId,
metadata: {
source: sourceMap[ext],
fileName: file.filename,
},
});
}
return createdPage;
}
@UseInterceptors(FileInterceptor)
@@ -142,6 +176,18 @@ export class ImportController {
throw new ForbiddenException();
}
this.auditService.log({
event: AuditEvent.PAGE_IMPORTED,
resourceType: AuditResource.PAGE,
resourceId: spaceId,
spaceId,
metadata: {
fileName: file.filename,
source,
spaceId,
},
});
return this.importService.importZip(
file,
source,
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import * as path from 'path';
import { jsonToText } from '../../../collaboration/collaboration.util';
import { InjectKysely } from 'nestjs-kysely';
@@ -36,6 +36,11 @@ import { PageService } from '../../../core/page/services/page.service';
import { ImportPageNode } from '../dto/file-task-dto';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class FileImportTaskService {
@@ -50,6 +55,7 @@ export class FileImportTaskService {
private readonly importAttachmentService: ImportAttachmentService,
private moduleRef: ModuleRef,
private eventEmitter: EventEmitter2,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async processZIpImport(fileTaskId: string): Promise<void> {
@@ -402,6 +408,7 @@ export class FileImportTaskService {
// Process pages level by level sequentially to respect foreign key constraints
const allBacklinks: any[] = [];
const validPageIds = new Set<string>();
const pageTitles = new Map<string, string>();
let totalPagesProcessed = 0;
// Sort levels to process in order
@@ -478,8 +485,9 @@ export class FileImportTaskService {
await trx.insertInto('pages').values(insertablePage).execute();
// Track valid page IDs and collect backlinks
// Track valid page IDs, titles, and collect backlinks
validPageIds.add(insertablePage.id);
pageTitles.set(insertablePage.id, insertablePage.title);
allBacklinks.push(...backlinks);
totalPagesProcessed++;
@@ -522,6 +530,26 @@ export class FileImportTaskService {
`Successfully imported ${totalPagesProcessed} pages with ${filteredBacklinks.length} backlinks`,
);
});
if (validPageIds.size > 0) {
const auditPayloads = Array.from(validPageIds).map((pageId) => ({
event: AuditEvent.PAGE_CREATED,
resourceType: AuditResource.PAGE,
resourceId: pageId,
spaceId: fileTask.spaceId,
metadata: {
source: fileTask.source,
fileTaskId: fileTask.id,
title: pageTitles.get(pageId),
},
}));
this.auditService.logBatchWithContext(auditPayloads, {
workspaceId: fileTask.workspaceId,
actorId: fileTask.creatorId,
actorType: 'user',
});
}
} catch (error) {
this.logger.error('Failed to import files:', error);
throw new Error(`File import failed: ${error?.['message']}`);
@@ -14,6 +14,7 @@ import { getAttachmentFolderPath } from '../../../core/attachment/attachment.uti
import { AttachmentType } from '../../../core/attachment/attachment.constants';
import { unwrapFromParagraph } from '../utils/import-formatter';
import { resolveRelativeAttachmentPath } from '../utils/import.utils';
import { imageDimensionsFromData } from 'image-dimensions';
import { load } from 'cheerio';
import pLimit from 'p-limit';
import { InjectQueue } from '@nestjs/bullmq';
@@ -271,15 +272,37 @@ export class ImportAttachmentService {
continue;
}
const { attachmentId, apiFilePath } = processFile(relPath);
const { attachmentId, apiFilePath, abs } = processFile(relPath);
const width = $img.attr('width') ?? '100%';
let width = $img.attr('width');
const height = $img.attr('height');
const align = $img.attr('data-align') ?? 'center';
if (!width) {
try {
const buf = await fs.readFile(abs);
const natural = imageDimensionsFromData(new Uint8Array(buf));
if (natural) {
width = height
? String(
Math.round((natural.width / natural.height) * Number(height)),
)
: String(natural.width);
}
} catch {
/* empty */
}
if (!width) {
width = '600';
}
}
$img
.attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId)
.attr('width', width)
.attr('height', height)
.attr('data-align', align);
unwrapFromParagraph($, $img);
@@ -420,7 +443,7 @@ export class ImportAttachmentService {
const { attachmentId, apiFilePath, abs } = processFile(relPath);
const fileName = path.basename(abs);
const width = $oldDiv.attr('data-width') || '100%';
const width = $oldDiv.attr('data-width') || '600';
const align = $oldDiv.attr('data-align') || 'center';
const $newDiv = $('<div>')
@@ -460,7 +483,7 @@ export class ImportAttachmentService {
.attr('data-type', 'drawio')
.attr('data-src', drawioSvg.apiFilePath)
.attr('data-title', 'diagram')
.attr('data-width', '100%')
.attr('data-width', '600')
.attr('data-align', 'center')
.attr('data-attachment-id', drawioSvg.attachmentId);
@@ -531,7 +554,9 @@ export class ImportAttachmentService {
// Post-process DOM elements to add file sizes after uploads complete
// This avoids blocking file operations during initial DOM processing
const elementsNeedingSize = $('[data-attachment-id]:not([data-size])');
const elementsNeedingSize = $(
'[data-attachment-id]:not([data-attachment-size])',
);
for (const element of elementsNeedingSize.toArray()) {
const $el = $(element);
const attachmentId = $el.attr('data-attachment-id');
@@ -545,7 +570,7 @@ export class ImportAttachmentService {
if (processedEntry) {
try {
const stat = await fs.stat(processedEntry.abs);
$el.attr('data-size', stat.size.toString());
$el.attr('data-attachment-size', stat.size.toString());
} catch (error) {
this.logger.debug(
`Could not get size for ${processedEntry.abs}:`,
@@ -49,7 +49,7 @@ export class ImportService {
userId: string,
spaceId: string,
workspaceId: string,
): Promise<void> {
) {
const file = await filePromise;
const fileBuffer = await file.toBuffer();
const fileExtension = path.extname(file.filename).toLowerCase();
@@ -50,6 +50,7 @@ export async function formatImportHtml(opts: {
}
notionFormatter($, $root);
xwikiFormatter($, $root);
defaultHtmlFormatter($, $root);
const backlinks = await rewriteInternalLinksToMentionHtml(
@@ -69,6 +70,14 @@ export async function formatImportHtml(opts: {
};
}
export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
const $content = $root.find('#xwikicontent');
if ($content.length) {
$root.children().remove();
$root.append($content.contents());
}
}
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
$root.find('a[href]').each((_, el) => {
const $el = $(el);
@@ -90,6 +99,15 @@ export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
});
}
const COLUMN_LAYOUTS = [
'',
'',
'two_equal',
'three_equal',
'four_equal',
'five_equal',
] as const;
export function notionFormatter($: CheerioAPI, $root: Cheerio<any>) {
// remove page header icon and cover image
$root.find('.page-header-icon').remove();
@@ -100,6 +118,31 @@ export function notionFormatter($: CheerioAPI, $root: Cheerio<any>) {
if (!$(el).text().trim()) $(el).remove();
});
// columns
$root.find('div.column-list').each((_, el) => {
const $list = $(el);
const $cols = $list.find('div.column');
if ($cols.length <= 1) {
$list.replaceWith($cols.html() || '');
return;
}
const layout = COLUMN_LAYOUTS[$cols.length] ?? 'two_equal';
let cells = '';
$cols.each((_, col) => {
const $col = $(col);
$col.children('div[style*="display:contents"]').each((_, wrapper) => {
$(wrapper).replaceWith($(wrapper).html() || '');
});
cells += `<div data-type="column">${$col.html()}</div>`;
});
$list.replaceWith(
`<div data-type="columns" data-layout="${layout}">${cells}</div>`,
);
});
// block math → mathBlock
$root.find('figure.equation').each((_: any, fig: any) => {
const $fig = $(fig);
@@ -8,6 +8,7 @@ export enum QueueName {
AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}',
NOTIFICATION_QUEUE = '{notification-queue}',
AUDIT_QUEUE = '{audit-queue}',
}
export enum QueueJob {
@@ -67,4 +68,8 @@ 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',
AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup',
}
@@ -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;
}
@@ -84,6 +84,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
BullModule.registerQueue({
name: QueueName.NOTIFICATION_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.AUDIT_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 3,
},
}),
],
exports: [BullModule],
providers: [GeneralQueueProcessor],
@@ -17,6 +17,9 @@ export const ForgotPasswordEmail = ({ username, resetLink }: Props) => {
We received a request from you to reset your password.
</Text>
<Link href={resetLink}> Click here to set a new password</Link>
<Text style={paragraph}>
This link is valid for 30 minutes.
</Text>
<Text style={paragraph}>
If you did not request a password reset, please ignore this email.
</Text>
@@ -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;
+1 -1
View File
@@ -36,7 +36,7 @@ async function bootstrap() {
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', {
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'],
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug', 'mcp'],
});
const reflector = app.get(Reflector);
+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 {}
+181
View File
@@ -0,0 +1,181 @@
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) {
return;
}
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
if (!isRestricted) {
client.broadcast.to(room).emit('message', data);
return;
}
await this.broadcastToAuthorizedUsers(room, client.data.userId, pageId, data);
}
async invalidateSpaceRestrictionCache(spaceId: string): Promise<void> {
await this.cacheManager.del(
`${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`,
);
}
async emitCommentEvent(
spaceId: string,
pageId: string,
data: any,
): Promise<void> {
const room = getSpaceRoomName(spaceId);
const hasRestrictions = await this.spaceHasRestrictions(spaceId);
if (!hasRestrictions) {
this.server.to(room).emit('message', data);
return;
}
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
if (!isRestricted) {
this.server.to(room).emit('message', data);
return;
}
await this.broadcastToAuthorizedUsers(room, null, pageId, data);
}
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(
room: string,
excludeUserId: string | null,
pageId: string,
data: any,
): Promise<void> {
const sockets = await this.server.in(room).fetchSockets();
const otherSockets = excludeUserId
? sockets.filter((s) => s.data.userId !== excludeUserId)
: sockets;
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',
]);