Files
qrcode-generator/README.md
T
Florian Wilde cdda7df17a Version 1.7.0: Doku durchgesehen und Roadmap angeglichen
Sechs Erweiterungen sind zusammengekommen — Logo im Code, Freigabe je
Gruppe, Domain-Check, Dublettenpruefung, Filter nach Art und die
Kurzhilfe zu den Domains. Dafuer eine neue Minor-Version.

Zwei Stellen waren schlicht falsch und wiegen schwerer als jede Luecke:
Anleitung und Funktionsuebersicht behaupteten beide noch, statische Codes
wuerden "nicht gespeichert" — ueberholt seit 1.5.0. Ebenso die Aussage,
nur "Website" sei ausprogrammiert; das gilt seit 1.1.0 fuer alle fuenfzehn.

Danach die Luecken: Jedes der sechs Features steht jetzt in allen drei
Dokumenten, die es betrifft. Die Bedienungsanleitung hatte keines davon.

Die Roadmap verliert die zwei gebauten Punkte und bekommt einen neuen aus
der Umsetzung: Die Gestaltung eines gespeicherten Codes laesst sich
ueberhaupt nicht aendern, ein Logo also nur ueber die Schnittstelle
nachruesten.

Die Kurzhilfe verwies unter "Nachpruefen" noch auf curl, obwohl seit #7
der Pruefen-Knopf danebensteht.
2026-08-21 11:27:15 +02:00

44 KiB
Raw Permalink Blame History

QR-Code-Generator

Eine Webanwendung zum Erzeugen von QR-Codes — als Docker-Compose-Stack aus zwei Containern: der Next.js-Anwendung und einer PostgreSQL-Datenbank.

Umgesetzt sind alle 15 QR-Typen. Welche davon in der Oberfläche erscheinen, entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.


Was die Anwendung kann — kurz

  • 15 QR-Typen: Website, vCard, App-Link, Mehrere Links, Social Media, WhatsApp, PDF, Bild, Video, E-Mail, SMS, Standort, Zahlung, WLAN, Feedback.
  • Statisch oder dynamisch. Dynamische Codes zeigen auf eine Kurz-URL: Ziel jederzeit änderbar, ohne neu zu drucken — mit Scan-Zähler.
  • Eigene Seiten für Linksammlungen, Profile, hinterlegte PDFs und Bilder sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
  • Gestaltung: freie Farben mit Kontrastwarnung, 256–2048 px, Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
  • Logo in der Mitte: das Logo der Installation oder ein eigenes je Code, 10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
  • Meine Codes: Liste oder Karten, Suche, Sortierung, Filter nach Gruppe und nach Art, Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen ablegen; liegt derselbe Inhalt schon dort, wird gewarnt.
  • Feedback auswerten: Durchschnitt, Verteilung und Kommentare — anonym erhoben, ohne Name und IP-Adresse.
  • Anmeldung abschaltbar. Mit Anmeldung: Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.
  • Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt — mit Kurzhilfe zur Einrichtung und einer Prüfung, ob eine Domain wirklich hierher führt.
  • Single Sign-on über Authentik oder einen anderen OIDC-Anbieter.
  • Typen abschaltbar: Was ihr nicht braucht, erscheint gar nicht erst — für die ganze Installation oder je Gruppe.
  • Eigenes Erscheinungsbild: Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar.
  • Hell und dunkel: folgt dem Betriebssystem, mit Umschalter für eine eigene Wahl.
  • Sprechende Kurz-Links: qr.firma.de/r/sommeraktion statt Zufallszeichen, nachträglich änderbar — die vorherige Adresse bleibt gültig.
  • Sichtbare Version: Die Fußzeile nennt die laufende Fassung und führt zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
  • Zwei Container, kein Reverse Proxy nötig. Migrationen laufen beim Start von selbst; alles Nötige steht in einer .env.

→ Ausführlich: Alle Funktionen im Detail


Anleitungen


Inhalt


Schritt für Schritt zum laufenden Stack

Vom leeren Server bis zum ersten QR-Code. Alle Befehle laufen auf dem Rechner, auf dem die Anwendung später läuft.

Schritt 1 — Voraussetzungen prüfen

Gebraucht werden Docker mit Compose v2 und Git:

docker compose version    # erwartet: Docker Compose version v2.x oder höher
git --version

Kommt docker: command not found, fehlt Docker — die offizielle Anleitung installiert Engine und Compose in einem Rutsch.

Schritt 2 — Projekt holen

git clone https://git.wilde-server.de/florian.wilde/qrcode-generator.git
cd qrcode-generator

Schritt 3 — Konfiguration anlegen

cp .env.example .env
nano .env

Zwei Werte musst du anfassen:

Wert Was eintragen
POSTGRES_PASSWORD Ein eigenes Passwort. Es steht nur in dieser Datei und wird nirgends sonst gebraucht.
DATABASE_URL Dasselbe Passwort noch einmal — es steckt mitten im Verbindungsstring.

Und einen solltest du bewusst setzen:

Wert Was eintragen
BASE_URL Die Adresse, unter der die Anwendung von außen erreichbar ist.

