Phase C: die letzten sechs Typen — PDF, Bild, Feedback und Datei-Upload

Damit sind alle 15 QR-Typen gebaut.

PDF und Bild brauchen eine hochgeladene Datei. Die liegt in der Datenbank,
nicht auf der Platte: damit bleibt es bei einem Volumen, das gesichert werden
muss — ein zweites wird beim Sichern zu leicht vergessen. Geprueft wird der
Dateiinhalt, nicht die Endung; eine Textdatei namens harmlos.pdf kommt nicht
durch. SVG ist bewusst nicht erlaubt, weil es Skripte enthalten darf und von
derselben Adresse wie die Anwendung ausgeliefert wuerde. Ausgeliefert wird mit
nosniff und einer CSP-Sandbox, damit auch ein PDF mit Skripten nichts anrichtet.

Dateien haengen am Code: wird er geloescht, geht sie mit. Eine ausgetauschte
oder nie gespeicherte Datei wird nach 24 Stunden weggeraeumt. Eine fremde
Datei laesst sich nicht an den eigenen Code haengen — sonst koennte man sie
einem anderen Code entziehen.

Feedback sammelt Note und Kommentar, ohne Name, IP oder Geraetekennung. Die
Auswertung mit Durchschnitt, Verteilung und Kommentaren ist aus der Codeliste
erreichbar — ein Feedback-Code ohne Auswertung waere nur die halbe Sache.
This commit is contained in:
Florian Wilde
2026-08-14 23:34:28 +02:00
parent fdf6452b35
commit e296b1e6c2
32 changed files with 1819 additions and 37 deletions
+11
View File
@@ -10,6 +10,7 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request";
import { attachUploads } from "@/lib/uploads";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
@@ -77,6 +78,8 @@ export async function PATCH(
requirePermission(actor, "codes:update", { groupId: existing.groupId });
const update: Prisma.QrCodeUpdateInput = {};
/** Dateien, die nach der Änderung zu diesem Code gehören. */
let neueDateien: string[] | null = null;
if (parsed.data.data !== undefined) {
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
@@ -92,6 +95,7 @@ export async function PATCH(
// stillschweigend unbrauchbar machen.
assertDynamicPossible(qrType, validated.data);
update.data = toJsonInput(validated.data);
neueDateien = qrType.uploadIds(validated.data);
}
if (parsed.data.label !== undefined) {
@@ -114,6 +118,13 @@ export async function PATCH(
data: update,
include: WITH_RELATIONS,
});
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
// später als verwaist weggeräumt.
if (neueDateien !== null) {
await attachUploads(updated.id, neueDateien);
}
return Response.json({ code: toQrCodeDto(updated) });
} catch (error) {
return errorResponse(error);
+5
View File
@@ -13,6 +13,7 @@ import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request";
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
import { generateSlug } from "@/lib/slug";
import { attachUploads } from "@/lib/uploads";
export const dynamic = "force-dynamic";
@@ -115,6 +116,10 @@ export async function POST(request: NextRequest): Promise<Response> {
createdBy: { select: { name: true, email: true } },
},
});
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
// vorher lagen sie ohne Bezug herum.
await attachUploads(created.id, qrType.uploadIds(validated.data));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) {
if (!isUniqueViolation(error)) throw error;
+95
View File
@@ -0,0 +1,95 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
export const dynamic = "force-dynamic";
const feedbackSchema = z.object({
codeId: z.string().trim().min(1),
rating: z
.number()
.int()
.min(1, { error: "Bitte wähle eine Bewertung." })
.max(5, { error: "Bitte wähle eine Bewertung." }),
comment: z
.string()
.trim()
.max(1000, { error: "Der Text ist zu lang (höchstens 1000 Zeichen)." })
.default(""),
});
/**
* Grobe Bremse gegen Fluten.
*
* Im Arbeitsspeicher und damit pro Container — bei einem Dienst dieser Größe
* genügt das. Die Adresse wird nur als Schlüssel benutzt und nirgends
* gespeichert; nach dem Zeitfenster ist sie wieder weg.
*/
const FENSTER_MS = 60_000;
const MAX_PRO_FENSTER = 10;
const zaehler = new Map<string, { anzahl: number; bis: number }>();
function zuSchnell(schluessel: string): boolean {
const jetzt = Date.now();
const eintrag = zaehler.get(schluessel);
if (!eintrag || eintrag.bis < jetzt) {
zaehler.set(schluessel, { anzahl: 1, bis: jetzt + FENSTER_MS });
// Gelegentlich ausmisten, damit die Tabelle nicht wächst.
if (zaehler.size > 5000) {
for (const [key, wert] of zaehler) {
if (wert.bis < jetzt) zaehler.delete(key);
}
}
return false;
}
eintrag.anzahl += 1;
return eintrag.anzahl > MAX_PRO_FENSTER;
}
/** Nimmt eine Rückmeldung von einer öffentlichen Landingpage entgegen. */
export async function POST(request: NextRequest): Promise<Response> {
try {
const parsed = feedbackSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const herkunft =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"unbekannt";
if (zuSchnell(herkunft)) {
return jsonError(
"Zu viele Rückmeldungen in kurzer Zeit. Bitte versuche es gleich noch einmal.",
429,
);
}
// Nur Codes, die tatsächlich Feedback einsammeln — sonst ließen sich
// beliebige Codes mit Einträgen zumüllen.
const code = await prisma.qrCode.findUnique({
where: { id: parsed.data.codeId },
select: { id: true, type: true },
});
if (!code || code.type !== "feedback") {
return jsonError("Zu diesem Code lässt sich keine Rückmeldung abgeben.", 404);
}
await prisma.feedback.create({
data: {
qrCodeId: code.id,
rating: parsed.data.rating,
comment: parsed.data.comment.length > 0 ? parsed.data.comment : null,
},
});
return Response.json({ ok: true }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}
+49
View File
@@ -0,0 +1,49 @@
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
/**
* Liefert eine hochgeladene Datei aus.
*
* Bewusst ohne Anmeldung: Die Landingpages sind öffentlich, und wer einen
* Code scannt, soll das hinterlegte PDF sehen können. Der Schutz liegt in der
* nicht erratbaren Id — dieselbe Überlegung wie beim Slug einer Kurz-URL.
*/
export async function GET(
request: NextRequest,
context: Context,
): Promise<Response> {
const { id } = await context.params;
const upload = await prisma.upload.findUnique({ where: { id } });
if (!upload) {
return new Response("Datei nicht gefunden", {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
// `?download` erzwingt das Speichern statt der Anzeige.
const alsDownload = request.nextUrl.searchParams.has("download");
return new Response(new Uint8Array(upload.data), {
headers: {
"Content-Type": upload.mimeType,
"Content-Length": String(upload.size),
// Der Typ steht fest — der Browser soll nicht raten und daraus
// womöglich etwas Ausführbares machen.
"X-Content-Type-Options": "nosniff",
// Ein PDF darf Skripte enthalten. Ausgeliefert von derselben Adresse
// wie die Anwendung könnte es damit auf angemeldete Sitzungen
// zugreifen; die Sandbox nimmt ihm diese Möglichkeit.
"Content-Security-Policy": "sandbox; default-src 'none'",
"Content-Disposition": `${alsDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(upload.filename)}`,
// Der Inhalt einer Id ändert sich nie — die Id wechselt mit der Datei.
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
+66
View File
@@ -0,0 +1,66 @@
import type { NextRequest } from "next/server";
import { getActor } from "@/lib/auth/actor";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, jsonError } from "@/lib/http";
import {
cleanupOrphanedUploads,
isUploadKind,
maxUploadBytes,
storeUpload,
} from "@/lib/uploads";
export const dynamic = "force-dynamic";
/**
* Nimmt eine Datei entgegen und gibt ihre Id zurück.
*
* Der Upload passiert vor dem Speichern des Codes — anders geht es nicht, das
* Formular braucht die Id, um sie im Code zu hinterlegen. Dateien ohne Code
* bleiben deshalb erst einmal liegen und werden später weggeräumt.
*/
export async function POST(request: NextRequest): Promise<Response> {
try {
const actor = await getActor();
// Wer keinen Code anlegen darf, soll auch nichts ablegen können.
const groupId = await resolveTargetGroup(actor, undefined);
requirePermission(actor, "codes:create", { groupId });
let form: FormData;
try {
form = await request.formData();
} catch {
return jsonError("Die Datei konnte nicht gelesen werden.");
}
const kind = form.get("kind");
if (!isUploadKind(kind)) {
return jsonError("Unbekannte Dateiart.");
}
const file = form.get("file");
if (!(file instanceof File)) {
return jsonError("Es wurde keine Datei mitgeschickt.");
}
const upload = await storeUpload(file, kind);
// Beiläufig aufräumen. Scheitert das, ist der Upload trotzdem gültig.
try {
await cleanupOrphanedUploads();
} catch (error) {
console.error("Verwaiste Uploads konnten nicht entfernt werden:", error);
}
return Response.json({ upload }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}
/** Damit die Oberfläche die Grenze nennen kann, bevor jemand wählt. */
export async function GET(): Promise<Response> {
return Response.json({ maxBytes: maxUploadBytes() });
}