Files
ApiCore/ApiCore-Backend/src/documents/documents.service.ts
T
Nick SzaunigandClaude Opus 5 9a147ee123 feat: make webhooks and documents editable instead of delete-and-recreate
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]>
2026-09-22 02:07:48 +02:00

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),
});
}
}