Beispiel für ein eigenes Passwort MeinGeheimes123:

POSTGRES_PASSWORD=MeinGeheimes123
DATABASE_URL=postgresql://qrcode:MeinGeheimes123@db:5432/qrcode?schema=public
BASE_URL=http://192.168.1.50:3000

Warum BASE_URL so wichtig ist: Ein dynamischer QR-Code enthält nicht das eigentliche Ziel, sondern die Kurz-URL {BASE_URL}/r/{slug}. Steht dort localhost, funktioniert der Code nur auf dem Server selbst — ein Handy läuft ins Leere. Bereits gedruckte Codes lassen sich später nicht mehr umbiegen, also am besten vor dem ersten produktiven Code festlegen.

Die Frage dahinter lautet: Wer soll die Codes scannen können?

Wer scannt Was in BASE_URL gehört
nur Leute im eigenen Netz oder per VPN die interne Adresse, z. B. http://192.168.1.50:3000
auch Kundschaft, Besucher, Aushänge im Laden eine öffentlich auflösbare Domain, z. B. https://qr.example.com

Eine Domain, die nur im internen DNS steht, verhält sich dabei wie eine LAN-Adresse: Ohne VPN kommt niemand darauf. Ein gedruckter Code führt dann für alle anderen ins Leere — mit „Server nicht gefunden“, ohne jeden Hinweis darauf, woran es liegt.

Und: BASE_URL muss dieselbe Adresse sein, über die ihr die Oberfläche aufruft — nicht bloß irgendeine erreichbare. Sonst scheitert die Anmeldung über einen externen Anbieter, siehe docs/authentik.md.

Der Hostname db im DATABASE_URL bleibt unverändert — das ist der Servicename aus der docker-compose.yml, nicht dein Server.

Schritt 4 — Starten

docker compose up -d --build

Der erste Durchlauf baut das Image und dauert je nach Maschine 5 bis 15 Minuten. Spätere Starts brauchen nur wenige Sekunden.

Schritt 5 — Prüfen, ob alles läuft

docker compose ps

Erwartet werden zwei Container, db mit dem Zusatz (healthy):

NAME                     SERVICE   STATUS
qrcode-generator-app-1   app       Up 30 seconds
qrcode-generator-db-1    db        Up 45 seconds (healthy)

Und im Protokoll:

docker compose logs app
→ Datenbankmigrationen werden angewendet …
All migrations have been successfully applied.
→ Anwendung wird gestartet …
▲ Next.js 16.3.0
✓ Ready

Die Datenbank richtet sich beim ersten Start selbst ein — es gibt keinen Extraschritt dafür.

Schritt 6 — Aufrufen

Im Browser die Adresse öffnen, die du als BASE_URL eingetragen hast, zum Beispiel http://192.168.1.50:3000. Es erscheint „Was möchtest du erstellen?“ mit den Typ-Kacheln.

Schritt 7 — Ersten Code erstellen

  1. Kachel Website anklicken.
  2. Adresse eingeben, etwa https://example.com — die Vorschau erscheint sofort.
  3. PNG oder SVG herunterladen. Fertig.

Für einen Code, dessen Ziel du später ändern kannst:

  1. Unter „Art des Codes“ auf Dynamisch wechseln.
  2. Optional einen Namen vergeben, damit du ihn wiederfindest.
  3. Dynamischen Code speichern — erst dabei entsteht die Kurz-URL, deshalb ist der Download bis dahin gesperrt.
  4. Danach herunterladen. Unter Meine Codes lässt sich das Ziel jederzeit ändern, ohne den gedruckten Code anzufassen.

Schritt 8 — Aufräumen, was du nicht brauchst

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

docker compose logs -f app     # Protokoll mitlesen (Abbruch mit Strg+C)
docker compose ps              # Status beider Container
docker compose restart app     # Anwendung neu starten
docker compose down            # Stack stoppen — die Daten bleiben erhalten
docker compose up -d           # wieder starten
docker compose down -v         # Stack stoppen UND die Datenbank löschen

Hinter einem Reverse Proxy (Nginx Proxy Manager)

Soll die Anwendung unter einer eigenen Domain mit HTTPS laufen, übernimmt der Proxy die Zertifikate; die Anwendung selbst bleibt unverändert.

1. Port nur lokal veröffentlichen. Wenn Proxy und Anwendung auf demselben Rechner laufen, muss Port 3000 nicht nach außen offen sein. In der .env:

APP_PORT=3000
BASE_URL=https://qr.deine-domain.de

2. Im Nginx Proxy Manager einen Proxy Host anlegen:

Feld Wert
Domain Names qr.deine-domain.de
Scheme http
Forward Hostname / IP IP des Docker-Hosts, z. B. 192.168.1.50
Forward Port 3000
Websockets Support nicht nötig — die Anwendung benutzt keine; angeschaltet schadet es aber nicht
Block Common Exploits an
SSL Zertifikat anfordern, Force SSL aktivieren

Läuft der Proxy als Container im selben Docker-Netz wie dieser Stack, kann statt der IP auch der Servicename app mit Port 3000 stehen — dann muss das Port-Mapping gar nicht nach außen offen sein.

