Files
AfiS-Aktensystem/web/lib/files/service.ts
T
Harlekin 89edaa6ae1 Protokoll im Adminbereich, ein Knopf für das ganze Blatt
Die restlichen zwei Änderungswünsche.

**Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog -
harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll
ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion,
Art und Zeitraum, rückwärts blätternd.

Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die
Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes
Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten
aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu
Aktenzeichen aufgelöst - ein Protokoll aus UUIDs liest niemand, und eines, das
niemand liest, ist keine Kontrolle.

**Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit
zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt.

Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus
dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute
gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges
zwischenzeitlich geändertes Feld das ganze Blatt - elf gute Änderungen verloren,
weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die
Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein
Upload mit Prüfkette ist keine Feldänderung.

lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts.
2026-09-03 15:16:10 +02:00

285 lines
9.4 KiB
TypeScript

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 { 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
}