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' 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 { 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 { 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 }, }) } }