3. BASE_URL auf die öffentliche Adresse setzen — mit https:// und ohne Port. Danach:

docker compose up -d

BASE_URL muss dieselbe Adresse sein, unter der ihr die Anwendung aufruft — nicht bloß irgendeine erreichbare. Steht dort weiterhin http://192.168.1.50:3000, zeigen neue dynamische Codes ins interne Netz statt auf die Domain, und die Anmeldung über einen externen Anbieter scheitert (siehe unten).

Was der Proxy weiterreichen muss

Die Anwendung wertet drei Kopfzeilen aus, die erst der Proxy setzt. Der Nginx Proxy Manager schickt alle drei von sich aus mit — bei anderen Proxys muss man sie eintragen.

Kopfzeile Wofür sie gebraucht wird Fehlt sie, passiert das
X-Forwarded-Proto erkennt, dass die Verbindung über HTTPS läuft Der Sitzungs-Cookie wird ohne Secure gesetzt und ginge auch über eine unverschlüsselte Verbindung mit
X-Forwarded-Host die aufgerufene Domain — für mehrere Domains und für die Anmeldung Kurz-URLs unter Zweitdomains werden nicht zugeordnet
X-Forwarded-For Absender für die Bremse gegen Durchprobieren Alle Aufrufe zählen als derselbe Absender

Für ein reines nginx (ohne NPM) heißt das:

proxy_set_header Host              $host;
proxy_set_header X-Forwarded-Host  $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;

Bei Traefik und Caddy ist nichts zu tun — beide setzen die Kopfzeilen von sich aus.

Größe der Uploads

PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von client_max_body_size mit 413 ab, und zwar bevor die Anwendung sie zu sehen bekommt — die Fehlermeldung kommt dann vom Proxy und nicht aus der Oberfläche.

Der Wert muss also mindestens so groß sein wie MAX_UPLOAD_MB (Voreinstellung 10 MB), besser etwas darüber. Im Nginx Proxy Manager steht das unter Advanced → Custom Nginx Configuration:

client_max_body_size 20m;

Nachprüfen, ob es stimmt

# Kommt die Anwendung überhaupt an?
curl -I https://qr.deine-domain.de/

# Kennt der Container dieselbe Adresse wie der Browser?
docker compose exec app env | grep BASE_URL

Beides muss zusammenpassen. Ein häufiger Fall: Der Proxy läuft, die Domain öffnet sich, aber BASE_URL steht noch auf der internen IP aus der Einrichtung. Dann ist die Oberfläche zwar erreichbar, aber

  • jeder neue dynamische Code zeigt auf die interne Adresse, und
  • die Anmeldung über einen externen Anbieter endet mit einer Fehlermeldung, weil der Anbieter den Browser auf den anderen Host zurückschickt — siehe docs/authentik.md.

Erreichbar für wen?

Der Proxy entscheidet mit, wer die Codes scannen kann. Steht die Domain nur im internen DNS oder ist sie nur über VPN erreichbar, funktionieren gedruckte Codes auch nur dort. Für Aushänge, Flyer oder Verpackungen muss die Domain öffentlich auflösbar und der Proxy von außen erreichbar sein.

Mehrere Domains

Sollen Kurz-URLs unter verschiedenen Adressen laufen — etwa je Kunde eine —, lege sie unter Verwaltung → Domains an und richte im Proxy für jede einen eigenen Proxy Host auf dieselbe Anwendung ein.

Die Weiterleitung erkennt am Host-Header, welche Domain gemeint ist, und prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit 404 — über einen gefälschten Header lässt sich also keine fremde Domain ansprechen.

Nachprüfen, ob eine Domain wirklich hierher führt

Auf der Domain-Seite steht bei jedem Eintrag ein Knopf Prüfen. Er geht den Weg der Scanner nach und meldet vier Dinge:

Schritt Was geprüft wird
DNS Löst der Name auf? Auf welche Adressen?
Zertifikat Gültig, von wem ausgestellt, wie lange noch?
Erreichbar Antwortet dort ein Webserver, und wie schnell?
Ziel Steht dahinter diese Installation?

Der letzte Schritt ist der eigentliche Wert. Die Anwendung ruft dazu unter der Domain eine eigene Route auf und lässt sich einen HMAC über eine frische Zufallszahl zurückgeben; das Geheimnis dafür steht in der Datenbank und verlässt den Server nie. Nur so unterscheidet sich „die Domain zeigt irgendwohin“ von „die Domain zeigt hierher“.

Grenze der Aussage: Der Aufruf startet dort, wo der Container läuft. Wer internes DNS oder Hairpin-NAT hat, bekommt womöglich einen Fehler, obwohl von außen alles stimmt. Umgekehrt gilt: Meldet die Prüfung von innen Erfolg, heißt das noch nicht, dass die Domain auch von außen erreichbar ist. Dafür bleibt der Blick von einem fremden Netz aus.

