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 ( -
+ {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
++ {eintrag.comment} +
++ 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. +
+