Both could only be created and deleted. For a webhook that is actively harmful: recreating it issues a new signing secret, so the receiver that verifies the X-ApiCore-Signature header against the old one starts rejecting every delivery without anyone changing it on purpose. Backend: - New PATCH /webhooks/:id behind a new webhooks:update permission, covering the target URL, the event selection and the active flag - the last of which had no way to be set after creation at all, so a misbehaving receiver could only be deleted. The signing secret is deliberately left alone and stays out of the audit log. - A changed webhook URL runs through the same assertPublicWebhookUrl SSRF check as a new one; without that the edit would have been a way around the guard create() applies. Covered by a test. - New PATCH /documents/:id behind a new documents:update permission, for re-linking a document filed against the wrong record and correcting its display name. Re-linking validates the new target with the existing assertEntityExists; the stored file is untouched. Frontend: - New webhook edit dialog. The event labels and the toggle helper moved out of the create dialog into lib/webhooks/labels.ts so both use one copy. - Documents can be renamed from the attachment card. Re-linking a document to a different record has no UI yet: it needs a two-step picker (colony, then inspection) that is not worth building unprompted. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
195 lines
5.9 KiB
TypeScript
195 lines
5.9 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import * as crypto from 'node:crypto';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
import { matchesFileSignature } from '../common/file-signature';
|
|
import { Document, DocumentEntityType } from '../../generated/prisma';
|
|
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
|
|
import { UpdateDocumentDto } from './dto/update-document.dto';
|
|
|
|
export type DocumentSummary = Omit<Document, 'storagePath'>;
|
|
|
|
function storageRoot(): string {
|
|
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
|
|
}
|
|
|
|
function toSummary(document: Document): DocumentSummary {
|
|
return {
|
|
id: document.id,
|
|
entityType: document.entityType,
|
|
entityId: document.entityId,
|
|
fileName: document.fileName,
|
|
mimeType: document.mimeType,
|
|
sizeBytes: document.sizeBytes,
|
|
uploadedByUserId: document.uploadedByUserId,
|
|
createdAt: document.createdAt,
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class DocumentsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly auditLog: AuditLogService,
|
|
) {}
|
|
|
|
async findAll(
|
|
entityType: DocumentEntityType,
|
|
entityId: string,
|
|
): Promise<DocumentSummary[]> {
|
|
const documents = await this.prisma.document.findMany({
|
|
where: { entityType, entityId },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
return documents.map(toSummary);
|
|
}
|
|
|
|
private async assertEntityExists(
|
|
entityType: DocumentEntityType,
|
|
entityId: string,
|
|
): Promise<void> {
|
|
const exists = await (() => {
|
|
switch (entityType) {
|
|
case 'APIARY':
|
|
return this.prisma.apiary.findUnique({ where: { id: entityId } });
|
|
case 'COLONY':
|
|
return this.prisma.colony.findUnique({ where: { id: entityId } });
|
|
case 'QUEEN':
|
|
return this.prisma.queen.findUnique({ where: { id: entityId } });
|
|
case 'INSPECTION':
|
|
return this.prisma.inspection.findUnique({
|
|
where: { id: entityId },
|
|
});
|
|
case 'HONEY_BATCH':
|
|
return this.prisma.honeyBatch.findUnique({
|
|
where: { id: entityId },
|
|
});
|
|
default:
|
|
return Promise.resolve(null);
|
|
}
|
|
})();
|
|
if (!exists) {
|
|
throw new NotFoundException(`${entityType} ${entityId} not found`);
|
|
}
|
|
}
|
|
|
|
async create(
|
|
file: Express.Multer.File,
|
|
entityType: DocumentEntityType,
|
|
entityId: string,
|
|
actingUserId: string,
|
|
): Promise<DocumentSummary> {
|
|
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
|
|
throw new BadRequestException('Nicht unterstützter Dateityp');
|
|
}
|
|
if (!matchesFileSignature(file.buffer, file.mimetype)) {
|
|
throw new BadRequestException(
|
|
'Datei entspricht nicht dem angegebenen Format',
|
|
);
|
|
}
|
|
await this.assertEntityExists(entityType, entityId);
|
|
|
|
const extension = ALLOWED_DOCUMENT_MIME_TYPES[file.mimetype];
|
|
const storageFileName = `${crypto.randomUUID()}${extension}`;
|
|
const directory = path.join(storageRoot(), entityType.toLowerCase());
|
|
await fs.mkdir(directory, { recursive: true });
|
|
const storagePath = path.join(directory, storageFileName);
|
|
await fs.writeFile(storagePath, file.buffer);
|
|
|
|
const document = await this.prisma.document.create({
|
|
data: {
|
|
entityType,
|
|
entityId,
|
|
fileName: file.originalname,
|
|
mimeType: file.mimetype,
|
|
sizeBytes: file.size,
|
|
storagePath,
|
|
uploadedByUserId: actingUserId,
|
|
},
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'Document',
|
|
entityId: document.id,
|
|
action: 'CREATE',
|
|
userId: actingUserId,
|
|
afterData: toSummary(document),
|
|
});
|
|
return toSummary(document);
|
|
}
|
|
|
|
async readFile(
|
|
id: string,
|
|
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
|
|
const document = await this.prisma.document.findUnique({
|
|
where: { id },
|
|
});
|
|
if (!document) {
|
|
throw new NotFoundException('Document not found');
|
|
}
|
|
const buffer = await fs.readFile(document.storagePath);
|
|
return {
|
|
buffer,
|
|
fileName: document.fileName,
|
|
mimeType: document.mimeType,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Re-links a document to a different record or corrects its display name.
|
|
* The stored file is untouched - replacing the content means uploading a
|
|
* new document.
|
|
*/
|
|
async update(
|
|
id: string,
|
|
dto: UpdateDocumentDto,
|
|
actingUserId: string,
|
|
): Promise<DocumentSummary> {
|
|
const before = await this.prisma.document.findUnique({ where: { id } });
|
|
if (!before) {
|
|
throw new NotFoundException('Document not found');
|
|
}
|
|
const entityType = dto.entityType ?? before.entityType;
|
|
const entityId = dto.entityId ?? before.entityId;
|
|
if (entityType !== before.entityType || entityId !== before.entityId) {
|
|
await this.assertEntityExists(entityType, entityId);
|
|
}
|
|
const document = await this.prisma.document.update({
|
|
where: { id },
|
|
data: { entityType, entityId, fileName: dto.fileName },
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'Document',
|
|
entityId: id,
|
|
action: 'UPDATE',
|
|
userId: actingUserId,
|
|
beforeData: toSummary(before),
|
|
afterData: toSummary(document),
|
|
});
|
|
return toSummary(document);
|
|
}
|
|
|
|
async delete(id: string, actingUserId: string): Promise<void> {
|
|
const document = await this.prisma.document.findUnique({
|
|
where: { id },
|
|
});
|
|
if (!document) {
|
|
throw new NotFoundException('Document not found');
|
|
}
|
|
await this.prisma.document.delete({ where: { id } });
|
|
await fs.unlink(document.storagePath).catch(() => undefined);
|
|
await this.auditLog.record({
|
|
entityType: 'Document',
|
|
entityId: id,
|
|
action: 'DELETE',
|
|
userId: actingUserId,
|
|
beforeData: toSummary(document),
|
|
});
|
|
}
|
|
}
|