Zwei Dinge, die daraus folgen:

  • Der Slug ist je Domain eindeutig, nicht global. Derselbe sprechende Slug darf auf zwei Domains liegen und zeigt dort auf verschiedene Ziele.
  • Eine Domain kann mehreren Gruppen dienen. Das heißt aber nicht, dass diese die Codes der jeweils anderen sehen — die Übersicht bleibt strikt nach Gruppe gefiltert.

BASE_URL bleibt dabei wichtig: sie liefert die Standard-Domain für Aufrufe über eine nicht registrierte Adresse (etwa direkt über die IP im LAN) und bestimmt, ob Kurz-URLs mit http oder https gebildet werden.


Wenn etwas nicht läuft

POSTGRES_PASSWORD fehlt — bitte .env aus .env.example anlegen Die .env fehlt oder ist leer. Schritt 3 nachholen.

ports are not available: ... bind: address already in use Port 3000 ist belegt. Entweder den anderen Dienst beenden oder in der .env einen anderen Port setzen, z. B. APP_PORT=3100, dann docker compose up -d.

Der Container db bleibt unhealthy oder startet immer neu Meist ein Datenverzeichnis aus einer älteren PostgreSQL-Version. Protokoll ansehen mit docker compose logs db. Wenn dort von einem inkompatiblen Datenlayout die Rede ist und du die Daten nicht brauchst: docker compose down -v — Achtung, das löscht die Datenbank.

Der QR-Code führt auf dem Handy ins Leere BASE_URL zeigt auf localhost. Auf die LAN-Adresse oder Domain ändern, docker compose up -d, und die betroffenen dynamischen Codes neu herunterladen. Statische Codes sind davon nicht betroffen.

Die Anwendung antwortet nicht, docker compose ps zeigt sie aber als Up docker compose logs app ansehen. Kommt dort ein Datenbankfehler, stimmt meist das Passwort in DATABASE_URL nicht mit POSTGRES_PASSWORD überein — beide Werte müssen identisch sein.

Nach einem Update fehlt etwas oder die Seite ist leer docker compose up -d --build erzwingt einen Neubau. Hilft das nicht: docker compose down && docker compose up -d --build.


Aktualisieren

Für neue Versionen liegt ein Update-Skript bei — einmal für Linux/macOS und einmal für Windows. Es holt den aktuellen Stand aus Git, baut den Stack neu und wartet, bis die Anwendung wieder antwortet.

./update.sh            # Linux / macOS
.\update.ps1           # Windows

Beim ersten Aufruf unter Linux muss das Skript einmal ausführbar gemacht werden: chmod +x update.sh.

Was das Skript tut:

  1. prüft, ob git, docker compose und die .env vorhanden sind,
  2. bricht ab, wenn es lokale Änderungen gibt — sonst würde git pull im Konflikt enden,
  3. holt den neuen Stand mit git merge --ff-only und zeigt die neuen Commits,
  4. baut den Stack nur neu, wenn es tatsächlich Änderungen gab,
  5. wartet, bis die Anwendung auf APP_PORT antwortet, und zeigt sonst das Protokoll,
  6. gibt die Migrationszeilen aus dem Start-Protokoll und den Containerstatus aus.

Optionen:

Option Wirkung
--force / -Force Auch ohne neue Commits neu bauen
--prune / -Prune Danach verwaiste Docker-Images entfernen (docker image prune)
--help Hilfe anzeigen

Die Datenbank bleibt beim Update erhalten — neue Migrationen wendet der App-Container beim Start automatisch an. Wer lieber von Hand aktualisiert:

git pull
docker compose up -d --build

Konfiguration

Alle Einstellungen stehen in der .env im Projektverzeichnis (Vorlage: .env.example).

Variable Bedeutung Standard
BASE_URL Öffentliche Adresse der App — landet in dynamischen QR-Codes http://localhost:3000
APP_PORT Host-Port, auf dem die App veröffentlicht wird 3000
AUTH_ENABLED Anmeldung mit Benutzern, Gruppen und Rollen false
DATABASE_URL Verbindungsstring der App zur Datenbank —
POSTGRES_USER Datenbankbenutzer qrcode
POSTGRES_PASSWORD Datenbankpasswort (muss gesetzt sein) —
POSTGRES_DB Name der Datenbank qrcode

Wichtig: BASE_URL richtig setzen

Ein dynamischer QR-Code enthält nicht das eigentliche Ziel, sondern die Kurz-URL {BASE_URL}/r/{slug}. Diese Adresse muss von den Geräten erreichbar sein, die den Code später scannen.

  • Nur am selben Rechner testen → http://localhost:3000 genügt.
  • Im LAN mit dem Handy scannen → die LAN-Adresse des Servers eintragen, z. B. http://192.168.1.50:3000.

Wird BASE_URL nachträglich geändert, zeigen bereits gedruckte dynamische Codes weiterhin auf die alte Adresse. Am besten also vor dem ersten produktiven Code festlegen.

Nach einer Änderung an der .env:

docker compose up -d

Ändern sich POSTGRES_*-Werte, muss DATABASE_URL entsprechend mitgezogen werden — der Hostname darin bleibt db (der Servicename aus docker-compose.yml).


Bedienung

1. Typ wählen

