Files
Laufzettel/src/app/api/anhaenge/[id]/route.ts
T
Florian WildeandClaude Opus 5 3cbd5026f8
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
feat(anhaenge): Fotos und Dateien an einem Schritt (#52)
Ein Schritt nimmt jetzt Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am
Handy direkt aus der Kamera. Die Dateien liegen im Dateisystem unter
ANHAENGE_PFAD, nach Kunde und Lauf gegliedert; in der Datenbank stehen
nur Name, Typ, Groesse und Pruefsumme. Der Pfad entsteht aus der
Kennung, nie aus dem Dateinamen vom Browser.

Jeder Abruf prueft Recht und Mandantengrenze — auch der des Bildes
selbst: der Route Handler ruft rechtErforderlich() selbst, weil die
Huelle unter (app) Route Handler nicht abdeckt. Hochgeladen wird ueber
eine Server Action statt ueber einen zweiten Endpunkt; der
Archiv-Schreibschutz haengt an derselben einen Stelle wie alles andere
am Lauf. Kein SVG: ein SVG ist ein Dokument, das Skripte ausfuehrt.

In der Datenbank halten zwei CHECK-Bedingungen und zwei
zusammengesetzte Fremdschluessel fest, was auch gilt, wenn jemand an
der Service-Schicht vorbei schreibt — ein Anhang kann strukturell nicht
an einem Schritt eines anderen Laufs oder bei einem fremden Kunden
haengen.

docs/betrieb/sicherung.md ist neu geschrieben: dort stand "Kein
Upload-Verzeichnis, keine erzeugten Dateien", und eine Sicherung nach
der alten Anleitung haette ab hier still Daten verloren. Sicherung und
Ruecksicherung sind einmal wirklich gefahren, mit Pruefsummenvergleich.

Gefunden in der Klickprobe am gebauten Stand und nur dort: zwei Felder
"datei" in einem Formular, und FormData.get nahm das leere. Wer "Datei"
waehlte, bekam "Keine Datei gewaehlt", waehrend "Foto" ging. Die Regel
liegt jetzt als reine Funktion in src/lib, und tests/e2e/anhaenge.spec.ts
laedt bewusst ueber den zweiten Knopf hoch.

Closes #52

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 23:05:00 +02:00

62 lines
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AnhangFehler, anhangLesen } from "@/server/services/anhaenge";
import { MandantFehler } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { istBild } from "@/lib/anhaenge";
/**
* Einen Anhang ausliefern (#032).
*
* **Route Handler prüfen selbst.** Die Hülle unter `src/app/(app)/layout.tsx`
* deckt sie nicht ab, und der Proxy sieht nur, **ob** ein Cookie da ist, nie was
* drinsteht (CLAUDE.md). Hier zählt das doppelt: eine Adresse, die eine Datei
* ohne Prüfung ausliefert, umgeht alles andere.
*
* `Content-Disposition: inline` nur für Bilder. Ein PDF wird **heruntergeladen**
* statt im Browser gerendert: ein PDF kann Skripte enthalten, und ein Anhang,
* den jemand aus einem fremden Lauf öffnet, soll nichts ausführen.
*/
export async function GET(
_anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
let benutzer: { id: string; rolle: Parameters<typeof anhangLesen>[1]["rolle"] };
try {
const zugang = await rechtErforderlich("lesen");
benutzer = { id: zugang.benutzer.id, rolle: zugang.benutzer.rolle };
} catch (fehler) {
if (fehler instanceof RechteFehler) {
return Response.json({ fehler: "Nicht angemeldet." }, { status: 401 });
}
throw fehler;
}
const { id } = await params;
try {
const datei = await anhangLesen(id, benutzer);
// Der Dateiname steckt in einer Kopfzeile. `dateinameSaeubern` hat
// Steuerzeichen und Anführungszeichen schon beim Anlegen entfernt.
const anzeige = istBild(datei.medientyp) ? "inline" : "attachment";
return new Response(new Uint8Array(datei.inhalt), {
headers: {
"Content-Type": datei.medientyp,
"Content-Length": String(datei.groesse),
"Content-Disposition": `${anzeige}; filename="${datei.dateiname}"`,
// Privat: ein Anhang gehört einem Kunden, kein Zwischenspeicher darf ihn
// an den nächsten Betrachter weitergeben.
"Cache-Control": "private, max-age=0, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
});
} catch (fehler) {
if (fehler instanceof MandantFehler) {
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
// Unterscheidung wäre schon eine Auskunft über fremde Kunden.
return Response.json({ fehler: "Diesen Anhang gibt es nicht." }, { status: 404 });
}
if (fehler instanceof AnhangFehler) {
return Response.json({ fehler: fehler.message }, { status: 404 });
}
throw fehler;
}
}