import { writeFile } from 'node:fs/promises' import { db } from '@/lib/db' import { writeAudit } from '@/lib/audit' 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 { 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, } 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) { const anhang = await db.attachment.findFirst({ where: { AND: [visibleAttachmentWhere(p), { id }, { scanStatus: 'CLEAN' }] }, 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' }, ], }, 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 }