Die Startseite zeigt unter „Was möchtest du erstellen?“ ein Kachel-Grid der freigegebenen QR-Typen. Alle 15 Typen sind umgesetzt.

Typ Kodiert als Statisch Dynamisch
Website die URL selbst ✓ ✓
E-Mail mailto: mit Betreff und Text ✓ ✓
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:

  • vCard und WLAN werden vom Gerät direkt ausgewertet — die Kamera legt einen Kontakt an oder richtet das Netz ein. Über eine Kurz-URL würde stattdessen nur ein Browser aufgehen.
  • App-Link kann nicht statisch sein: welches Ziel richtig ist, steht erst beim Scannen fest. Die Weiterleitung wertet den User-Agent aus und schickt iOS in den App Store, Android zu Google Play und alles Übrige auf die Ausweichadresse.
  • GiroCode ist kein Web-Ziel, sondern ein Datensatz für Banking-Apps. Als 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 Netzwerkname oder Passwort ausschließlich aus Hex-Ziffern (etwa abc123), setzt die Anwendung ihn wie in der Spezifikation vorgesehen in Anführungszeichen — sonst würden manche Geräte den Wert als Hexzahl deuten.

2. Statisch oder dynamisch

Statisch Dynamisch
Im Code kodiert die Ziel-URL selbst die Kurz-URL {BASE_URL}/r/{slug}
Ziel später änderbar nein ja, jederzeit
Braucht diesen Server nein ja — er leitet weiter
Scans werden gezählt nein ja
Wird gespeichert nein ja, in PostgreSQL

Ein dynamischer Code muss einmal gespeichert werden, denn erst dabei entsteht seine Kurz-URL. Bis dahin zeigt die Vorschau einen Platzhalter und der Download ist gesperrt — so kann kein Code entstehen, der ins Leere führt.

3. Gestalten

  • Farben: Vorder- und Hintergrund per Farbwähler oder als Hex-Wert. Fünf Voreinstellungen und ein Knopf zum Tauschen der Farben stehen bereit.
  • Kontrastwarnung: Die Anwendung berechnet das Kontrastverhältnis und warnt bei schlecht scanbaren Kombinationen — ebenso bei hellem Vordergrund auf dunklem Grund, den viele Scanner-Apps nicht erkennen.
  • Größe: 256 bis 2048 px (gilt für das PNG; das SVG skaliert verlustfrei).
  • Fehlerkorrektur: L, M (Standard), Q oder H. Höhere Level machen den Code robuster gegen Beschädigung, aber auch dichter.
  • Rand: Breite der Ruhezone in Modulen. Weniger als 4 kann das Scannen erschweren.

Die Vorschau wird bei jeder Änderung serverseitig neu erzeugt — sie zeigt also exakt das, was auch heruntergeladen wird.

4. Herunterladen

PNG für Druck und Office-Dokumente, SVG für verlustfreie Skalierung (Plakate, Schilder, Druckvorstufe).

5. Verwaltung: welche Typen erscheinen

Unter Verwaltung → QR-Typen lässt sich jeder der 15 Typen einzeln ein- und ausblenden. Ausgeblendete Typen verschwinden vollständig von der Startseite und lassen sich nicht mehr neu anlegen — praktisch, um die noch nicht umgesetzten Typen gar nicht erst zu zeigen.

Zwei Eigenschaften sind dabei zu unterscheiden:

Bedeutung Woher
umgesetzt Der Typ ist ausprogrammiert. Seit 1.1.0 gilt das für alle fünfzehn. Registry im Code
freigegeben Die Verwaltung zeigt den Typ an. Datenbank, jederzeit änderbar

Nur umgesetzt und freigegeben heißt benutzbar. Freigegeben, aber nicht umgesetzt, erscheint als Kachel „Bald verfügbar“.

Freigabe je Gruppe

Die Auswahl Freigabe für über der Liste hat zwei Stufen:

  • Alle Gruppen (Grundeinstellung) — gilt für die ganze Installation.
  • Nur: <Gruppe> — weicht für diese eine Gruppe davon ab. Die Marketingabteilung bekommt Social Media, die Werkstatt nur Website und PDF.

Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung. Die globale Abschaltung gewinnt immer: Ein Typ, der in der Grundeinstellung aus ist, lässt sich in keiner Gruppe einschalten — der Schalter steht dort sichtbar still und sagt warum. Andersherum wäre „hier abgeschaltet“ keine verlässliche Aussage mehr, und wer den Schalter umlegt, müsste alle Gruppen durchsehen, um zu wissen, was er getan hat.

Auf der Startseite sieht man die Vereinigung über die eigenen Gruppen. Wer in Werkstatt und Marketing ist, sieht beides. Welche Freigabe gilt, entscheidet sich erst beim Anlegen — dort zählt die gewählte Gruppe, und die Route prüft das noch einmal.

Bereits gespeicherte Codes bleiben unberührt. Schaltet man einen Typ ab, leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also nicht ungültig — gesperrt ist nur das Anlegen neuer Codes.

