mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
@@ -1,7 +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';
|
||||
@@ -22,6 +24,7 @@ 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';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
@@ -39,6 +42,10 @@ try {
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClsModule.forRoot({
|
||||
global: true,
|
||||
middleware: { mount: true },
|
||||
}),
|
||||
LoggerModule,
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
@@ -77,6 +84,12 @@ try {
|
||||
...enterpriseModules,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: AuditActorInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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',
|
||||
|
||||
// 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',
|
||||
|
||||
// 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',
|
||||
|
||||
// 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',
|
||||
|
||||
// Page permission
|
||||
PAGE_RESTRICTED: 'page.restricted',
|
||||
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
|
||||
PAGE_PERMISSION_ADDED: 'page.permission_added',
|
||||
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
|
||||
|
||||
// Comment updates / resolve
|
||||
COMMENT_UPDATED: 'comment.updated',
|
||||
COMMENT_RESOLVED: 'comment.resolved',
|
||||
COMMENT_REOPENED: 'comment.reopened',
|
||||
|
||||
// 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_MOVED_TO_SPACE,
|
||||
//AuditEvent.PAGE_DUPLICATED,
|
||||
]);
|
||||
|
||||
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}`,
|
||||
};
|
||||
@@ -120,6 +120,30 @@ 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 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,
|
||||
@@ -54,6 +55,11 @@ import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||
import * as path from 'path';
|
||||
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 {
|
||||
@@ -69,6 +75,7 @@ export class AttachmentController {
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -132,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) {
|
||||
|
||||
@@ -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,
|
||||
@@ -29,6 +30,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 +46,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) {
|
||||
@@ -64,6 +71,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);
|
||||
}
|
||||
|
||||
@@ -112,6 +126,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,
|
||||
@@ -135,16 +155,27 @@ export class AuthService {
|
||||
|
||||
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,
|
||||
@@ -201,6 +232,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(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
@@ -25,6 +26,11 @@ import {
|
||||
} 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';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('comments')
|
||||
@@ -35,6 +41,7 @@ export class CommentController {
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -51,7 +58,7 @@ export class CommentController {
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
return this.commentService.create(
|
||||
const comment = await this.commentService.create(
|
||||
{
|
||||
userId: user.id,
|
||||
page,
|
||||
@@ -59,6 +66,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)
|
||||
@@ -136,20 +155,32 @@ export class CommentController {
|
||||
|
||||
if (isOwner) {
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
comment.spaceId,
|
||||
);
|
||||
|
||||
// Space admin can delete any comment
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||
throw new ForbiddenException(
|
||||
'You can only delete your own comments or must be a space admin',
|
||||
} else {
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
comment.spaceId,
|
||||
);
|
||||
|
||||
// Space admin can delete any comment
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||
throw new ForbiddenException(
|
||||
'You can only delete your own comments or must be a space admin',
|
||||
);
|
||||
}
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
}
|
||||
await this.commentRepo.deleteComment(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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,15 @@ 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 {
|
||||
AUDIT_SERVICE,
|
||||
NoopAuditService,
|
||||
} from '../integrations/audit/audit.service';
|
||||
import { ClsMiddleware } from 'nestjs-cls';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -37,17 +43,31 @@ import { WatcherModule } from './watcher/watcher.module';
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: AUDIT_SERVICE,
|
||||
useClass: NoopAuditService,
|
||||
},
|
||||
],
|
||||
exports: [AUDIT_SERVICE],
|
||||
})
|
||||
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(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
@@ -25,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,
|
||||
@@ -40,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')
|
||||
@@ -50,6 +57,7 @@ export class PageController {
|
||||
private readonly pageHistoryService: PageHistoryService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -129,6 +137,19 @@ export class PageController {
|
||||
|
||||
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' &&
|
||||
@@ -153,8 +174,10 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const { hasRestriction } =
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
const { hasRestriction } = await this.pageAccessService.validateCanEdit(
|
||||
page,
|
||||
user,
|
||||
);
|
||||
|
||||
const updatedPage = await this.pageService.update(
|
||||
page,
|
||||
@@ -202,6 +225,21 @@ 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 {
|
||||
// User with edit permission can delete
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
@@ -211,6 +249,21 @@ export class PageController {
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,20 +280,30 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
//Todo: currently, this means if they are not admins, they need to add a space admin to the page, which is not possible as it was soft-deleted
|
||||
// so page is virtually lost. Fix.
|
||||
// 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();
|
||||
}
|
||||
|
||||
//TODO: can users with page level edit, but no space level edit restore pages they can edit?
|
||||
|
||||
// Check page-level edit permission (if restoring to a restricted ancestor)
|
||||
// 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,
|
||||
});
|
||||
@@ -286,7 +349,7 @@ export class PageController {
|
||||
deletedPageDto.spaceId,
|
||||
);
|
||||
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -410,7 +473,26 @@ export class PageController {
|
||||
await this.pageAccessService.validateCanEdit(movedPage, user);
|
||||
|
||||
// Moves only accessible pages; inaccessible child pages become root pages in original space
|
||||
return this.pageService.movePageToSpace(movedPage, dto.spaceId, user.id);
|
||||
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)
|
||||
@@ -425,6 +507,8 @@ export class PageController {
|
||||
// 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([
|
||||
@@ -440,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(
|
||||
@@ -451,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)
|
||||
|
||||
@@ -368,6 +368,8 @@ export class PageService {
|
||||
}
|
||||
|
||||
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
|
||||
let childPageIds: string[] = [];
|
||||
|
||||
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||
includeContent: false,
|
||||
});
|
||||
@@ -413,11 +415,13 @@ export class PageService {
|
||||
|
||||
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 },
|
||||
pageIdsToMove.filter((id) => id !== rootPage.id),
|
||||
childPageIds,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
@@ -462,6 +466,8 @@ export class PageService {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { childPageIds };
|
||||
}
|
||||
|
||||
async duplicatePage(
|
||||
@@ -680,10 +686,12 @@ export class PageService {
|
||||
});
|
||||
|
||||
const hasChildren = pages.length > 1;
|
||||
const childPageIds = insertedPageIds.filter((id) => id !== newPageId);
|
||||
|
||||
return {
|
||||
...duplicatedPage,
|
||||
hasChildren,
|
||||
childPageIds,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +5,7 @@ import {
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
@@ -29,6 +30,11 @@ 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')
|
||||
@@ -40,6 +46,7 @@ export class ShareController {
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -156,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)
|
||||
@@ -202,6 +222,19 @@ export class ShareController {
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disablePublicSharing: 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,170 @@ 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'
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||
delete updateWorkspaceDto.aiSearch;
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
delete updateWorkspaceDto.disablePublicSharing;
|
||||
|
||||
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 +542,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(
|
||||
@@ -564,6 +659,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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -33,6 +33,7 @@ export class WorkspaceRepo {
|
||||
'enforceSso',
|
||||
'plan',
|
||||
'enforceMfa',
|
||||
'trashRetentionDays',
|
||||
];
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
@@ -162,8 +163,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 +183,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 +203,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 +218,5 @@ export class WorkspaceRepo {
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+19
-2
@@ -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;
|
||||
@@ -415,6 +432,7 @@ export interface PagePermissions {
|
||||
export interface DB {
|
||||
apiKeys: ApiKeys;
|
||||
attachments: Attachments;
|
||||
audit: Audit;
|
||||
authAccounts: AuthAccounts;
|
||||
authProviders: AuthProviders;
|
||||
backlinks: Backlinks;
|
||||
@@ -425,9 +443,8 @@ export interface DB {
|
||||
groupUsers: GroupUsers;
|
||||
notifications: Notifications;
|
||||
pageAccess: PageAccess;
|
||||
pageHierarchy: PageHierarchy;
|
||||
pageHistory: PageHistory;
|
||||
pagePermissions: PagePermissions;
|
||||
pageHistory: PageHistory;
|
||||
pages: Pages;
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
UserMfa as _UserMFA,
|
||||
ApiKeys,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
} from './db';
|
||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||
|
||||
@@ -155,3 +156,8 @@ export type UpdatablePageAccess = Updateable<Omit<_PageAccess, 'id'>>;
|
||||
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'>>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 9e493d75f5...9157ff1e6d
@@ -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,
|
||||
@@ -24,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 {
|
||||
@@ -34,6 +40,7 @@ export class ExportController {
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -62,6 +69,20 @@ export class ExportController {
|
||||
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({
|
||||
@@ -93,6 +114,18 @@ export class ExportController {
|
||||
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':
|
||||
|
||||
@@ -239,6 +239,7 @@ export class ExportService {
|
||||
return {
|
||||
fileStream: zipFile,
|
||||
fileName,
|
||||
spaceName: space.name,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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']}`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
@@ -68,4 +69,7 @@ export enum QueueJob {
|
||||
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',
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -35,7 +35,6 @@ export class WsService {
|
||||
|
||||
const pageId = this.extractPageId(data);
|
||||
if (!pageId) {
|
||||
client.broadcast.to(room).emit('message', data);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user