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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
285 lines
9.4 KiB
TypeScript
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
|
|
}
|