Ohne Eintrag in der Datenbank gilt ein Typ als freigegeben. Eine frische Installation zeigt also alles, und Typen aus späteren Versionen erscheinen automatisch.

6. Meine Codes

Unter „Meine Codes“ stehen alle dynamischen Codes mit Vorschaubild, Kurz-URL, aktuellem Ziel, Scan-Anzahl, Erstellungsdatum und letztem Scan. Dort lässt sich

  • das Ziel ändern — die Kurz-URL und damit der gedruckte Code bleiben gültig,
  • der Code löschen (mit Bestätigungsdialog),
  • das Bild erneut als PNG oder SVG herunterladen.

Liste oder Karten. Voreingestellt ist die Liste — eine Zeile je Code, gedacht für viele Projekte nebeneinander. Über die beiden Schalter rechts neben der Sortierung wechselst du zur Kartenansicht mit großer Vorschau. Die Wahl merkt sich der Browser.

Dazu kommen

  • ein Suchfeld über Name, Ziel, Kurz-URL und Typ,
  • eine Sortierung nach Datum, Name oder Scan-Anzahl,
  • ein Gruppenfilter, sobald Codes in mehr als einer Gruppe liegen,
  • ein Filter nach Art, sobald dynamische und statische Codes nebeneinander liegen.

Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die Kartenansicht.

Abgelegte statische Codes stehen mit in der Liste, tragen aber ein Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Beim Ablegen läuft eine Dublettenprüfung: Liegt in derselben Gruppe schon ein statischer Code mit identischem Inhalt, hält das Ablegen an und nennt ihn — eine Rückfrage mit dem Knopf „Trotzdem ablegen“, keine Sperre. Verglichen wird nur der Inhalt, nicht die Gestaltung. Steht der Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.

Bei einem Feedback-Code führt aus der Zeile ein Stern-Symbol zur Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.


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.


Anmeldung, Gruppen und Rollen

Standardmäßig ist die Anmeldung aus: wer die Anwendung erreicht, darf alles. Für den Einzelplatz- oder LAN-Betrieb ist das gewollt. Sobald mehrere Leute damit arbeiten, lässt sie sich einschalten.

Einschalten

In der .env:

AUTH_ENABLED=true

Danach:

docker compose up -d

Beim nächsten Aufruf erscheint die Ersteinrichtung und du legst den ersten Administrator an. Diese Seite schließt sich, sobald es einen Benutzer gibt — danach führt sie zur normalen Anmeldung.

Fehlt die Zeile in deiner .env? Die .env wird von git pull nicht angefasst — sie gehört bewusst nicht ins Repository. Wurde sie vor Einführung der Anmeldung angelegt, fehlt AUTH_ENABLED darin. Einfach ergänzen; ohne die Zeile gilt false.

Ob es angekommen ist, zeigt:

docker compose exec app env | grep AUTH_ENABLED

Aussperren ist nicht möglich. Der Schalter steht bewusst in der .env und nicht in der Oberfläche. Wer den Zugang verliert, setzt AUTH_ENABLED=false, startet neu und kommt wieder an alles heran.

Bestehende Codes gehen dabei nicht verloren: die Migration legt eine Gruppe Standard an und ordnet ihnen diese zu.

Die vier Ebenen

Ebene Darf
Instanz-Administrator alles: Benutzer, Gruppen, QR-Typen und sämtliche Codes
Administrator einer Gruppe alles in seiner Gruppe, einschließlich Mitglieder und Rollen
Bearbeiter Codes der Gruppe anlegen, ändern, löschen
Betrachter Codes der Gruppe ansehen und herunterladen — sonst nichts

Ein Benutzer kann in mehreren Gruppen sein, mit unterschiedlicher Rolle je Gruppe. Er sieht ausschließlich die Codes der Gruppen, in denen er Mitglied ist.

Aktion Betrachter Bearbeiter Admin der Gruppe Instanz-Admin
Codes sehen, Bilder herunterladen ✓ ✓ ✓ ✓
Statische Codes erzeugen ✓ ✓ ✓ ✓
Dynamische Codes anlegen und ändern – ✓ ✓ ✓
Codes löschen – ✓ ✓ ✓
Mitglieder und Rollen der Gruppe – – ✓ ✓
Gruppen und Benutzer anlegen – – – ✓
QR-Typen freigeben – – – ✓

Was unabhängig von der Anmeldung bleibt

Die Weiterleitung unter /r/{slug} ist immer öffentlich — sonst könnte niemand einen gedruckten Code scannen. Sie verrät nur das Ziel, nicht die Verwaltung.

Benutzer verwalten

Unter Verwaltung → Benutzer: Konten anlegen, Passwörter setzen, Konten deaktivieren oder löschen.

  • Passwörter werden mit scrypt gespeichert (Node-Standardbibliothek, kein natives Modul — dadurch keine Abhängigkeit, die zur Container-Plattform passen muss).
  • Ein neues Passwort oder das Deaktivieren beendet alle offenen Sitzungen des Betroffenen sofort.
  • Nach acht Fehlversuchen ist ein Konto 15 Minuten gesperrt; ein Administrator kann die Sperre vorzeitig aufheben.
  • Der letzte aktive Administrator kann sich weder selbst deaktivieren noch sich die Rechte entziehen noch gelöscht werden.
  • Passwörter kann nur ein Administrator setzen. Einen Versand per E-Mail gibt es bewusst nicht — das spart einen SMTP-Zugang.

