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 ? ( +
+ +