Files
AfiS-Aktensystem/web/lib/files/service.ts
T
Harlekin 55bcb0d95c Phase 5 abgeschlossen: Dateien mit vollständiger Prüfkette
Upload → quarantine/ → Typprüfung → ClamAV → Neukodierung → store/. Es gibt
keinen Pfad, auf dem eine Datei ungeprüft in store/ landet, und keinen
Schalter, der die Kette abschaltet.

Die Typprüfung liest Magic Bytes aus einer eigenen Signaturtabelle statt aus
file-type: bei einer Positivliste aus fünf Formaten hilft es nicht, hundert
erkennen zu können. Die Signatur muss an Position 0 stehen - ein Vorspann vor
%PDF- ist der klassische Weg, eine Datei für zwei Programme zugleich lesbar zu
machen. Wer trotzdem durchkommt, kommt als text/plain durch und wird unter der
Endung des erkannten Typs ausgeliefert, nicht unter der hochgeladenen.

Drei Ausgänge statt zwei: BLOCKED heisst "ist gefährlich", FAILED heisst
"wissen wir nicht". Nur FAILED lässt sich freigeben, und die Freigabe
wiederholt die Kette ohne den Scanner - Typprüfung und Neukodierung laufen
dabei mit. Ein fehlender clamd macht damit keine Datei sauber; ein Test hält
das fest.

Auslieferung nur über /api/files/[id], mit Sichtbarkeitsprüfung der
zugehörigen Akte, Protokoll ab Stufe 3 und den harten Kopfzeilen aus docs/07.
Vorschaubilder dürfen inline, weil sie serverseitig als WebP neu kodiert
wurden.

Das Lichtbild im Aktenblatt ist damit gefüllt: der Feldwert ist die Kennung
eines geprüften Anhangs, serverseitig zu einer Adresse aufgelöst.

/api/events liefert attachment.scanned über pg_notify - die Grundlage, auf der
Phase 6 den Aktenchat aufsetzt. Die Sichtbarkeit wird je Ereignis geprüft,
sonst verriete schon die Kennung die Existenz.

Nebenbei riss das Bündelbudget (121 kB gegen 120 erlaubte): lib/i18n/de.ts war
ein Objekt, und eine Client-Komponente zieht die ganze Datei mit. Die Texte
stehen jetzt in sechs Dateien nach Bereichen, de.ts setzt sie nur zusammen.
Grösste Route wieder bei 118 kB, und sie wächst nicht mehr mit jedem neuen
Bereich mit.

133 Tests im Web-Paket, 15 in der Datenbank, 28 e2e.
2026-09-02 20:27:22 +02:00

285 lines
9.4 KiB
TypeScript

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<void> {
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<Record<string, string>> {
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<string, string> = {}
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
}