Files
AfiS-Aktensystem/web/lib/files/service.ts
T
Harlekin e0b03ea336
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
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +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
}