import { writeFile } from 'node:fs/promises' import { db } from '@/lib/db' import { writeAudit } from '@/lib/audit/write' import { AccessError, must } from '@/lib/errors' import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter' import { canEditCase } from '@/lib/access/caseWrite' import { canEditDocument } from '@/lib/access/documentWrite' import { loadVisibleCase } from '@/lib/cases/service' import { loadVisibleDocument } from '@/lib/documents/service' import { visibleCaseWhere, visibleEntryWhere } from '@/lib/access/caseFilter' import { MAX_FILE_BYTES, MAX_UPLOADS_PER_MINUTE, MAX_USER_BYTES, RATE_WINDOW_MS, } from '@/lib/files/limits' import { ensureDirFor, QUARANTINE, sha256Of, storageKeyFor } from '@/lib/files/paths' import { runCheckChain } from '@/lib/files/process' import type { EffectivePermissions } from '@/lib/auth/permissions' import type { Prisma } from '@afis/db' /** * Anhänge: annehmen, auflisten, ausliefern. * * Die Prüfkette selbst steht in `process.ts`. Hier steht, wer was anhängen * darf und welche Grenzen gelten. */ /** Wohin der Anhang gehört. Genau eine der drei Formen. */ export type AttachmentOwner = | { kind: 'case'; caseNumber: string } | { kind: 'entry'; entryId: string } | { kind: 'document'; documentNumber: string } const KOPFDATEN = { id: true, originalName: true, mimeType: true, sizeBytes: true, scanStatus: true, scanMessage: true, thumbKey: true, blurhash: true, width: true, height: true, uploadedById: true, createdAt: true, caseId: true, entryId: true, documentId: true, retractedAt: true, retractedById: true, retractionReason: true, } satisfies Prisma.AttachmentSelect /** * Prüft, ob der Nutzer an diesen Ort etwas anhängen darf, und liefert die * Fremdschlüssel dafür. * * Es ist dieselbe Berechtigung wie fürs Schreiben am Besitzer: wer einen * Eintrag verfassen darf, darf ihm auch eine Datei beilegen. */ async function aufloesen( p: EffectivePermissions, owner: AttachmentOwner, ): Promise<{ caseId?: string; entryId?: string; documentId?: string; label: string }> { if (owner.kind === 'case') { const akte = await loadVisibleCase(p, owner.caseNumber) if (!akte) throw new AccessError('not-found') must(canEditCase(p, akte, 'entry.create')) return { caseId: akte.id, label: akte.caseNumber } } if (owner.kind === 'document') { const doc = await loadVisibleDocument(p, owner.documentNumber) if (!doc) throw new AccessError('not-found') must(canEditDocument(p, doc)) return { documentId: doc.id, label: doc.documentNumber } } // Ein Eintrag ist nur über seine Akte erreichbar, und die Sichtbarkeit des // Eintrags schliesst die der Akte mit ein. const eintrag = await db.caseEntry.findFirst({ where: { AND: [visibleEntryWhere(p), { id: owner.entryId }] }, select: { id: true, case: { select: { caseNumber: true, status: true, classification: true } } }, }) if (!eintrag) throw new AccessError('not-found') must(canEditCase(p, eintrag.case, 'entry.create')) return { entryId: eintrag.id, label: eintrag.case.caseNumber } } /** * Nimmt eine Datei an und legt sie in der Quarantäne ab. * * Der Datensatz entsteht **sofort** mit `PENDING`, damit die Oberfläche ihn * zeigen kann, während die Prüfung läuft. Der gemeldete Typ wandert * unverändert in `mimeType` und wird von der Prüfkette überschrieben - bis * dahin ist er nichts als eine Behauptung des Browsers. */ export async function acceptUpload( p: EffectivePermissions, input: { owner: AttachmentOwner fileName: string declaredType: string | null content: Uint8Array }, ): Promise<{ id: string }> { if (input.content.byteLength === 0) throw new AccessError('file-empty') if (input.content.byteLength > MAX_FILE_BYTES) throw new AccessError('file-too-large') const [verbraucht, letzteMinute] = await Promise.all([ db.attachment.aggregate({ where: { uploadedById: p.userId, scanStatus: { not: 'BLOCKED' } }, _sum: { sizeBytes: true }, }), db.attachment.count({ where: { uploadedById: p.userId, createdAt: { gte: new Date(Date.now() - RATE_WINDOW_MS) }, }, }), ]) if ((verbraucht._sum.sizeBytes ?? 0) + input.content.byteLength > MAX_USER_BYTES) { throw new AccessError('quota-exceeded') } if (letzteMinute >= MAX_UPLOADS_PER_MINUTE) throw new AccessError('rate-limited') const ziel = await aufloesen(p, input.owner) return db.$transaction(async (tx) => { const anhang = await tx.attachment.create({ data: { caseId: ziel.caseId ?? null, entryId: ziel.entryId ?? null, documentId: ziel.documentId ?? null, // Nur zur Anzeige und für den Download-Namen. Der Pfad auf der Platte // entsteht aus der Kennung, nie aus diesem Namen. originalName: input.fileName.slice(0, 200), mimeType: (input.declaredType ?? 'application/octet-stream').slice(0, 100), sizeBytes: input.content.byteLength, sha256: sha256Of(input.content), storageKey: '', uploadedById: p.userId, scanStatus: 'PENDING', }, select: { id: true }, }) const storageKey = storageKeyFor(QUARANTINE, anhang.id) await writeFile(await ensureDirFor(storageKey), input.content) await tx.attachment.update({ where: { id: anhang.id }, data: { storageKey } }) await writeAudit(tx, { actorId: p.userId, action: 'attachment.upload', targetType: 'Attachment', targetId: anhang.id, clearanceAtTime: p.clearanceLevel, meta: { owner: ziel.label, originalName: input.fileName.slice(0, 200), sizeBytes: input.content.byteLength, }, }) return anhang }) } /** Die Anhänge eines Besitzers, durch die Sichtbarkeitsprüfung gefiltert. */ export async function listAttachments( p: EffectivePermissions, owner: { caseId: string } | { entryId: string } | { documentId: string }, ) { return db.attachment.findMany({ where: { AND: [visibleAttachmentWhere(p), owner] }, orderBy: { createdAt: 'asc' }, take: 100, select: KOPFDATEN, }) } /** * Lädt einen Anhang zum Ausliefern. * * Nur was die Prüfkette bestanden hat oder von einem Administrator bewusst * freigegeben wurde. Alles andere gibt es für die Auslieferung nicht - auch * nicht mit Recht `admin`, denn die Datei liegt dann noch in der Quarantäne. */ export async function loadDeliverable(p: EffectivePermissions, id: string) { // Ein zurückgezogener Anhang wird nicht mehr ausgeliefert - ausser an // jemanden mit `audit.read`. Das ist dieselbe Regel wie beim Text eines // zurückgezogenen Verlaufseintrags: die Sache bleibt nachprüfbar, ist aber // aus dem Umlauf. Siehe docs/05-akten-und-vorlagen.md. const darfNachpruefen = p.isAdmin || p.permissions.has('audit.read') const anhang = await db.attachment.findFirst({ where: { AND: [ visibleAttachmentWhere(p), { id }, { scanStatus: 'CLEAN' }, ...(darfNachpruefen ? [] : [{ retractedAt: null }]), ], }, select: { id: true, originalName: true, mimeType: true, storageKey: true, thumbKey: true, sizeBytes: true, caseId: true, entryId: true, documentId: true, }, }) return anhang } /** * Gibt eine Datei frei, deren Prüfung nicht abgeschlossen werden konnte. * * Nur mit Recht `admin` und nur aus `FAILED` - was als Schadsoftware oder * unzulässiger Typ erkannt wurde, bleibt blockiert. Die Freigabe wiederholt * die Kette ohne den Virenscanner; die Typprüfung läuft dabei **immer** mit. */ export async function releaseAttachment( p: EffectivePermissions, id: string, ): Promise { if (!p.isAdmin) throw new AccessError('no-permission') const anhang = await db.attachment.findFirst({ where: { AND: [visibleAttachmentWhere(p), { id }] }, select: { id: true, scanStatus: true, scanMessage: true }, }) if (!anhang) throw new AccessError('not-found') if (anhang.scanStatus !== 'FAILED') throw new AccessError('release-not-possible') await db.$transaction(async (tx) => { await tx.attachment.update({ where: { id }, data: { releasedById: p.userId, releasedAt: new Date(), scanStatus: 'PENDING' }, }) await writeAudit(tx, { actorId: p.userId, action: 'attachment.release', targetType: 'Attachment', targetId: id, clearanceAtTime: p.clearanceLevel, meta: { previousMessage: anhang.scanMessage ?? undefined }, }) }) // Die Kette noch einmal, ohne den Scanner. Typprüfung und Neukodierung // laufen dabei mit - die Freigabe überspringt nur das, was ausgefallen war. await runCheckChain(id, { skipScan: true }) } /** * Löst die Lichtbildfelder eines Dokuments zu Adressen auf. * * **Serverseitig**, und nur für Anhänge, die die Prüfkette bestanden haben und * die der Leser sehen darf. Der Browser bekommt eine Adresse oder gar nichts - * nie eine Kennung, zu der er selbst probieren müsste. */ export async function resolvePhotoUrls( p: EffectivePermissions, sections: { fieldKey: string; visible: boolean; value: unknown; type: string }[], ): Promise> { const gesucht = sections .filter((s) => s.type === 'photo' && s.visible && typeof s.value === 'string' && s.value) .map((s) => ({ fieldKey: s.fieldKey, id: s.value as string })) if (gesucht.length === 0) return {} const erlaubt = await db.attachment.findMany({ where: { AND: [ visibleAttachmentWhere(p), { id: { in: gesucht.map((g) => g.id) } }, { scanStatus: 'CLEAN' }, // Ein zurückgezogenes Lichtbild verschwindet aus dem Feld. Bliebe es // stehen, wäre die Rücknahme eine Beschriftung und keine. { retractedAt: null }, ], }, select: { id: true, thumbKey: true }, }) const mitVorschau = new Map(erlaubt.map((a) => [a.id, a.thumbKey !== null])) const ergebnis: Record = {} for (const { fieldKey, id } of gesucht) { const vorschau = mitVorschau.get(id) if (vorschau === undefined) continue ergebnis[fieldKey] = vorschau ? `/api/files/${id}?variant=thumb` : `/api/files/${id}` } return ergebnis } /** * Wer darf die Rücknahme eines Anhangs aussprechen? * * Zwei Wege, und der erste ist der häufige: **wer die Datei hochgeladen hat**, * nimmt seinen eigenen Fehlgriff zurück. Sonst muss für jede versehentlich * angehängte Datei die Leitung gerufen werden, und das führt dazu, dass sie * eben liegen bleibt. * * Der zweite Weg ist, wer am Besitzer schreiben darf - dieselbe Prüfung wie * beim Anhängen. Wer eine Datei hinzufügen darf, darf sie auch zurücknehmen. */ async function darfZuruecknehmen( p: EffectivePermissions, anhang: { uploadedById: string caseId: string | null entryId: string | null documentId: string | null }, ): Promise { if (p.isAdmin || anhang.uploadedById === p.userId) return if (anhang.documentId) { const doc = await db.document.findUnique({ where: { id: anhang.documentId }, select: { status: true, classification: true, retractedAt: true }, }) if (!doc) throw new AccessError('not-found') must(canEditDocument(p, doc)) return } const caseId = anhang.caseId ?? ( await db.caseEntry.findUnique({ where: { id: anhang.entryId ?? '' }, select: { caseId: true }, }) )?.caseId if (!caseId) throw new AccessError('not-found') const akte = await db.case.findFirst({ where: { AND: [visibleCaseWhere(p), { id: caseId }] }, select: { status: true, classification: true }, }) if (!akte) throw new AccessError('not-found') must(canEditCase(p, akte, 'entry.create')) } /** Kürzer als das ist keine Begründung, sondern ein Achselzucken. */ const MIN_BEGRUENDUNG = 10 /** * Anhang zurückziehen. * * Kein Löschen: der Anhang bleibt als Grabstein an seiner Stelle stehen, mit * Zeitpunkt, Urheber und Begründung. Die Datei wird nicht mehr ausgeliefert * und verschwindet aus Lichtbildfeldern; für `audit.read` bleibt sie * abrufbar. Genau wie beim Verlaufseintrag, siehe README, Zusicherung 2. * * Die Datei auf der Platte bleibt liegen. Sie zu entfernen wäre das Löschen, * das hier gerade nicht stattfindet - und ein zurückgezogener Anhang, dessen * Prüfsumme nicht mehr nachrechenbar ist, wäre als Beweismittel wertlos. */ export async function retractAttachment( p: EffectivePermissions, id: string, reason: string, ): Promise { const begruendung = reason.trim() if (begruendung.length < MIN_BEGRUENDUNG) throw new AccessError('reason-required') const anhang = await db.attachment.findFirst({ where: { AND: [visibleAttachmentWhere(p), { id }] }, select: { id: true, originalName: true, uploadedById: true, caseId: true, entryId: true, documentId: true, retractedAt: true, }, }) if (!anhang) throw new AccessError('not-found') if (anhang.retractedAt) throw new AccessError('already-retracted') await darfZuruecknehmen(p, anhang) await db.$transaction(async (tx) => { await tx.attachment.update({ where: { id }, data: { retractedAt: new Date(), retractedById: p.userId, retractionReason: begruendung.slice(0, 500), }, }) await writeAudit(tx, { actorId: p.userId, action: 'attachment.retract', targetType: 'Attachment', targetId: id, clearanceAtTime: p.clearanceLevel, meta: { originalName: anhang.originalName, reason: begruendung.slice(0, 500) }, }) }) } /** * Rücknahme aufheben. * * Möglich, weil ein Irrtum auch beim Zurückziehen vorkommt - und weil beides * im Protokoll steht, ist daran nichts still. Der Grabstein verschwindet aus * der Ansicht, die beiden Protokolleinträge bleiben. */ export async function restoreAttachment( p: EffectivePermissions, id: string, ): Promise { const anhang = await db.attachment.findFirst({ where: { AND: [visibleAttachmentWhere(p), { id }] }, select: { id: true, originalName: true, uploadedById: true, caseId: true, entryId: true, documentId: true, retractedAt: true, }, }) if (!anhang) throw new AccessError('not-found') if (!anhang.retractedAt) throw new AccessError('release-not-possible') await darfZuruecknehmen(p, anhang) await db.$transaction(async (tx) => { await tx.attachment.update({ where: { id }, data: { retractedAt: null, retractedById: null, retractionReason: null }, }) await writeAudit(tx, { actorId: p.userId, action: 'attachment.restore', targetType: 'Attachment', targetId: id, clearanceAtTime: p.clearanceLevel, meta: { originalName: anhang.originalName }, }) }) }