Files
Harlekin 42dbf0f6b1
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Anhaenge zurueckziehen statt sie liegen zu lassen
Bisher gab es ueberhaupt keinen Weg, einen Anhang wieder loszuwerden. Wer
die falsche Datei hochlud, musste damit leben. Das ist keine Strenge,
sondern eine Luecke: die Regel des Hauses lautet nicht "nichts geht wieder
weg", sondern "nichts geht unbemerkt weg".

Also derselbe Weg wie beim Verlaufseintrag und beim Dokument. Zurueckziehen
mit Pflichtbegruendung, der Anhang bleibt als Grabstein an seiner Stelle
stehen - durchgestrichen, mit Zeitpunkt und Grund, fuer jeden sichtbar, der
ihn sehen darf. Aufheben laesst es sich, beides steht im Protokoll.

Aus dem Umlauf heisst wirklich aus dem Umlauf: die Auslieferung verweigert
die Datei, sie verschwindet aus Lichtbildfeldern, und das Vorschaubild
faellt weg. Ein Grabstein, unter dem der Download weiterlaeuft, waere eine
Beschriftung und keine Ruecknahme. Fuer `audit.read` bleibt sie abrufbar -
wie der Text eines zurueckgezogenen Eintrags, sonst waere es doch ein
Loeschen.

Im Ausdruck steht der Anhang weiter im Verzeichnis, mit dem Grund daneben.
Ihn dort wegzulassen hiesse, den Ausdruck sauberer zu machen als die Akte.

Die Datei auf der Platte bleibt liegen. Sie zu entfernen waere das
Loeschen, das hier gerade nicht stattfindet - und ein Anhang, dessen
Pruefsumme nicht mehr nachrechenbar ist, waere als Beweismittel wertlos.
Dazu eine Regel in der Datenbank, die DELETE auf Attachment verbietet, und
eine Bedingung, die einen Grabstein ohne Begruendung ausschliesst.

Zuruecknehmen darf, wer die Datei hochgeladen hat - der haeufige Fall, und
ohne das bliebe jeder Fehlgriff liegen, weil dafuer die Leitung zu rufen
waere - sowie jeder, der am Besitzer schreiben darf.
2026-09-04 16:18:33 +02:00

461 lines
15 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 { 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<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' },
// 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<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
}
/**
* 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<void> {
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<void> {
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<void> {
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 },
})
})
}