Files
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

109 lines
3.9 KiB
TypeScript

import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { Readable } from 'node:stream'
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth/current'
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write'
import { loadDeliverable } from '@/lib/files/service'
import { absolutePath } from '@/lib/files/paths'
import { deliveryFileName } from '@/lib/files/delivery'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/**
* Auslieferung. **Niemals als statische Datei** - jeder Abruf läuft durch die
* Sichtbarkeitsprüfung der zugehörigen Akte und wird ab Stufe 3 protokolliert.
* Siehe docs/07-dateien-und-sicherheit.md.
*
* Die Kopfzeilen sind der zweite Schutzwall hinter der Neukodierung: selbst
* eine durchgerutschte Datei kann im Browser nichts ausführen. `sandbox` ohne
* Wert verbietet Skripte, Formulare und eigene Herkunft zugleich.
*/
export async function GET(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const user = await getCurrentUser()
// Nicht angemeldet ist hier 404 und nicht 401: die Existenz einer Datei ist
// selbst eine Information.
if (!user) return new NextResponse(null, { status: 404 })
const { id } = await context.params
const anhang = await loadDeliverable(user.permissions, id)
if (!anhang) return new NextResponse(null, { status: 404 })
const variante = new URL(request.url).searchParams.get('variant')
const vorschau = variante === 'thumb' && anhang.thumbKey !== null
const key = vorschau ? anhang.thumbKey! : anhang.storageKey
let groesse: number
try {
groesse = (await stat(absolutePath(key))).size
} catch {
return new NextResponse(null, { status: 404 })
}
await protokolliereAbruf(user.id, user.permissions.clearanceLevel, anhang)
// Die Endung folgt dem erkannten Typ, nicht dem hochgeladenen Namen.
const name = deliveryFileName(anhang.originalName, anhang.mimeType)
const headers = new Headers({
'Content-Type': vorschau ? 'image/webp' : anhang.mimeType,
'Content-Length': String(groesse),
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'private, max-age=31536000, immutable',
'Content-Security-Policy': "default-src 'none'; sandbox",
// Vorschaubilder dürfen inline, sie sind serverseitig neu kodiert worden
// und immer WebP. Alles andere wird heruntergeladen, nie angezeigt.
'Content-Disposition': vorschau
? 'inline'
: `attachment; filename="${name}"; filename*=UTF-8''${encodeURIComponent(name)}`,
})
const stream = Readable.toWeb(createReadStream(absolutePath(key))) as ReadableStream
return new NextResponse(stream, { headers })
}
/**
* Ein Anhang erbt die Stufe seines Besitzers. Für die Protokollpflicht ab
* Stufe 3 muss sie deshalb nachgeschlagen werden.
*/
async function protokolliereAbruf(
userId: string,
clearanceLevel: number,
anhang: { id: string; caseId: string | null; entryId: string | null; documentId: string | null },
): Promise<void> {
const stufe = anhang.caseId
? (await db.case.findUnique({ where: { id: anhang.caseId }, select: { classification: true } }))
?.classification
: anhang.entryId
? (
await db.caseEntry.findUnique({
where: { id: anhang.entryId },
select: { case: { select: { classification: true } } },
})
)?.case.classification
: anhang.documentId
? (
await db.document.findUnique({
where: { id: anhang.documentId },
select: { classification: true },
})
)?.classification
: 0
if ((stufe ?? 0) >= READ_AUDIT_FROM_LEVEL) {
recordRead({
actorId: userId,
action: 'attachment.read',
targetType: 'Attachment',
targetId: anhang.id,
clearanceAtTime: clearanceLevel,
meta: { classification: stufe },
})
}
}