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.
96 lines
2.8 KiB
TypeScript
96 lines
2.8 KiB
TypeScript
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);
|
|
}
|
|
}
|