Gruppen verwalten

Unter Verwaltung → Gruppen: Gruppen anlegen und Mitglieder mit Rollen zuweisen. Eine Gruppe, die noch Codes enthält, lässt sich nicht löschen — sonst führten gedruckte Codes ins Leere.


Anmeldung über Authentik (OIDC)

Statt Passwörter hier zu pflegen, lässt sich die Anmeldung an einen OpenID-Connect-Anbieter abgeben — getestet mit Authentik, ebenso nutzbar mit Keycloak, Zitadel oder Entra ID.

AUTH_ENABLED=true
OIDC_ENABLED=true
OIDC_ISSUER=https://auth.example.com/application/o/qrcode-generator/
OIDC_CLIENT_ID=…
OIDC_CLIENT_SECRET=…

Auf der Anmeldeseite erscheint dann ein Knopf für den Anbieter; das Passwortfeld bleibt daneben stehen, bis OIDC_ONLY=true gesetzt wird.

Übernommen wird, wer jemand ist — Kennung, E-Mail-Adresse, Anzeigename — und auf Wunsch über OIDC_ADMIN_GROUP das Recht zur Verwaltung. Gruppen und Rollen bleiben Sache dieser Anwendung: Die Gruppen im Verzeichnis bedeuten etwas anderes als die hier, und eine automatische Übersetzung wäre eine Quelle stiller Überraschungen.

Bestehende Konten mit Passwort werden beim ersten Anmelden anhand der bestätigten E-Mail-Adresse verknüpft, nicht verdoppelt.

→ Vollständige Anleitung mit allen Feldern in Authentik, Fehlerbildern und dem Weg zurück: docs/authentik.md


Technischer Aufbau

Stack

Baustein Verwendet
Framework Next.js 16 (App Router, TypeScript), Frontend + API
Oberfläche React 19, Tailwind CSS 4
Datenbank PostgreSQL 18
ORM Prisma 7 mit @prisma/adapter-pg
QR-Erzeugung qrcode, serverseitig, Ausgabe als PNG und SVG
Validierung Zod 4, im Browser und noch einmal auf dem Server

Der Stack läuft ohne Reverse Proxy: Der App-Container wird direkt per Port-Mapping veröffentlicht.

Verzeichnisse

prisma/
  schema.prisma            Datenmodell (Modell QrCode)
  migrations/              Versionierte Migrationen
docker/
  entrypoint.sh            Migration + Serverstart im Container
update.sh / update.ps1     Update-Skript (git pull + Neubau + Prüfung)
src/
  app/
    page.tsx               Startseite mit dem Kachel-Grid
    erstellen/[type]/      Generator je QR-Typ
    meine-codes/           Übersicht der dynamischen Codes
    r/[slug]/route.ts      Weiterleitung dynamischer Codes (302)
    api/
      qr/preview/          Live-Vorschau (SVG als Data-URL)
      qr/download/         Download als PNG oder SVG
      codes/               Anlegen und Auflisten dynamischer Codes
      codes/[id]/          Ändern und Löschen
      codes/[id]/qr/       Bild eines gespeicherten Codes
  components/
    qr-forms/              Formulare je QR-Typ + deren Registry
    qr-generator.tsx       Generator-Oberfläche
    codes-list.tsx         „Meine Codes“
    design-panel.tsx       Farben, Größe, Fehlerkorrektur, Rand
  lib/
    qr/
      type-module.ts       Vertrag eines QR-Typs (defineQrType)
      registry.ts          Registry aller 15 Typen
      types/website.ts     Der einzige aktive Typ in V1
      design.ts            Gestaltungsoptionen + Zod-Schema
      contrast.ts          Kontrastbewertung
      render.ts            PNG-/SVG-Erzeugung
      request.ts           Anfrage-Schema und Auflösung des Code-Inhalts
    db.ts                  Prisma-Client
    env.ts                 Laufzeit-Konfiguration

Datenmodell

Nur dynamische Codes werden gespeichert. Das Modell QrCode hält neben Slug, Zähler und Gestaltung ein generisches data-Feld (JSONB) sowie den Typ — so brauchen die Typen aus Version 2 keine Schemaänderung.

Wie ein QR-Typ aufgebaut ist

Jeder Typ ist ein Modul, das über defineQrType erzeugt wird und vier Dinge beisteuert: Metadaten (Titel, Beschreibung, Icon), ein Zod-Schema, die Kodierung des Inhalts und eine Kurzfassung für Listen.

export const websiteType = defineQrType({
  id: "website",
  title: "Website",
  description: "Verlinke eine beliebige Webadresse.",
  icon: "globe",
  active: true,
  supportsDynamic: true,
  schema: websiteDataSchema,
  defaultData: { url: "" },
  encode: (data) => data.url,
  summary: (data) => describe(data.url),
});

