mirror of
https://github.com/docmost/docmost.git
synced 2026-05-17 23:14:07 +08:00
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user