import { NextResponse } from 'next/server' import type { Modul } from '@prisma/client' import { protokollieren } from '@/lib/audit' import { darf } from '@/lib/auth/rollen' import { benutzerFuerRoute } from '@/lib/auth/sitzung' import { db, nichtGeloescht } from '@/lib/db' import { typErkennen } from '@/lib/speicher/dateityp' import { betreuteIdAusSchluessel, lesen } from '@/lib/speicher' import { zugriffTokenPruefen } from '@/lib/speicher/schluessel' /** * Auslieferung einer abgelegten Datei beim Dateitreiber. * * Hierher zeigt `signierteUrl()`, wenn kein Objektspeicher konfiguriert * ist. Vier Schranken stehen hintereinander, und jede muss halten: * * 1. **Signatur** - der Link traegt einen HMAC ueber Schluessel und * Ablauf. Ohne AUTH_SECRET laesst er sich weder faelschen noch * verlaengern. * 2. **Anmeldung** - ein weitergeleiteter Link nuetzt einem Fremden * nichts, er ist nicht angemeldet. * 3. **Berechtigung** - geprueft wird gegen die betreuteId, die im * Schluessel steht. Wer diese Person nicht betreut, kommt nicht an * ihre Unterlagen, auch nicht mit gueltigem Link. * 4. **Auslieferung** - immer als Anhang, nie als HTML, mit * X-Content-Type-Options: nosniff. Eine hochgeladene Datei darf * niemals als Seite im Kontext dieser Anwendung ausgefuehrt werden. * * Jeder erfolgreiche Abruf steht danach im Audit-Log. */ const MODUL: Modul = 'DOKUMENTE' /** Nur diese Typen werden ueberhaupt ausgeliefert - HTML ist nicht dabei. */ const AUSLIEFERBAR = new Set([ 'application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'image/tiff', 'image/heic', ]) function nurText(status: number, meldung: string): NextResponse { return new NextResponse(meldung, { status, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store', }, }) } export async function GET( _anfrage: Request, { params }: { params: Promise<{ signatur: string }> }, // Response statt NextResponse: Die Abweisung ohne zweiten Faktor kommt // aus benutzerFuerRoute() und ist eine schlichte Response. NextResponse // erbt davon, die uebrigen Rueckgaben passen also weiterhin. ): Promise { const { signatur } = await params // 1. Signatur und Ablauf const geprueft = zugriffTokenPruefen(decodeURIComponent(signatur)) if (!geprueft.gueltig) { return nurText( geprueft.grund === 'abgelaufen' ? 410 : 400, geprueft.grund === 'abgelaufen' ? 'Dieser Link ist abgelaufen. Öffne das Dokument erneut in der Akte.' : 'Dieser Link ist ungültig.', ) } const schluessel = geprueft.schluessel // 2. Angemeldet - und mit abgeschlossenem zweitem Faktor. // // Frueher stand hier nur die Frage nach der Anmeldung. Damit kam jemand, // der die TOTP-Einrichtung nie abgeschlossen hatte, an jedes Dokument - // die MFA-Pflicht galt nur fuer Seiten, nicht fuer diesen Abruf. const angemeldet = await benutzerFuerRoute() if (!angemeldet.ok) return angemeldet.antwort const benutzer = angemeldet.benutzer // 3. Zugriff auf genau diese betreute Person const betreuteId = betreuteIdAusSchluessel(schluessel) if (!betreuteId) return nurText(400, 'Dieser Link ist ungültig.') const betreute = await db.betreute.findFirst({ where: { id: betreuteId, ...nichtGeloescht }, select: { id: true }, }) if (!betreute) return nurText(404, 'Nicht gefunden.') const eintrag = await db.betreuungsZugang.findUnique({ where: { userId_betreuteId: { userId: benutzer.id, betreuteId } }, select: { rolle: true, freigegebeneModule: true }, }) const zugang = eintrag ? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule } : benutzer.rolle === 'ADMIN' ? { rolle: 'ADMIN' as const, freigegebeneModule: [] } : null if (!darf(zugang, MODUL, 'lesen')) { return nurText(403, 'Für diesen Bereich fehlt dir die Berechtigung.') } // Der Datensatz zur Datei - fuer Anzeigename und Protokolleintrag. const dokument = await db.dokument.findFirst({ where: { objektSchluessel: schluessel, betreuteId }, select: { id: true, titel: true, dateiname: true, geloeschtAm: true }, }) let inhalt: Buffer try { inhalt = await lesen(schluessel) } catch { return nurText(404, 'Die Datei liegt nicht mehr im Speicher.') } // 4. Auslieferung: Typ am Inhalt bestimmen, nie am gespeicherten Feld. const erkannt = typErkennen(inhalt) const typ = erkannt && AUSLIEFERBAR.has(erkannt.mimeTyp) ? erkannt.mimeTyp : 'application/octet-stream' const name = dateinamen(dokument?.dateiname ?? null, schluessel) await protokollieren({ aktion: 'DOKUMENT_HERUNTERGELADEN', entitaet: 'Dokument', entitaetId: dokument?.id ?? null, bezeichnung: dokument?.titel ?? name, betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, }) return new NextResponse(new Uint8Array(inhalt), { status: 200, headers: { 'Content-Type': typ, 'Content-Length': String(inhalt.length), // Niemals im Browser rendern - auch dann nicht, wenn jemand einen // Weg gefunden haette, eine HTML-Datei hier abzulegen. 'Content-Disposition': `attachment; filename="${asciiName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`, 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox", 'Referrer-Policy': 'no-referrer', // Private Daten gehoeren in keinen Zwischenspeicher. 'Cache-Control': 'private, no-store, max-age=0', ...(dokument?.geloeschtAm ? { 'X-Papierkorb': '1' } : {}), }, }) } function dateinamen(gespeichert: string | null, schluessel: string): string { if (gespeichert && gespeichert.trim() !== '') return gespeichert const endung = schluessel.slice(schluessel.lastIndexOf('.') + 1) return `dokument.${endung}` } /** Fallback fuer Browser, die filename* nicht verstehen. */ function asciiName(name: string): string { return ( name .replace(/[\u00e4]/g, 'ae') .replace(/[\u00f6]/g, 'oe') .replace(/[\u00fc]/g, 'ue') .replace(/[\u00c4]/g, 'Ae') .replace(/[\u00d6]/g, 'Oe') .replace(/[\u00dc]/g, 'Ue') .replace(/[\u00df]/g, 'ss') // eslint-disable-next-line no-control-regex .replace(/[^\u0020-\u007e]/g, '_') .replace(/["\\]/g, '_') || 'dokument' ) }