Innerhalb des Moduls arbeitet man mit dem echten, aus dem Zod-Schema abgeleiteten Datentyp; nach außen zeigt die Registry eine einheitliche Schnittstelle. Deshalb kennen Vorschau, Download, Speicherung und Weiterleitung keinen einzigen typspezifischen Sonderfall.

Warum 302 und kein 301?

Die Weiterleitung unter /r/{slug} antwortet mit 302 (temporär) und Cache-Control: no-store. Ein permanenter Redirect würde in Browsern hängen bleiben: Zieländerungen kämen nicht an und der Scan-Zähler bliebe stehen.


Entwicklung ohne Docker

Voraussetzung: Node.js 20.9+ und eine erreichbare PostgreSQL-Instanz.

npm install

# Datenbank z. B. als Einzelcontainer starten
docker run -d --name qr-db -p 5432:5432 \
  -e POSTGRES_USER=qrcode -e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=qrcode \
  postgres:18-alpine

# .env anlegen und DATABASE_URL auf localhost zeigen lassen:
#   DATABASE_URL=postgresql://qrcode:devpass@localhost:5432/qrcode?schema=public

npm run db:migrate        # Migrationen anwenden und Prisma Client erzeugen
npm run dev               # http://localhost:3000

Weitere Skripte:

npm run build             # Produktionsbuild
npm run typecheck         # TypeScript prüfen
npm run lint              # ESLint
npm run db:studio         # Prisma Studio (Datenbank ansehen)

Hinweis zu npm 11: Installationsskripte sind standardmäßig blockiert. Die Pakete, die welche ausführen dürfen (Prisma lädt darüber seine Engine-Binaries), stehen im Feld allowScripts der package.json.


API

Alle Endpunkte antworten im Fehlerfall mit { "error": "…" } und einer deutschen Meldung.

Methode Pfad Zweck
POST /api/qr/preview Vorschau als SVG-Data-URL
POST /api/qr/download PNG- oder SVG-Datei
GET /api/codes Alle dynamischen Codes
POST /api/codes Dynamischen Code anlegen
GET /api/codes/{id} Einzelnen Code lesen
PATCH /api/codes/{id} Ziel, Name oder Gestaltung ändern
DELETE /api/codes/{id} Code löschen
GET /api/codes/{id}/qr Bild des Codes (?format=png|svg&download=1)
GET /r/{slug} Weiterleitung, erhöht den Scan-Zähler
GET /api/admin/types Zustand aller QR-Typen (?group={id} je Gruppe)
PATCH /api/admin/types Typ freigeben oder abschalten (groupId optional)
POST /api/admin/domains/{id}/check Domain prüfen: DNS, Zertifikat, Erreichbarkeit, Ziel
GET /api/domain-check Beweis für die Domain-Prüfung (?nonce=…, ohne Anmeldung)

Beispiel:

curl -X POST http://localhost:3000/api/codes \
  -H 'Content-Type: application/json' \
  -d '{
        "type": "website",
        "data": { "url": "https://example.com" },
        "design": { "fgColor": "#0F172A", "bgColor": "#FFFFFF",
                    "size": 512, "ecLevel": "M", "margin": 4 },
        "label": "Plakat Eingang"
      }'

Einen weiteren Typ ergänzen

Alle 15 vorgesehenen Typen sind gebaut. Kommt ein sechzehnter dazu, besteht er aus:

  1. einem Modul in src/lib/qr/types/ (Zod-Schema, Kodierung, Kurzfassung),
  2. einer Formular-Komponente in src/components/qr-forms/,
  3. je einem Eintrag in src/lib/qr/registry.ts und in src/components/qr-forms/index.ts,
  4. einer Landingpage in src/components/landing/, falls er hasLandingPage meldet.

Am übrigen Code ist dafür nichts zu ändern: Vorschau, Download, Gestaltung, Speicherung, Weiterleitung, Liste und Verwaltung arbeiten für alle Typen gleich. Die Kennung gehört zusätzlich in QR_TYPE_IDS in src/lib/qr/type-module.ts.

Für einen angekündigten, aber noch nicht gebauten Typ gibt es defineComingSoonType: Er erscheint als Kachel mit dem Badge „Bald verfügbar“ und lässt sich nicht öffnen.

Braucht der Typ eine hochgeladene Datei, meldet er ihre Ids über uploadIds — daran hängt, was beim Löschen des Codes mitgeht und was als verwaist weggeräumt wird.

Was als Nächstes sinnvoll wäre

  • Gruppen aus dem Verzeichnis übernehmen. Die OIDC-Anbindung übernimmt heute bewusst nur die Identität. Eine Zuordnung von Verzeichnisgruppen zu Gruppen dieser Anwendung wäre möglich, braucht aber eine klare Regel dafür, was mit von Hand vergebenen Rollen passiert.
  • Statistik über die Zeit. Der Zähler weiß heute nur, wie oft und wann zuletzt. Ein Verlauf je Tag wäre eine eigene Tabelle — und die Frage, wie lange man ihn aufbewahrt.
  • Codes exportieren. Eine CSV aller Codes einer Gruppe, für die Übergabe an Druckereien.