From e296b1e6c21da539c8d698c00cc2226c4bdddd8d Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 14 Aug 2026 23:34:28 +0200 Subject: [PATCH] =?UTF-8?q?Phase=20C:=20die=20letzten=20sechs=20Typen=20?= =?UTF-8?q?=E2=80=94=20PDF,=20Bild,=20Feedback=20und=20Datei-Upload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 5 + README.md | 94 ++++++++- docker-compose.yml | 1 + .../migration.sql | 24 +++ .../20260814150000_feedback/migration.sql | 20 ++ prisma/schema.prisma | 51 +++++ src/app/(app)/layout.tsx | 2 +- .../(app)/meine-codes/[id]/feedback/page.tsx | 164 +++++++++++++++ src/app/api/codes/[id]/route.ts | 11 + src/app/api/codes/route.ts | 5 + src/app/api/feedback/route.ts | 95 +++++++++ src/app/api/uploads/[id]/route.ts | 49 +++++ src/app/api/uploads/route.ts | 66 ++++++ src/app/layout.tsx | 2 +- src/components/codes-list.tsx | 21 ++ src/components/landing/feedback-landing.tsx | 148 +++++++++++++ src/components/landing/image-landing.tsx | 36 ++++ src/components/landing/index.ts | 6 + src/components/landing/pdf-landing.tsx | 49 +++++ src/components/landing/shell.tsx | 4 +- src/components/qr-forms/feedback-form.tsx | 116 +++++++++++ src/components/qr-forms/file-field.tsx | 110 ++++++++++ src/components/qr-forms/image-form.tsx | 193 +++++++++++++++++ src/components/qr-forms/index.ts | 6 + src/components/qr-forms/pdf-form.tsx | 110 ++++++++++ src/lib/qr/registry.ts | 35 ++-- src/lib/qr/type-module.ts | 11 + src/lib/qr/types/feedback.ts | 68 ++++++ src/lib/qr/types/image.ts | 64 ++++++ src/lib/qr/types/pdf.ts | 58 ++++++ src/lib/uploads.shared.ts | 38 ++++ src/lib/uploads.ts | 194 ++++++++++++++++++ 32 files changed, 1819 insertions(+), 37 deletions(-) create mode 100644 prisma/migrations/20260814140000_datei_uploads/migration.sql create mode 100644 prisma/migrations/20260814150000_feedback/migration.sql create mode 100644 src/app/(app)/meine-codes/[id]/feedback/page.tsx create mode 100644 src/app/api/feedback/route.ts create mode 100644 src/app/api/uploads/[id]/route.ts create mode 100644 src/app/api/uploads/route.ts create mode 100644 src/components/landing/feedback-landing.tsx create mode 100644 src/components/landing/image-landing.tsx create mode 100644 src/components/landing/pdf-landing.tsx create mode 100644 src/components/qr-forms/feedback-form.tsx create mode 100644 src/components/qr-forms/file-field.tsx create mode 100644 src/components/qr-forms/image-form.tsx create mode 100644 src/components/qr-forms/pdf-form.tsx create mode 100644 src/lib/qr/types/feedback.ts create mode 100644 src/lib/qr/types/image.ts create mode 100644 src/lib/qr/types/pdf.ts create mode 100644 src/lib/uploads.shared.ts create mode 100644 src/lib/uploads.ts diff --git a/.env.example b/.env.example index 29c65ab..af46189 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,11 @@ APP_PORT=3000 # Wer den Zugang verliert, setzt den Wert zurück auf false und startet neu. AUTH_ENABLED=false +# Groesste erlaubte Datei fuer die Typen "PDF" und "Bild", in Megabyte. +# Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr +# grosse Werte machen die Sicherung entsprechend schwer. +MAX_UPLOAD_MB=10 + # --- PostgreSQL ------------------------------------------------------------ POSTGRES_USER=qrcode POSTGRES_PASSWORD=bitte-aendern diff --git a/README.md b/README.md index 826ad9a..70e572c 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,8 @@ Eine Webanwendung zum Erzeugen von QR-Codes — als Docker-Compose-Stack aus zwei Containern: der Next.js-Anwendung und einer PostgreSQL-Datenbank. -Umgesetzt sind **9 der 15 QR-Typen**. Die übrigen sechs sind in der Oberfläche -sichtbar, als Platzhalter markiert und lassen sich über die Verwaltung -ausblenden. +Umgesetzt sind **alle 15 QR-Typen**. Welche davon in der Oberfläche erscheinen, +entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden. --- @@ -152,9 +151,10 @@ Für einen Code, dessen Ziel du später ändern kannst: ### Schritt 8 — Aufräumen, was du nicht brauchst -Unter **Verwaltung → QR-Typen** kannst du einzelne Typen ausblenden — etwa -die sechs, die noch nicht umgesetzt sind, damit sie gar nicht erst -auftauchen. +Unter **Verwaltung → QR-Typen** kannst du einzelne Typen ausblenden — 15 +Kacheln sind viel, wenn ihr ohnehin nur drei davon braucht. Ausgeblendete +Typen verschwinden aus der Auswahl; bereits erstellte Codes dieses Typs +funktionieren unverändert weiter. ### Die wichtigsten Befehle im Betrieb @@ -358,8 +358,7 @@ werden — der Hostname darin bleibt `db` (der Servicename aus ### 1. Typ wählen Die Startseite zeigt unter „Was möchtest du erstellen?“ ein Kachel-Grid der -freigegebenen QR-Typen. Umgesetzte Typen sind anklickbar, die übrigen tragen -das Badge „Bald verfügbar“. +freigegebenen QR-Typen. Alle 15 Typen sind umgesetzt. | Typ | Kodiert als | Statisch | Dynamisch | | --- | --- | :-: | :-: | @@ -368,10 +367,16 @@ das Badge „Bald verfügbar“. | SMS | `SMSTO:` | ✓ | ✓ | | WhatsApp | `https://wa.me/…` | ✓ | ✓ | | Standort | `geo:` — dynamisch über OpenStreetMap | ✓ | ✓ | +| Video | die Videoadresse — dynamisch mit Player-Seite | ✓ | ✓ | | vCard | vCard 3.0 | ✓ | — | | WLAN | `WIFI:` | ✓ | — | | App-Link | Weiterleitung je nach Gerät | — | ✓ | | Zahlung | GiroCode (EPC069-12) oder Zahlungslink | ✓ | nur Link | +| Mehrere Links | eigene Seite mit Linkliste | — | ✓ | +| Social Media | eigene Seite mit den Profilen | — | ✓ | +| PDF | hinterlegte Datei auf eigener Seite | — | ✓ | +| Bild | hinterlegte Bilder auf eigener Seite | — | ✓ | +| Feedback | Bewertungsformular auf eigener Seite | — | ✓ | Warum manche Typen nur eine Betriebsart können: @@ -386,6 +391,12 @@ Warum manche Typen nur eine Betriebsart können: dynamischer Code funktioniert daher nur die Variante „Zahlungslink“ — die Anwendung weist das beim Speichern ausdrücklich ab, statt einen Code entstehen zu lassen, der beim Scannen ins Leere führt. +- **Mehrere Links, Social Media, PDF, Bild und Feedback** brauchen eine Seite, + die die Anwendung selbst ausliefert — eine Linkliste oder ein Formular passt + nicht in einen einzelnen Code. Diese Seiten liegen unter `/s/{slug}`; der + gedruckte Code zeigt weiterhin auf `/r/{slug}` und wird dorthin + weitergeleitet. Der Scan-Zähler zählt dabei den Aufruf des Codes, nicht das + Neuladen der Seite. > **Zum WLAN-Code:** Das Passwort steht im Klartext im Code — wer ihn scannt > oder abfotografiert, kennt es. Für Gästenetze ist das gewollt. Besteht ein @@ -482,9 +493,70 @@ Kartenansicht. Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden. -> Version 1 ist für den Einzelplatz-/LAN-Betrieb gedacht und hat **keine -> Benutzerverwaltung**. Wer die Anwendung erreicht, sieht und ändert alle -> Codes. Die App gehört daher nicht ungeschützt ins offene Internet. +Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur +Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare. + +--- + +### 7. Eigene Seiten: Links, Profile, Dateien, Feedback + +Fünf Typen haben kein fremdes Ziel, auf das sich weiterleiten ließe — sie +brauchen eine Seite. Die liefert die Anwendung selbst aus, unter +`{BASE_URL}/s/{slug}`: + +| Typ | Was die Seite zeigt | +| --- | --- | +| Mehrere Links | Überschrift, kurzer Text, bis zu 20 beschriftete Links | +| Social Media | dieselbe Form, aber mit Netzwerk-Auswahl aus einem Katalog | +| PDF | das hinterlegte Dokument mit „Öffnen“ und „Herunterladen“ | +| Bild | ein Bild oder eine Galerie aus bis zu 10 Bildern mit Bildunterschriften | +| Feedback | ein Formular mit Sternen oder Gesichtern und optionalem Kommentar | + +Diese Seiten sind **öffentlich** und zeigen bewusst nichts von der Anwendung: +keine Navigation, kein „Verwaltung“, kein Hinweis auf den Generator. Wer einen +Aushang scannt, soll nur den Inhalt sehen. + +Der gedruckte Code zeigt weiterhin auf `/r/{slug}`; von dort geht es auf die +Seite. Der Scan-Zähler zählt dabei den Aufruf des Codes — ein Neuladen der +Seite zählt nicht mit. + +**Bei Social Media** genügt als Eingabe das Kürzel: aus `@firma` wird beim +Speichern `https://instagram.com/firma`. Eine vollständige Adresse bleibt +unverändert, damit auch abweichende Profil-Adressen möglich sind. + +**Beim Video-Typ** wird der Player von YouTube oder Vimeo erst nach einem Klick +geladen, und davor steht, dass dabei Daten an den Anbieter gehen. Ungefragt +einen Drittanbieter einzubinden wäre auf einer Seite, die Fremde nach einem +Scan zu sehen bekommen, nicht in Ordnung. + +#### Hochgeladene Dateien + +PDFs und Bilder liegen **in der Datenbank**, nicht auf der Platte. Damit gibt +es weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat +alles. + +- Die Grenze steht in der `.env` unter `MAX_UPLOAD_MB` (Voreinstellung 10). +- Erlaubt sind PDF sowie PNG, JPEG, WebP, GIF und AVIF. **SVG bewusst nicht** — + eine SVG-Datei darf Skripte enthalten und würde von derselben Adresse wie die + Anwendung ausgeliefert. +- Geprüft wird der **Inhalt**, nicht die Dateiendung. Eine Textdatei mit dem + Namen `harmlos.pdf` wird abgewiesen. +- Wird ein Code gelöscht, verschwindet seine Datei mit ihm. Eine ausgetauschte + Datei und eine hochgeladene, aber nie gespeicherte werden nach 24 Stunden + aufgeräumt. + +#### Rückmeldungen + +Ein Feedback-Eintrag besteht aus einer Note von 1 bis 5 und einem optionalen +Text. Gespeichert wird **kein Name, keine IP-Adresse, keine Gerätekennung** — +wer einen Aushang scannt und eine Note vergibt, bleibt anonym. Gegen Fluten +gibt es eine Bremse von 10 Rückmeldungen pro Minute und Absender; die Adresse +dient dabei nur als Schlüssel im Arbeitsspeicher und wird nirgends gespeichert. + +> Ohne eingeschaltete Anmeldung sieht und ändert jeder, der die Anwendung +> erreicht, alle Codes. Für den Einzelplatz- oder LAN-Betrieb ist das gewollt; +> ins offene Internet gehört die Anwendung dann nicht. Mit `AUTH_ENABLED=true` +> gibt es Benutzer, Gruppen und Rollen — siehe unten. --- diff --git a/docker-compose.yml b/docker-compose.yml index a480278..aebbee0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ services: DATABASE_URL: ${DATABASE_URL} # Anmeldung mit Benutzern, Gruppen und Rollen. AUTH_ENABLED: ${AUTH_ENABLED:-false} + MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10} ports: # Direktes Port-Mapping, bewusst ohne Reverse Proxy. - "${APP_PORT:-3000}:3000" diff --git a/prisma/migrations/20260814140000_datei_uploads/migration.sql b/prisma/migrations/20260814140000_datei_uploads/migration.sql new file mode 100644 index 0000000..8efd234 --- /dev/null +++ b/prisma/migrations/20260814140000_datei_uploads/migration.sql @@ -0,0 +1,24 @@ +-- Hochgeladene Dateien für die Typen „PDF" und „Bild". +-- +-- Der Inhalt liegt als bytea in der Datenbank. Damit bleibt es bei einem +-- Volumen, das gesichert werden muss. +CREATE TABLE "Upload" ( + "id" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "data" BYTEA NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "qrCodeId" TEXT, + + CONSTRAINT "Upload_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "Upload_qrCodeId_idx" ON "Upload"("qrCodeId"); +CREATE INDEX "Upload_createdAt_idx" ON "Upload"("createdAt"); + +-- Wird ein Code gelöscht, verschwindet seine Datei mit ihm. +ALTER TABLE "Upload" + ADD CONSTRAINT "Upload_qrCodeId_fkey" + FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260814150000_feedback/migration.sql b/prisma/migrations/20260814150000_feedback/migration.sql new file mode 100644 index 0000000..b86eafa --- /dev/null +++ b/prisma/migrations/20260814150000_feedback/migration.sql @@ -0,0 +1,20 @@ +-- Rückmeldungen zu einem Feedback-Code. +-- +-- Ohne IP-Adresse und ohne Gerätekennung: Wer einen Aushang scannt und eine +-- Note vergibt, soll nicht wiedererkennbar sein. +CREATE TABLE "Feedback" ( + "id" TEXT NOT NULL, + "qrCodeId" TEXT NOT NULL, + "rating" INTEGER NOT NULL, + "comment" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Feedback_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "Feedback_qrCodeId_createdAt_idx" ON "Feedback"("qrCodeId", "createdAt"); + +ALTER TABLE "Feedback" + ADD CONSTRAINT "Feedback_qrCodeId_fkey" + FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3b09a79..63a3c71 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -180,8 +180,59 @@ model QrCode { createdById String? createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull) + /// Dateien, die zu diesem Code gehören — beim Löschen gehen sie mit. + uploads Upload[] + /// Rückmeldungen, die über die Landingpage eingegangen sind. + feedback Feedback[] + @@unique([domainId, slug]) @@index([createdAt]) @@index([groupId]) @@index([domainId]) } + +/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter +/// einem Bild-Code. +/// +/// Der Inhalt liegt in der Datenbank, nicht auf der Platte. Damit gibt es +/// weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat +/// alles. Bei Dateigrößen im einstelligen Megabyte-Bereich und einer +/// Installation dieser Größenordnung ist das der bessere Tausch als ein +/// zweites Volumen, das beim Sichern vergessen werden kann. +model Upload { + id String @id @default(cuid()) + /// Ursprünglicher Dateiname, für den Download. + filename String + mimeType String + /// Größe in Bytes — als Angabe für die Oberfläche, ohne die Datei zu laden. + size Int + data Bytes + createdAt DateTime @default(now()) + + /// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen, + /// der Code aber noch nicht gespeichert wurde; solche Reste räumt + /// `src/lib/uploads.ts` nach einer Weile weg. + qrCodeId String? + qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade) + + @@index([qrCodeId]) + @@index([createdAt]) +} + +/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes +/// abgegeben hat. +/// +/// Bewusst ohne jeden Bezug zur Person: keine IP-Adresse, kein Kennzeichen +/// des Geräts. Wer einen Zettel an der Wand scannt und eine Note vergibt, +/// soll nicht wiedererkennbar sein. +model Feedback { + id String @id @default(cuid()) + qrCodeId String + qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade) + /// 1 bis 5. + rating Int + comment String? + createdAt DateTime @default(now()) + + @@index([qrCodeId, createdAt]) +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index a66a5c5..6f7c800 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -23,7 +23,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) { const showAdmin = can(actor, "instance:manage"); return ( -
+
}; + +function formatDate(value: Date): string { + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(value); +} + +export default async function FeedbackResultsPage({ params }: PageProps) { + const { id } = await params; + const actor = await requirePageActor(); + + const code = await prisma.qrCode.findUnique({ + where: { id }, + include: { + feedback: { orderBy: { createdAt: "desc" }, take: 200 }, + }, + }); + + if (!code || code.type !== "feedback") notFound(); + if (!can(actor, "codes:read", { groupId: code.groupId })) notFound(); + + const daten = feedbackDataSchema.safeParse(code.data); + const skala = daten.success ? daten.data.scale : "stars"; + const titel = daten.success ? daten.data.title : (code.label ?? "Feedback"); + + const eintraege = code.feedback; + const anzahl = eintraege.length; + const schnitt = + anzahl > 0 + ? eintraege.reduce((summe, eintrag) => summe + eintrag.rating, 0) / anzahl + : 0; + + // Verteilung von 5 nach 1 — so liest man eine Bewertungsübersicht. + const verteilung = [5, 4, 3, 2, 1].map((note) => ({ + note, + anzahl: eintraege.filter((eintrag) => eintrag.rating === note).length, + })); + const groesste = Math.max(1, ...verteilung.map((zeile) => zeile.anzahl)); + + const kommentare = eintraege.filter((eintrag) => eintrag.comment); + + return ( +
+ + + Zurück zu Meine Codes + + +

+ {titel} +

+

+ {anzahl === 0 + ? "Es ist noch keine Rückmeldung eingegangen." + : `${anzahl} ${anzahl === 1 ? "Rückmeldung" : "Rückmeldungen"} · ${code.scans} ${code.scans === 1 ? "Scan" : "Scans"}`} +

+ + {anzahl > 0 ? ( + <> + +
+
+

+ {schnitt.toFixed(1).replace(".", ",")} +

+

von 5 im Schnitt

+
+ +
+ {verteilung.map((zeile) => ( +
+ + {zeile.note}{" "} + + + + + + + {zeile.anzahl} + +
+ ))} +
+
+
+ + {kommentare.length > 0 ? ( +
+

+ Kommentare ({kommentare.length}) +

+
    + {kommentare.map((eintrag) => ( +
  • +
    + + {eintrag.rating}/5 + + + +
    +

    + {eintrag.comment} +

    +
  • + ))} +
+ {anzahl >= 200 ? ( +

+ Angezeigt werden die 200 jüngsten Rückmeldungen. +

+ ) : null} +
+ ) : ( +

+ Bisher hat niemand etwas dazugeschrieben. +

+ )} + + ) : ( + +

+ Sobald jemand den Code scannt und eine Bewertung abgibt, steht sie + hier. Die Rückmeldungen enthalten keinen Namen und keine + IP-Adresse. +

+
+ )} +
+ ); +} diff --git a/src/app/api/codes/[id]/route.ts b/src/app/api/codes/[id]/route.ts index 3c36f8e..3b1653d 100644 --- a/src/app/api/codes/[id]/route.ts +++ b/src/app/api/codes/[id]/route.ts @@ -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); diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts index 6b8b5b9..5feb0e6 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -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 { 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; diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts new file mode 100644 index 0000000..4139516 --- /dev/null +++ b/src/app/api/feedback/route.ts @@ -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(); + +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 { + 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); + } +} diff --git a/src/app/api/uploads/[id]/route.ts b/src/app/api/uploads/[id]/route.ts new file mode 100644 index 0000000..a8075e7 --- /dev/null +++ b/src/app/api/uploads/[id]/route.ts @@ -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 { + 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", + }, + }); +} diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts new file mode 100644 index 0000000..444c5a7 --- /dev/null +++ b/src/app/api/uploads/route.ts @@ -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 { + 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 { + return Response.json({ maxBytes: maxUploadBytes() }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7dfff57..eecbed9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -22,7 +22,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: LayoutProps<"/">) { return ( - {children} + {children} ); } diff --git a/src/components/codes-list.tsx b/src/components/codes-list.tsx index 6f558e9..b122251 100644 --- a/src/components/codes-list.tsx +++ b/src/components/codes-list.tsx @@ -524,6 +524,18 @@ export function CodesList() {
+ {/* Ein Feedback-Code ohne Weg zu den Antworten waere + nur die halbe Sache. */} + {code.type === "feedback" ? ( + + + + ) : null}
+ {code.type === "feedback" ? ( + + + Rückmeldungen + + ) : null} + ); + })} +
+ + {note !== null ? ( +

+ {note} von 5 +

+ ) : null} + + + {allowComment ? ( +
+ +