23 Commits
Author SHA1 Message Date
florian.wilde 1f4753b753 Merge: Version 1.7.0 — Dokumentation durchgesehen, Roadmap angeglichen 2026-08-21 09:41:25 +00:00
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
florian.wilde c6891f66ff Merge #11: QR-Typen je Gruppe freigeben 2026-08-21 09:00:09 +00:00
Florian Wilde ff28352907 Merge branch 'main' into feat/11-typen-je-gruppe 2026-08-21 10:58:08 +02:00
florian.wilde 830d6d7379 Merge #8: Logo im QR-Code 2026-08-21 08:56:57 +00:00
Florian Wilde a4bf3c77b6 Merge branch 'main' into feat/8-logo-im-code
# Conflicts:
#	docs/funktionen.md
2026-08-21 10:55:54 +02:00
florian.wilde 43a0504f38 Merge #7: Domain-Check für DNS, Webserver und Zertifikat 2026-08-21 08:53:24 +00:00
Florian Wilde 14b9ffc22f Merge branch 'main' into feat/7-domain-check
# Conflicts:
#	src/components/domain-admin.tsx
2026-08-21 10:51:57 +02:00
florian.wilde 87ffdcae04 Merge #6: Dublettenprüfung 2026-08-21 08:46:49 +00:00
florian.wilde a7af9c9290 Merge #5: Filtermöglichkeit der angelegten Codes 2026-08-21 08:45:51 +00:00
florian.wilde 101f664cef Merge #4: Domains hinzufügen — Kurzhilfe erstellen 2026-08-21 08:43:16 +00:00
Florian Wilde 3cb5421a20 QR-Typen je Gruppe freigeben
Bisher galt die Freigabe eines Typs fuer die ganze Installation. Bei
mehreren Gruppen ist das zu grob: Die Marketingabteilung braucht Social
Media, die Werkstatt nur Website und PDF.

Die Freigabe bekommt eine zweite Ebene. Die Grundeinstellung bleibt die
Grundlage; je Gruppe laesst sich davon abweichen. Fehlt eine Abweichung,
folgt die Gruppe der Grundeinstellung.

Zur offenen Frage im Issue: Die globale Abschaltung gewinnt immer. Ein
global abgeschalteter Typ laesst sich in keiner Gruppe einschalten — der
Schalter steht dort sichtbar still und nennt den Grund. Andersherum waere
"hier abgeschaltet" keine verlaessliche Aussage mehr.

Eine eigene Tabelle statt eines zusammengesetzten Schluessels in
QrTypeSetting: Der globale Fall braeuchte dort eine leere Gruppe, und
PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
nebeneinander zu — die Eindeutigkeit der globalen Zeile waere also gerade
nicht gesichert.

Die Startseite zeigt die Vereinigung ueber die eigenen Gruppen; beim
Anlegen entscheidet die gewaehlte Gruppe, und die Route prueft das noch
einmal.

Refs #11
2026-08-21 03:48:22 +02:00
Florian Wilde c06f87e6d9 Logo in der Mitte des QR-Codes
Zur Wahl stehen zwei Quellen: das Logo der Installation aus dem
Erscheinungsbild oder ein eigenes je Code. Die Groesse ist zwischen 10 und
25 Prozent der Kantenlaenge einstellbar, hinter dem Bild liegt eine Flaeche
in der Hintergrundfarbe.

Mit Logo gilt die Fehlerkorrektur H, unabhaengig von der Auswahl — was das
Bild verdeckt, muss sie ausgleichen. Die Auswahl steht dann fest auf H und
sagt auch warum; heimlich geschieht das nicht. Ab 22 Prozent kommt eine
Warnung dazu.

Gerendert wird weiterhin serverseitig, damit Vorschau und Download
uebereinstimmen: das SVG bekommt ein eingebettetes Bild, das PNG wird mit
sharp zusammengesetzt. sharp lag ohnehin als Abhaengigkeit von Next im
Baum und steht jetzt ausdruecklich in der package.json.

Ein eigenes Logo gehoert zur Gestaltung, nicht zum Inhalt — es muss
trotzdem an den Code gehaengt werden. Sonst raeumt die Aufraeumroutine es
nach einem Tag weg und der Code verliert sein Bild.

Refs #8
2026-08-21 03:22:36 +02:00
Florian Wilde 69c34c4f0c Domain-Check: fuehrt die Domain wirklich hierher?
Eine Domain zu registrieren ist der kleinste Teil der Arbeit — DNS,
Reverse Proxy und Zertifikat liegen ausserhalb dieser Anwendung. Fehlt
dort etwas, merkt man es am gedruckten Code, der nicht aufgeht.

Der Knopf "Pruefen" geht den Weg der Scanner nach: Namen aufloesen,
Zertifikat ansehen, Adresse aufrufen — und sich beweisen lassen, dass am
anderen Ende diese Installation steht. Der Beweis ist ein HMAC ueber eine
frische Zufallszahl; das Geheimnis dafuer entsteht beim ersten Check und
verlaesst den Server nie.

Geprueft wird nur ein Hostname aus der Datenbank, nie einer aus der
Anfrage — sonst waere die Route ein Werkzeug zum Abklopfen fremder
Adressen. Scheitert schon DNS, fallen die drei Folgeschritte aus, statt
dreimal dasselbe zu melden.

Die Grenze der Aussage steht im Bericht selbst: Der Aufruf startet dort,
wo der Container laeuft.

Refs #7
2026-08-21 03:06:54 +02:00
Florian Wilde 60f46bcdb5 Warnung vor doppelt abgelegten statischen Codes
Ein statischer Code traegt seinen Inhalt selbst — zweimal derselbe Inhalt
ergibt zweimal exakt dasselbe Bild. Bisher entstand der zweite Eintrag
stillschweigend.

Das Anlegen antwortet nun mit 409 und nennt den vorhandenen Eintrag samt
Namen und Datum; die Oberflaeche zeigt das als Warnung mit dem Knopf
"Trotzdem ablegen". Eine Rueckfrage, keine Sperre — zwei Fassungen
desselben Codes koennen gewollt sein.

Verglichen wird Typ und Inhalt innerhalb der Gruppe, nicht die Gestaltung.
Dynamische Codes bleiben aussen vor: zwei Kurz-Links auf dasselbe Ziel
sind der Normalfall, sobald getrennt gezaehlt werden soll.

ApiError traegt dafuer jetzt Status und Antwortkoerper — eine gemeldete
Dublette ist keine Sackgasse, sondern eine Rueckfrage.

Refs #6
2026-08-21 02:53:23 +02:00
Florian Wilde 142536140c Meine Codes: Liste nach Art einschraenken
Seit statische Codes ablegbar sind, mischen sich zwei Arten in derselben
Liste. Eine Auswahl schraenkt sie auf dynamische oder statische ein — und
erscheint nur, wenn ueberhaupt beide vorkommen.

Zeigt die Liste nur statische Codes, faellt die Scan-Spalte weg und die
Sortierung nach Scans mit ihr: Statische zaehlen keine Scans, die Spalte
waere eine Reihe aus Strichen. Stand die Sortierung darauf, springt sie
auf "Neueste zuerst".

Die Beschreibung der Seite behauptete noch, statische Codes wuerden nicht
gespeichert. Das stimmt seit #2 nicht mehr.

Refs #5
2026-08-21 02:43:13 +02:00
Florian Wilde 1cf1df54df Kurzhilfe erklaert das Hinzufuegen einer Domain
Eine Domain zu registrieren genuegt nicht — DNS, Reverse Proxy und
Zertifikat muessen dazu passen. Bislang stand das nur im README, also
genau dort nicht, wo man es braucht.

Auf der Domain-Seite oeffnet ein Knopf die Erklaerung in sechs Schritten,
mit den drei noetigen Kopfzeilen und einem Pruefbefehl. Der Knopf selbst
ist als HelpButton allgemein gehalten, damit andere Verwaltungsseiten
spaeter dieselbe Form bekommen koennen.

Refs #4
2026-08-21 02:36:18 +02:00
florian.wilde b96e9ddbe5 Merge #9: Versionshinweis mit Aenderungsverlauf 2026-08-21 00:06:30 +00:00
Florian Wilde 586e1f042f Versionshinweis mit Aenderungsverlauf
Bisher liess sich der laufenden Anwendung nicht ansehen, welche Fassung sie
ist. Nach einem Update war damit auch nicht zu erkennen, ob es angekommen
ist — die haeufigste Frage im Betrieb.

Die Fussleiste nennt jetzt die Version und fuehrt auf /version mit dem
vollstaendigen Verlauf. Zusammengetragen ist er aus dem, was es schon gab:
dem Abschnitt „Gebaut" der Roadmap und der Git-Historie.

Gepflegt wird der Verlauf an genau einer Stelle, src/lib/changelog.ts. Daraus
entsteht sowohl die Seite als auch die CHANGELOG.md (npm run changelog); eine
Pruefung mit --check wacht darueber, dass beides zusammenpasst. Zwei gepflegte
Listen liefen unweigerlich auseinander, und ein Changelog, der nicht stimmt,
ist schlimmer als keiner.

Die Historie hatte keine Tags. Die Eintraege sind deshalb nach Erscheinungstag
zu Versionen zusammengefasst — die Nummern sind nachtraeglich vergeben, die
Daten stammen aus den Commits. Zu jeder Version gibt es jetzt einen
annotierten Tag im Repo; v1.6.0 folgt beim Merge.

Die Roadmap fuehrt den Verlauf nicht mehr doppelt, sondern verweist darauf und
behaelt den Blick nach vorn.

Refs #9
2026-08-21 01:58:24 +02:00
florian.wilde 7f67a35218 Merge #2: Statische Codes benennen und auf Wunsch ablegen 2026-08-20 23:26:45 +00:00
Florian Wilde eeaf3820b7 Statische Codes benennen und auf Wunsch ablegen
Das Feld „Name (optional)" stand nur im Dynamisch-Block. Bei einem statischen
Code gab es damit keine Moeglichkeit, ihm einen Namen zum Wiederfinden zu
geben.

Jetzt gilt es in beiden Betriebsarten und tut zweierlei: Es bestimmt den
Dateinamen beim Herunterladen — aus „Plakat Eingang" wird
qr-plakat-eingang.png statt qr-example-com-plakat.png — und es laesst sich
damit ein statischer Code in „Meine Codes" ablegen.

Ein abgelegter statischer Code ist ein Schnappschuss, kein steuerbares Ziel.
Aenderbar ist nur der Name; Inhalt und Gestaltung bleiben, wie sie beim
Ablegen waren, denn genau das steckt im gedruckten Bild. Er bekommt keinen
Kurz-Link, ist unter /r nicht erreichbar und zaehlt keine Scans — in der
Liste steht dort „statisch" und ein Strich. Wer nach dem Ablegen den Inhalt
aendert, bekommt das Ablegen erneut angeboten; der vorherige Eintrag bleibt
als eigener Schnappschuss.

Dafuer traegt QrCode jetzt `mode`, und `slug` darf leer sein. In PostgreSQL
schliessen sich mehrere NULL in einem eindeutigen Index nicht aus, die
Bedingung „je Domain eindeutig" gilt fuer dynamische Codes also unveraendert.

Refs #2
2026-08-20 19:33:49 +02:00
Florian Wilde 46e7eb0b59 Sprechende Kurz-Links: selbst vergeben, aendern, frueher gueltig halten
Schliesst Issue #1. Der Wunsch betraf den Kurz-Link, nicht das Ziel: Er soll
sich von Anfang an bestimmen und spaeter aendern lassen.

Beim Anlegen gibt es dafuer ein Feld, im Bearbeiten-Dialog laesst er sich
aendern. Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und „/" als
Trenner — damit sind Adressen wie kunde-a/sommer moeglich. Umlaute bleiben
draussen, weil sie in Adressen unleserlich kodiert werden; Gross- und
Kleinschreibung ist beim Aufrufen egal.

Beim Aendern bleibt die vorherige Adresse gueltig. Das ist der Kern: Auf
gedruckten Codes steht die alte, und genau deren Bestand ist der Sinn eines
dynamischen Codes. Frueher vergebene Adressen stehen im Dialog und lassen
sich einzeln freigeben — fuer den Tippfehler, mit dem noch nichts gedruckt
ist. Solange eine Adresse gehalten wird, kann sie kein anderer Code
uebernehmen; sonst liessen sich fremde gedruckte Codes umlenken.

Vergeben und Aendern ist standardmaessig Administratoren vorbehalten. Ein
Kurz-Link ist eine oeffentliche Adresse, und wer ihn aendert, kann Aushaenge
entwerten. Unter Verwaltung -> Einstellungen laesst sich das fuer Bearbeiter
freigeben; ohne das Recht erscheint das Feld gar nicht, Codes anlegen und
deren Ziel aendern geht weiterhin. Eigene Aktion in der Policy statt Teil von
codes:update, weil sie mehr wiegt.

Technisch: /r und /s sind Sammelrouten, weil ein Kurz-Link „/" enthalten
darf. Die Eindeutigkeit gilt zusaetzlich auf der kleingeschriebenen Form; die
Migration bricht ab, falls der Bestand das nicht zulaesst, statt still Daten
zu verlieren. Der bestehende exakte Index bleibt — er traegt den haeufigen
Fall, den gescannten Code.
2026-08-17 10:41:57 +02:00
Florian Wilde c38b27ca85 Anmeldung: Host-Abweichung frueh erkennen, weniger auf der Anmeldeseite verraten
Im Betrieb aufgetreten: Die Anwendung lief hinter einem Reverse Proxy unter
einer Domain, in der .env stand aber noch die interne Adresse aus der
Einrichtung. Der Anmeldeknopf setzt den Cookie mit dem laufenden Vorgang auf
dem Host, ueber den der Browser da ist — die Rueckkehradresse fuer den
Anbieter kommt dagegen aus BASE_URL. Der Anbieter schickte den Browser also
auf den anderen Host, der Cookie blieb zurueck, und am Ende stand „Der
Anmeldevorgang ist abgelaufen". Eine Meldung, die die Ursache gut versteckt.

Jetzt prueft der Start beide Adressen und bricht sofort mit einer Erklaerung
ab. Auf dem Bildschirm steht nur die aufgerufene Adresse — die interne gehoert
ins Protokoll, nicht auf eine Seite, die jeder sieht.

Aus demselben Grund faellt der Hinweis „Die Anmeldung mit Passwort ist
abgeschaltet (OIDC_ONLY=true)" weg. Wie die Anmeldung eingerichtet ist, geht
Fremde nichts an; wer hierher gehoert, sieht den einen Knopf und weiss, was zu
tun ist. Beim allerersten Aufruf bleibt der Hinweis, dass der Erste
Administrator wird — der ist dann nuetzlich.

Dazu die Anleitungen: Der Reverse-Proxy-Abschnitt im README nennt jetzt die
drei Kopfzeilen, die die Anwendung vom Proxy erwartet, und was ohne sie
passiert; dazu client_max_body_size fuer Uploads, ein Weg zum Nachpruefen und
die Frage, fuer wen die Codes erreichbar sein sollen. In authentik.md steht,
warum BASE_URL und die Adresse im Browser dieselbe sein muessen.
2026-08-17 00:28:31 +02:00
67 changed files with 4367 additions and 392 deletions
+85
View File
@@ -0,0 +1,85 @@
# Änderungen
Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der
Anwendung unter **Version und Änderungen**, erreichbar über die
Versionsnummer in der Fußzeile.
> Diese Datei wird erzeugt: `npm run changelog`.
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
## 1.7.0 — 21.08.2026
Logo im Code, Freigabe je Gruppe und ein Domain-Check.
- Ein Logo in der Mitte des Codes — das der Installation oder ein eigenes je Code, 10 bis 25 % der Kantenlänge. Die Fehlerkorrektur springt dabei auf H.
- QR-Typen lassen sich je Gruppe freigeben. Die Grundeinstellung bleibt die Grundlage; eine globale Abschaltung gewinnt immer.
- Der Knopf „Prüfen“ bei jeder Domain meldet DNS, Zertifikat, Erreichbarkeit — und ob dahinter wirklich diese Installation steht.
- Eine Kurzhilfe auf der Domain-Seite führt in sechs Schritten auf, was für eine neue Domain einzurichten ist.
- „Meine Codes“ lässt sich nach Art filtern: alle, nur dynamische, nur statische.
- Beim Ablegen eines statischen Codes wird gewarnt, wenn derselbe Inhalt schon in der Gruppe liegt.
## 1.6.0 — 21.08.2026
Sichtbare Version mit Änderungsverlauf.
- Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.
- Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.
- Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.
## 1.5.0 — 20.08.2026
Statische Codes lassen sich benennen und ablegen.
- Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.
- Der Name bestimmt den Dateinamen beim Herunterladen.
- Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.
## 1.4.0 — 17.08.2026
Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.
- Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.
- Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.
- Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.
- Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.
- Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.
## 1.3.0 — 16.08.2026
Eigenes Erscheinungsbild und ein Dark Mode.
- Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.
- Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.
- Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.
- Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.
## 1.2.0 — 15.08.2026
Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.
- Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.
- Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.
- Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.
- Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.
## 1.1.0 — 14.08.2026
Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.
- Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.
- Landingpages als eigene Gattung für Typen ohne fremdes Ziel.
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.
- Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.
- Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.
- „Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.
- Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.
## 1.0.0 — 13.08.2026
Erste lauffähige Fassung.
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.
- QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.
- Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.
- Download als PNG und SVG, serverseitig gerendert.
- Update-Skript für Linux, macOS und Windows.
+164 -17
View File
@@ -18,19 +18,28 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung. sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px, - **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG. Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter, - **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
Ziel ändern, löschen. 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 - **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
erhoben, ohne Name und IP-Adresse. erhoben, ohne Name und IP-Adresse.
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen - **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
Administrator, Bearbeiter und Betrachter. Administrator, Bearbeiter und Betrachter.
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt. - **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. - **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst. - **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 - **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
Admin-Bereich einstellbar. Admin-Bereich einstellbar.
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine - **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
eigene Wahl. 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 - **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
von selbst; alles Nötige steht in einer `.env`. von selbst; alles Nötige steht in einer `.env`.
@@ -45,7 +54,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung, - **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
Rollen und der Weg zurück Rollen und der Weg zurück
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung - **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
- **[Roadmap](docs/roadmap.md)** — was gebaut ist und was kommen könnte - **[Änderungen](CHANGELOG.md)** — was in welcher Version dazugekommen ist
- **[Roadmap](docs/roadmap.md)** — was als Nächstes kommen könnte
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht, - **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
Härtungen und was bewusst offen bleibt Härtungen und was bewusst offen bleibt
@@ -124,9 +134,25 @@ BASE_URL=http://192.168.1.50:3000
> **Warum `BASE_URL` so wichtig ist:** Ein dynamischer QR-Code enthält nicht > **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 > das eigentliche Ziel, sondern die Kurz-URL `{BASE_URL}/r/{slug}`. Steht dort
> `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy > `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy
> läuft ins Leere. Trage deshalb die LAN-Adresse des Servers oder die > läuft ins Leere. Bereits gedruckte Codes lassen sich später nicht mehr
> öffentliche Domain ein. Bereits gedruckte Codes lassen sich später nicht > umbiegen, also am besten vor dem ersten produktiven Code festlegen.
> 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](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der
Servicename aus der `docker-compose.yml`, nicht dein Server. Servicename aus der `docker-compose.yml`, nicht dein Server.
@@ -234,7 +260,7 @@ BASE_URL=https://qr.deine-domain.de
| Scheme | `http` | | Scheme | `http` |
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` | | Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
| Forward Port | `3000` | | Forward Port | `3000` |
| Websockets Support | an | | Websockets Support | nicht nötig — die Anwendung benutzt keine; angeschaltet schadet es aber nicht |
| Block Common Exploits | an | | Block Common Exploits | an |
| SSL | Zertifikat anfordern, *Force SSL* aktivieren | | SSL | Zertifikat anfordern, *Force SSL* aktivieren |
@@ -249,9 +275,76 @@ Port. Danach:
docker compose up -d docker compose up -d
``` ```
> `BASE_URL` muss zur Adresse passen, unter der Besucher die Anwendung > `BASE_URL` muss **dieselbe** Adresse sein, unter der ihr die Anwendung
> aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue > aufruft — nicht bloß irgendeine erreichbare. Steht dort weiterhin
> dynamische Codes ins interne Netz statt auf die Domain. > `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:
```nginx
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**:
```nginx
client_max_body_size 20m;
```
### Nachprüfen, ob es stimmt
```bash
# 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](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
### 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 ### Mehrere Domains
@@ -264,6 +357,30 @@ 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 404 — über einen gefälschten Header lässt sich also keine fremde Domain
ansprechen. 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: Zwei Dinge, die daraus folgen:
- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende - Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende
@@ -494,12 +611,32 @@ Zwei Eigenschaften sind dabei zu unterscheiden:
| | Bedeutung | Woher | | | Bedeutung | Woher |
| --- | --- | --- | | --- | --- | --- |
| **umgesetzt** | Der Typ ist ausprogrammiert. In Version 1 gilt das nur für „Website“. | Registry im Code | | **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 | | **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
umgesetzt, erscheint als Kachel „Bald verfügbar“. 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, > **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab,
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter > leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also > bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
@@ -529,13 +666,21 @@ Dazu kommen
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ, - ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl, - eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen. - 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 Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
Kartenansicht. Kartenansicht.
Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden. 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 Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare. Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
@@ -875,8 +1020,10 @@ deutschen Meldung.
| `DELETE` | `/api/codes/{id}` | Code löschen | | `DELETE` | `/api/codes/{id}` | Code löschen |
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) | | `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler | | `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
| `GET` | `/api/admin/types` | Zustand aller QR-Typen | | `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) |
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten | | `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: Beispiel:
+92 -3
View File
@@ -112,12 +112,40 @@ gescannt zu werden.
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
trennen kann. trennen kann.
### Ein Logo in die Mitte setzen
Ganz unten in der Gestaltung steht **Logo in der Mitte**. Ein Haken genügt,
dann fragt die Anwendung, woher das Bild kommen soll:
- **Logo dieser Installation** — das, was unter Verwaltung → Erscheinungsbild
hinterlegt ist. Kein Upload je Code nötig. Die Wahl erscheint nur, wenn dort
überhaupt eines liegt.
- **Eigenes Logo** — je Code hochgeladen, für Kundenprojekte mit fremdem
Zeichen. PNG, JPEG, WebP oder AVIF, bis 2 MB.
Die **Größe** liegt zwischen 10 und 25 % der Kantenlänge; voreingestellt sind
20 %, das verdeckt rund 4 % der Fläche. Hinter dem Bild liegt eine Fläche in
der Hintergrundfarbe, damit es sich absetzt.
**Die Fehlerkorrektur springt dabei auf H** und lässt sich nicht mehr ändern —
was das Bild verdeckt, muss sie ausgleichen. Das steht auch so unter der
Auswahl; heimlich geschieht es nicht.
Ab 22 % erscheint eine Warnung. Die Fehlerkorrektur trägt das zwar, aber
manche Scanner-Apps suchen in der Mitte nach Ausrichtungsmustern.
> **Einmal selbst scannen, bevor etwas in den Druck geht.** Ein Code mit Logo
> ist die einzige Stelle, an der die Anwendung etwas ausliefern kann, das auf
> dem einen Gerät geht und auf dem anderen nicht. Zwei verschiedene Apps
> kosten eine halbe Minute und ersparen eine Neuauflage.
--- ---
## Meine Codes ## Meine Codes
Hier stehen alle **dynamischen** Codes. Statische erscheinen nicht — sie Hier stehen die **dynamischen** Codes und die statischen, die du abgelegt
werden nicht gespeichert. hast. Beide Arten stehen nebeneinander in derselben Liste — ein statischer
trägt statt einer Kurz-URL das Abzeichen „statisch“ und zählt keine Scans.
![Meine Codes als Liste](bilder/04-meine-codes-liste.png) ![Meine Codes als Liste](bilder/04-meine-codes-liste.png)
@@ -128,6 +156,10 @@ Projekte nebeneinander.
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl. - Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe - Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
liegen. liegen.
- Ein **Filter nach Art** erscheint, sobald dynamische und statische Codes
nebeneinander liegen: *Alle Arten*, *Nur dynamische*, *Nur statische*.
Steht er auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung
nach Scans mit ihr — bei einer Liste aus lauter Strichen sagt sie nichts.
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage. - Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
@@ -135,6 +167,21 @@ Vorschau. Die Wahl merkt sich der Browser.
![Meine Codes als Karten](bilder/05-meine-codes-karten.png) ![Meine Codes als Karten](bilder/05-meine-codes-karten.png)
### Statische Codes ablegen
Ein statischer Code ist mit dem Download eigentlich fertig — sein Inhalt
steckt in ihm. Der Knopf **In „Meine Codes“ ablegen** legt ihn trotzdem in die
Liste, damit du ihn wiederfindest und erneut herunterladen kannst.
Liegt dort schon ein statischer Code mit **identischem Inhalt**, hält das
Ablegen an und zeigt, welcher das ist und wann er entstand. Der Knopf heißt
dann **Trotzdem ablegen** — es ist eine Warnung, keine Sperre. Verglichen wird
nur der Inhalt, nicht die Gestaltung: Derselbe Code in Schwarz-Weiß und in
Hausfarben gilt hier als Dublette.
Bei **dynamischen** Codes fragt niemand nach. Zwei Kurz-Links auf dasselbe
Ziel sind der Normalfall, sobald zwei Kampagnen getrennt gezählt werden.
### Ziel ändern ### Ziel ändern
![Dialog zum Ändern des Ziels](bilder/06-ziel-bearbeiten.png) ![Dialog zum Ändern des Ziels](bilder/06-ziel-bearbeiten.png)
@@ -247,6 +294,25 @@ sich Typen abschalten; sie verschwinden dann aus der Auswahl.
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
bearbeiten. Nur neue lassen sich nicht mehr anlegen. bearbeiten. Nur neue lassen sich nicht mehr anlegen.
**Freigabe je Gruppe.** Über der Liste steht die Auswahl **Freigabe für**:
| Auswahl | Wirkung |
| --- | --- |
| Alle Gruppen (Grundeinstellung) | gilt für die ganze Installation |
| Nur: *eine Gruppe* | weicht für diese eine Gruppe ab |
So bekommt die Marketingabteilung Social Media und die Werkstatt nur Website
und PDF. Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung.
**Die Grundeinstellung gewinnt immer.** Ein Typ, der dort abgeschaltet ist,
lässt sich in keiner Gruppe einschalten — der Schalter steht dann ausgegraut
und nennt den Grund. Andersherum wäre „hier abgeschaltet“ keine verlässliche
Aussage mehr.
Wer in mehreren Gruppen ist, sieht auf der Startseite die **Vereinigung**.
Welche Freigabe zählt, entscheidet sich beim Anlegen anhand der gewählten
Gruppe.
### Gruppen ### Gruppen
![Gruppen verwalten](bilder/13-verwaltung-gruppen.png) ![Gruppen verwalten](bilder/13-verwaltung-gruppen.png)
@@ -275,7 +341,30 @@ mehreren Gruppen dienen; die Codes bleiben trotzdem strikt nach Gruppe
getrennt. getrennt.
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
Anwendung leiten — siehe README, Abschnitt „Mehrere Domains“. Anwendung leiten. Was dafür einzurichten ist, steht nicht nur im README: Der
Knopf **Kurzhilfe** über der Liste führt es in sechs Schritten auf — DNS,
Weiterleitung im Proxy samt der drei nötigen Kopfzeilen, Zertifikat,
Registrieren, Gruppenzuteilung, Nachprüfen.
**Prüfen, ob eine Domain wirklich hierher führt.** Bei jedem Eintrag steht ein
Knopf **Prüfen**. Er geht den Weg der Scanner nach und meldet vier Dinge:
| Schritt | Was er sagt |
| --- | --- |
| DNS | ob der Name auflöst, und auf welche Adressen |
| Zertifikat | gültig bis wann, von wem ausgestellt |
| Erreichbar | ob dort ein Webserver antwortet, und wie schnell |
| Ziel | ob dahinter **diese** Installation steht |
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
sagt wenig; die Prüfung lässt sich beweisen, dass es diese Anwendung ist.
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
Meldung würde die eine wichtige verdecken.
> **Der Aufruf startet dort, wo der Container läuft.** Bei internem DNS oder
> Hairpin-NAT kann er scheitern, obwohl von außen alles stimmt. Und
> umgekehrt: Ein Erfolg von innen beweist noch nicht, dass die Domain von
> außen erreichbar ist. Dafür bleibt der Blick von einem fremden Netz aus.
--- ---
+30 -1
View File
@@ -94,6 +94,34 @@ Zu beachten:
> falsch eingestellt, sondern die `BASE_URL` — und die gehört korrigiert, > falsch eingestellt, sondern die `BASE_URL` — und die gehört korrigiert,
> bevor der erste dynamische Code entsteht: Sie steckt in jedem davon. > bevor der erste dynamische Code entsteht: Sie steckt in jedem davon.
### `BASE_URL` und die Adresse im Browser müssen dieselbe sein
Nicht nur „irgendeine erreichbare Adresse“ — **dieselbe**, über die ihr die
Anwendung tatsächlich aufruft.
Der Grund liegt im Ablauf: Beim Klick auf den Anmeldeknopf legt die Anwendung
einen Cookie mit dem laufenden Vorgang ab — auf dem Host, über den der Browser
gerade da ist. Die Rückkehradresse für Authentik kommt dagegen aus `BASE_URL`.
Zeigen die beiden auf verschiedene Hosts, schickt Authentik den Browser auf den
anderen Host, der Cookie bleibt beim ersten zurück, und die Anmeldung endet mit
**„Der Anmeldevorgang ist abgelaufen“**. Erkennbar ist es daran, dass in der
Adresszeile plötzlich eine andere Adresse steht als die, mit der man begonnen
hat.
Seit Version vom 17.08.2026 fängt die Anwendung das ab und nennt beide
Adressen, statt den Vorgang bis zum Ende laufen zu lassen.
Ein häufiges Muster: Die Anwendung läuft hinter einem Reverse Proxy unter
`https://qr.example.com`, in der `.env` steht aber noch die interne Adresse
`http://10.0.0.5:3005` aus der Einrichtung. Dann gehören **drei** Werte in
Übereinstimmung gebracht:
| | |
| --- | --- |
| Aufruf im Browser | `https://qr.example.com` |
| `BASE_URL` in der `.env` | `https://qr.example.com` |
| Redirect URI in Authentik | `https://qr.example.com/api/auth/oidc/callback` |
--- ---
## Schritt 2 — Eine Application anlegen ## Schritt 2 — Eine Application anlegen
@@ -259,7 +287,8 @@ Danach verschwindet das Passwortfeld von der Anmeldeseite.
| Authentik zeigt „Redirect URI Error“ | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein — siehe [Die Redirect URI](#die-redirect-uri). Auf Schema, Port, Schrägstrich am Ende und die Auswahl Strict/Regex achten. | | Authentik zeigt „Redirect URI Error“ | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein — siehe [Die Redirect URI](#die-redirect-uri). Auf Schema, Port, Schrägstrich am Ende und die Auswahl Strict/Regex achten. |
| „Der Anbieter hat keine E-Mail-Adresse geliefert“ | Der Scope `email` fehlt im Provider, oder der Benutzer hat in Authentik keine Adresse hinterlegt. | | „Der Anbieter hat keine E-Mail-Adresse geliefert“ | Der Scope `email` fehlt im Provider, oder der Benutzer hat in Authentik keine Adresse hinterlegt. |
| „Der Anbieter hat die Anmeldung abgelehnt“ | Der Benutzer ist der Application in Authentik nicht zugeordnet — Policy/Group Binding prüfen. | | „Der Anbieter hat die Anmeldung abgelehnt“ | Der Benutzer ist der Application in Authentik nicht zugeordnet — Policy/Group Binding prüfen. |
| „Der Anmeldevorgang ist abgelaufen“ | Zwischen Klick und Rückkehr sind mehr als 10 Minuten vergangen, oder der Cookie ging verloren. Häufigste echte Ursache: `BASE_URL` zeigt auf eine andere Adresse als die, über die der Browser kommt. | | „Der Anmeldevorgang ist abgelaufen“ | Der Cookie mit dem laufenden Vorgang kam nicht zurück. Entweder sind mehr als 10 Minuten vergangen — oder, weit häufiger, `BASE_URL` zeigt auf einen anderen Host als den, über den der Browser kommt. Erkennbar daran, dass in der Adresszeile eine andere Adresse steht als beim Start. Siehe [BASE_URL und die Adresse im Browser](#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein). |
| „Du rufst die Anwendung über … auf, BASE_URL zeigt aber auf …“ | Genau der Fall von oben, nur früh erkannt. Die Meldung nennt beide Adressen; `BASE_URL` in der `.env` auf die erste setzen und `docker compose up -d` ausführen. |
| „Das Anmelde-Token des Anbieters ist ungültig“ | Uhrzeit-Abweichung zwischen den Servern oder ein gewechselter Signaturschlüssel. Beides zeigt `docker compose logs app` genauer. | | „Das Anmelde-Token des Anbieters ist ungültig“ | Uhrzeit-Abweichung zwischen den Servern oder ein gewechselter Signaturschlüssel. Beides zeigt `docker compose logs app` genauer. |
| Nach der Anmeldung fehlt die Verwaltung | `OIDC_ADMIN_GROUP` ist gesetzt, der Benutzer aber nicht in dieser Gruppe. Entweder die Gruppe in Authentik ergänzen oder die Variable leeren und das Recht hier vergeben. | | Nach der Anmeldung fehlt die Verwaltung | `OIDC_ADMIN_GROUP` ist gesetzt, der Benutzer aber nicht in dieser Gruppe. Entweder die Gruppe in Authentik ergänzen oder die Variable leeren und das Recht hier vergeben. |
+174 -9
View File
@@ -102,12 +102,95 @@ Browser landet.
| Ziel später änderbar | nein | ja, jederzeit | | Ziel später änderbar | nein | ja, jederzeit |
| Scans gezählt | nein | ja, mit Datum des letzten | | Scans gezählt | nein | ja, mit Datum des letzten |
| Braucht diesen Server | nie | dauerhaft | | Braucht diesen Server | nie | dauerhaft |
| In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler | | In „Meine Codes“ | auf Wunsch, als Schnappschuss | immer |
Der **Slug** ist acht Zeichen lang aus einem Alphabet ohne verwechselbare ### Ein statischer Code mit Namen
Zeichen (kein `0`, `O`, `1`, `l`, `I`). Er ist **je Domain** eindeutig, nicht
global — derselbe Slug darf auf zwei Domains liegen und dort auf Beide Betriebsarten haben ein Feld **Name (optional)**. Es tut zweierlei:
Verschiedenes zeigen.
- Es bestimmt den **Dateinamen beim Herunterladen** — aus „Plakat Eingang“
wird `qr-plakat-eingang.png` statt `qr-example-com-plakat.png`.
- Auf Wunsch lässt sich der Code damit **in „Meine Codes“ ablegen**, um ihn
wiederzufinden und erneut herunterzuladen.
Ein abgelegter statischer Code ist ein **Schnappschuss**, kein steuerbares
Ziel. Änderbar ist nur sein Name; Inhalt und Gestaltung bleiben, wie sie beim
Ablegen waren — denn genau das steckt im gedruckten Bild. Er hat keinen
Kurz-Link, ist unter `/r/…` nicht erreichbar und zählt keine Scans; in der
Liste steht dort „statisch“ und ein Strich.
Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten —
der vorherige Eintrag bleibt als eigener Schnappschuss bestehen.
**Dublettenprüfung.** Liegt in derselben Gruppe schon ein statischer Code mit
identischem Inhalt, hält das Ablegen an und zeigt, welcher Eintrag das ist und
wann er entstand. Der Knopf heißt dann **Trotzdem ablegen** — eine Warnung,
keine Sperre: Zwei Fassungen desselben Codes können gewollt sein.
Verglichen wird nur der Inhalt, nicht die Gestaltung. Derselbe Code in
Schwarz-Weiß und in Hausfarben gilt also als Dublette — was er streng
genommen auch ist, denn gescannt wird beides Mal dasselbe. Dynamische Codes
bleiben außen vor: Zwei Kurz-Links auf dasselbe Ziel sind der Normalfall,
sobald zwei Kampagnen getrennt gezählt werden sollen.
### Ein Logo in der Mitte
Ein Bild in die Mitte des Codes setzen, wie man es von Firmen-Codes kennt.
Zur Wahl stehen zwei Quellen:
- das **Logo dieser Installation** aus dem Erscheinungsbild — ein Haken
genügt, kein Upload je Code;
- ein **eigenes Logo** je Code, für Kundenprojekte mit fremdem Zeichen.
Die Größe ist zwischen 10 und 25 % der Kantenlänge einstellbar; voreingestellt
sind 20 %, das entspricht rund 4 % der Fläche. Hinter dem Bild liegt eine
Fläche in der Hintergrundfarbe, damit es sich absetzt.
**Die Fehlerkorrektur wird dabei auf H erzwungen** — was das Bild verdeckt,
muss sie ausgleichen. Die Auswahl steht dann fest auf H und sagt auch warum;
heimlich geschieht das nicht. Ab 22 % erscheint eine Warnung: Die
Fehlerkorrektur trägt das zwar, aber manche Scanner-Apps suchen in der Mitte
nach Ausrichtungsmustern.
Jeder erzeugte Code wird in der Prüfung wieder eingelesen — über mehrere
Größen, Prozentsätze und Inhaltslängen. Das ersetzt trotzdem nicht den einen
Scan mit dem eigenen Handy, bevor etwas in den Druck geht.
### Der Kurz-Link
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
verwechselbare Zeichen (kein `0`, `O`, `1`, `l`, `I`) — Kurz-URLs werden
gelegentlich abgetippt.
**Selbst bestimmen** lässt er sich beim Anlegen und später ändern:
| | |
| --- | --- |
| Erlaubt | Kleinbuchstaben, Ziffern, Bindestriche, `/` als Trenner |
| Beispiele | `sommeraktion`, `tisch-14`, `kunde-a/flyer` |
| Nicht erlaubt | Umlaute, Leerzeichen, Unterstriche, Bindestrich am Rand eines Abschnitts |
| Länge | bis 100 Zeichen, höchstens 3 Abschnitte à 48 Zeichen |
Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: `SommerAktion`
findet `sommeraktion`. Gespeichert wird kleingeschrieben, und zwei Kurz-Links
dürfen sich nicht nur in der Schreibweise unterscheiden.
**Beim Ändern bleibt die vorherige Adresse gültig.** Das ist der Sinn der
Sache: Auf gedruckten Codes steht die alte, und die soll weiter funktionieren.
Frühere Adressen stehen im Bearbeiten-Dialog und lassen sich einzeln
**freigeben** — für den Fall eines Tippfehlers, mit dem noch nichts gedruckt
ist. Eine freigegebene Adresse ist sofort tot und danach wieder vergebbar.
Solange sie gehalten wird, kann sie kein anderer Code übernehmen; sonst
ließen sich fremde gedruckte Codes umlenken.
**Wer darf das?** Standardmäßig nur Administratoren — ein Kurz-Link ist eine
öffentliche Adresse, und wer ihn ändert, kann Aushänge entwerten. Unter
Verwaltung → Einstellungen lässt sich das für Bearbeiter freigeben. Ohne das
Recht erscheint das Feld gar nicht; Codes anlegen und deren Ziel ändern geht
weiterhin.
Der Kurz-Link ist **je Domain** eindeutig, nicht global — derselbe darf auf
zwei Domains liegen und dort auf Verschiedenes zeigen.
Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben — permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben —
@@ -232,8 +315,9 @@ Die **Vorschau** wird serverseitig gerendert und aktualisiert sich bei jeder
## Meine Codes ## Meine Codes
Zeigt alle dynamischen Codes — statische erscheinen nicht, weil sie nicht Zeigt die dynamischen Codes und die statischen, die abgelegt wurden. Ein
gespeichert werden. abgelegter statischer Code trägt statt einer Kurz-URL das Abzeichen
„statisch“ und zählt keine Scans.
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten - **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
mit großer Vorschau. Die Wahl merkt sich der Browser. mit großer Vorschau. Die Wahl merkt sich der Browser.
@@ -241,6 +325,10 @@ gespeichert werden.
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl. - **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt - **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
es auch eine Gruppenspalte. es auch eine Gruppenspalte.
- **Filter nach Art** (*Alle Arten*, *Nur dynamische*, *Nur statische*),
sobald beide Arten vorkommen. Bei „Nur statische“ verschwindet die
Scan-Spalte und mit ihr die Sortierung danach: Statische zählen keine
Scans, die Spalte wäre eine Reihe aus Strichen.
- **Kurz-URL kopieren** mit einem Klick. - **Kurz-URL kopieren** mit einem Klick.
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig. - **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
- **Löschen** mit Rückfrage, die den betroffenen Code nennt. - **Löschen** mit Rückfrage, die den betroffenen Code nennt.
@@ -343,6 +431,53 @@ Domains werden in der Verwaltung angelegt und Gruppen zugeteilt.
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
Anwendung leiten. Anwendung leiten.
### Kurzhilfe zur Einrichtung
Was außerhalb der Anwendung einzurichten ist, steht nicht nur im README: Auf
der Domain-Seite öffnet der Knopf **Kurzhilfe** eine Erklärung in sechs
Schritten — DNS-Eintrag, Weiterleitung im Reverse Proxy samt der drei nötigen
Kopfzeilen zum Abtippen, Zertifikat, Registrieren, Gruppenzuteilung,
Nachprüfen. Der Hostname aus `BASE_URL` wird als Beispiel eingesetzt.
Bewusst ein Dialog und kein Dauertext: Die Erklärung braucht man einmal beim
Einrichten und danach nie wieder.
### Prüfen, ob eine Domain hierher führt
Bei jedem Eintrag steht 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. Dass irgendwo etwas antwortet,
sagt wenig — es könnte die Parkseite des Registrars sein oder eine andere
Installation dieser Anwendung. Deshalb ruft die Prüfung unter der Domain eine
eigene Route auf und lässt sich einen HMAC über eine frische Zufallszahl
zurückgeben. Das Geheimnis dafür entsteht beim ersten Check, liegt in der
Datenbank und verlässt den Server nie.
Die Meldungen sind im Klartext: abgelaufen, selbst ausgestellt, gilt für einen
anderen Namen, Zwischenzertifikat fehlt, Port 443 nimmt nichts an, „es
antwortet etwas, aber nicht diese Anwendung“. Läuft ein Zertifikat in
höchstens 14 Tagen ab, gilt der Schritt als Warnung statt als Erfolg.
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
Meldung würde die eine wichtige verdecken.
**Die Grenze der Aussage** steht unter jedem Bericht: Der Aufruf startet dort,
wo der Container läuft. Bei internem DNS oder Hairpin-NAT kann er scheitern,
obwohl von außen alles stimmt; umgekehrt beweist ein Erfolg von innen nicht,
dass die Domain von außen erreichbar ist.
Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer aus
der Anfrage — sonst wäre die Route ein Werkzeug, mit dem sich vom Server aus
beliebige Adressen abklopfen ließen. Näheres in
[sicherheit.md](sicherheit.md).
--- ---
## Erscheinungsbild ## Erscheinungsbild
@@ -415,8 +550,9 @@ auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
| Bereich | Was dort geht | | Bereich | Was dort geht |
| --- | --- | | --- | --- |
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe | | **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
| **QR-Typen** | einzelne Typen ausblenden | | **QR-Typen** | einzelne Typen ausblenden — für alle oder je Gruppe |
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben | | **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen | | **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
| **Domains** | anlegen, Gruppen zuteilen, abschalten | | **Domains** | anlegen, Gruppen zuteilen, abschalten |
@@ -428,6 +564,20 @@ lassen sich bearbeiten. Nur neue lassen sich nicht mehr anlegen. Ohne Eintrag
in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation
alles zeigt und Typen aus späteren Versionen automatisch erscheinen. alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
Die Freigabe hat **zwei Ebenen**. Die Grundeinstellung gilt für die ganze
Installation; je Gruppe lässt sich davon abweichen — 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 nennt den Grund. Andersherum wäre „hier
abgeschaltet“ keine verlässliche Aussage mehr.
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
in beiden Gruppen ist, sieht beides; welche Freigabe zählt, entscheidet sich
beim Anlegen anhand der gewählten Gruppe.
--- ---
## Betrieb ## Betrieb
@@ -450,6 +600,21 @@ alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
--- ---
## Version und Änderungen
Die Fußzeile nennt die laufende Version und führt auf eine Seite mit dem
vollständigen Änderungsverlauf. Nach einem Update ist das die schnellste
Antwort auf die Frage, ob der Container wirklich mit dem neuen Abbild läuft:
Steht dort noch die alte Nummer, ist er es nicht.
Der Verlauf wird an **einer** Stelle gepflegt (`src/lib/changelog.ts`).
Daraus entstehen sowohl die Seite in der Anwendung als auch die
[CHANGELOG.md](../CHANGELOG.md) im Projekt (`npm run changelog`); eine
Prüfung wacht darüber, dass beides zusammenpasst. Zu jeder Version gibt es
einen Git-Tag.
---
## Schnittstelle ## Schnittstelle
Alles, was die Oberfläche tut, geht auch über HTTP: Alles, was die Oberfläche tut, geht auch über HTTP:
@@ -466,7 +631,7 @@ Alles, was die Oberfläche tut, geht auch über HTTP:
| `POST /api/feedback` | Rückmeldung abgeben | | `POST /api/feedback` | Rückmeldung abgeben |
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern | | `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter | | `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter |
| `/api/admin/types` · `users` · `groups` · `domains` · `branding` | Verwaltung | | `/api/admin/types` · `users` · `groups` · `domains` · `branding` · `settings` | Verwaltung |
| `GET /r/{slug}` | Weiterleitung eines dynamischen Codes | | `GET /r/{slug}` | Weiterleitung eines dynamischen Codes |
| `GET /s/{slug}` | öffentliche Landingpage | | `GET /s/{slug}` | öffentliche Landingpage |
+30 -105
View File
@@ -7,77 +7,21 @@ Aufwände sind grobe Einschätzungen, keine Zusagen.
## Gebaut ## Gebaut
### Version 1 — Grundgerüst (13.08.2026) Der vollständige Verlauf mit Versionsnummern steht im
[Changelog](../CHANGELOG.md) — und in der Anwendung selbst unter
**Version und Änderungen**, erreichbar über die Versionsnummer in der
Fußzeile.
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, ohne Reverse Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
Proxy, Migrationen beim Start Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein 1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
Formular, ohne Eingriff in den übrigen Code und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes,
- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und 1.6.0 den Versionshinweis und 1.7.0 sechs Erweiterungen auf einmal — Logo im
Scan-Zähler Code, Freigabe je Gruppe, Domain-Check, Dublettenprüfung, Filter nach Art und
- Gestaltung: Farben mit Kontrastwarnung, Größe, Fehlerkorrektur, Rand die Kurzhilfe zu den Domains.
- Download als PNG und SVG, serverseitig gerendert
- „Meine Codes“ mit Ziel ändern und Löschen
- Deutsche Oberfläche, deutsche Fehlermeldungen, Validierung mit Zod
- Update-Skript für Linux/macOS und Windows
### Typen einzeln freigeben (14.08.2026) Damit sind zwei Punkte erledigt, die hier lange unter „Als Nächstes“ standen:
**QR-Typen je Gruppe freigeben** und **Logo im QR-Code**.
Ausblenden nicht gebrauchter Typen in der Verwaltung. Bereits erstellte Codes
eines abgeschalteten Typs funktionieren weiter.
### Phase A — acht weitere Typen (14.08.2026)
vCard, App-Link, WhatsApp, E-Mail, SMS, Standort, Zahlung (GiroCode und
Zahlungslink), WLAN.
### Phase B1 — Anmeldung, Gruppen, Rollen (14.08.2026)
Abschaltbare Anmeldung, Benutzerverwaltung, Gruppen mit den Rollen
Administrator, Bearbeiter und Betrachter, Sitzungen in der Datenbank,
scrypt-Passwörter, Sperre nach Fehlversuchen.
### Phase B2 — mehrere Domains (14.08.2026)
Domains für Kurz-URLs, den Gruppen zugeteilt. Slug je Domain eindeutig,
Host-Header gegen die registrierten Domains geprüft.
### Listenansicht (14.08.2026)
„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter — gedacht für
viele Projekte nebeneinander. Karten bleiben als zweite Ansicht.
### Phase C — die letzten sechs Typen (14.08.2026)
Mehrere Links, Social Media, Video, PDF, Bild, Feedback. Dazu:
- Landingpages als eigene Gattung (`/s/{slug}`), ohne App-Navigation
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen
- Feedback mit anonymer Erhebung und Auswertung
Damit sind **alle 15 vorgesehenen Typen** gebaut.
### Single Sign-on (15.08.2026)
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
ID-Token.
### Erscheinungsbild (16.08.2026)
Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar. Die Farbe
wirkt über die Farbvariablen, die Tailwind ohnehin benutzt — deshalb ohne
Umbau der rund 60 eingefärbten Stellen.
### Dark Mode (16.08.2026)
Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System.
Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne
Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl.
### Dokumentation (15.08.2026)
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
--- ---
@@ -86,27 +30,7 @@ Die Bilder erzeugt ein Skript, damit sie nicht veralten.
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
Person, die den Code kennt. Person, die den Code kennt.
### 1. QR-Typen je Gruppe freigeben · mittel ### 1. Statistik über die Zeit · mittel
Heute gilt die Freigabe eines Typs für die ganze Installation: Was
abgeschaltet ist, ist für alle weg. Bei mehreren Gruppen ist das zu grob — die
Marketingabteilung braucht Social Media, die Werkstatt nur Website und PDF.
Geplant: Die Freigabe bekommt eine zweite Dimension. Es bleibt eine globale
Einstellung als Grundlage, und je Gruppe lässt sich davon abweichen. Wer in
mehreren Gruppen ist, sieht auf der Startseite die Vereinigung; beim Anlegen
entscheidet die gewählte Gruppe.
Zu klären ist der Fall, in dem ein Typ global aus, in einer Gruppe aber an
ist — entweder die Gruppe darf das überstimmen, oder die globale Abschaltung
gewinnt immer. Die zweite Lesart ist die weniger überraschende und
wahrscheinlich die richtige.
Berührt: `QrTypeSetting` (zusammengesetzter Schlüssel aus Typ und Gruppe),
`src/lib/qr/availability.ts`, die Startseite und die Typ-Verwaltung, die dann
eine Gruppenauswahl bekommt.
### 2. Statistik über die Zeit · mittel
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
@@ -120,7 +44,7 @@ sagt nichts über einzelne Menschen aus und braucht deshalb weder Einwilligung
noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine
andere Entscheidung als „ein Diagramm wäre schön“. andere Entscheidung als „ein Diagramm wäre schön“.
### 3. Papierkorb statt Löschen · klein ### 2. Papierkorb statt Löschen · klein
Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot, Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot,
samt seiner Dateien und Rückmeldungen. samt seiner Dateien und Rückmeldungen.
@@ -130,20 +54,7 @@ Geplant: Gelöschte Codes 30 Tage aufheben und dann endgültig entfernen. In
im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code
bekommen und die gedruckten Codes zeigten plötzlich woandershin. bekommen und die gedruckten Codes zeigten plötzlich woandershin.
### 4. Logo im QR-Code · mittel ### 3. Ablaufdatum und Zeitsteuerung · mittel
Ein kleines Bild in die Mitte setzen, wie man es von Firmen-Codes kennt.
Geplant: Upload eines Logos je Code, mittig eingesetzt, mit erzwungener
Fehlerkorrektur **H** und einer Warnung, sobald zu viel Fläche verdeckt wird.
Das ist der Punkt mit dem größten Risiko, etwas Kaputtes auszuliefern: Ein
Code, der auf dem eigenen Handy noch funktioniert, kann bei anderen
Scanner-Apps durchfallen. Gehört deshalb nicht ohne eine ehrliche Prüfung mit
mehreren Geräten und Apps ausgeliefert — und mit einer klaren Obergrenze für
die verdeckte Fläche.
### 5. Ablaufdatum und Zeitsteuerung · mittel
Ein Code, der ab einem Datum woandershin führt oder abläuft — für Ein Code, der ab einem Datum woandershin führt oder abläuft — für
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum. Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
@@ -160,6 +71,20 @@ Geplant, in zwei Stufen:
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
Fenstern geschieht. Fenstern geschieht.
### 4. Gestaltung eines gespeicherten Codes ändern · klein
Aufgefallen beim Einbau des Logos: In „Meine Codes“ lässt sich die Gestaltung
eines abgelegten Codes gar nicht mehr anfassen — weder Farben noch Größe noch
Logo. Wer ein Logo nachrüsten will, kommt nur über die Schnittstelle daran.
Bei einem **dynamischen** Code ist das unnötig streng: Das gedruckte Bild
bleibt zwar, wie es ist, aber der erneute Download soll ja gerade die neue
Gestaltung tragen. Geplant: derselbe Gestaltungsbereich wie im Generator, im
Bearbeiten-Dialog.
Bei einem **statischen** Code bleibt es beim Nein — er ist ein Schnappschuss,
und eine geänderte Gestaltung ergäbe ein anderes Bild als das gedruckte.
--- ---
## Später denkbar ## Später denkbar
+23
View File
@@ -191,6 +191,29 @@ Konto nur bei **bestätigter** E-Mail-Adresse.
--- ---
## Der Domain-Check und seine Grenzen
Auf der Domain-Seite ruft der Knopf **Prüfen** die eingetragene Domain vom
Server aus auf. Drei Dinge grenzen das ein:
- Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer
aus der Anfrage. Sonst wäre die Route ein Werkzeug, mit dem sich vom Server
aus beliebige Adressen abklopfen ließen.
- Auslösen darf sie nur, wer `instance:manage` hat — dieselbe Berechtigung,
die auch Domains anlegt.
- Die Route, die den Beweis liefert (`/api/domain-check`), ist **ohne
Anmeldung** erreichbar; der Aufruf kommt ja von außen. Sie gibt nichts
preis: Die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Ohne
das Geheimnis lässt er sich nicht bilden, und aus ihm folgt nichts über das
Geheimnis. Es entsteht beim ersten Check und verlässt den Server nie.
Beim Zertifikat läuft die Verbindung bewusst mit `rejectUnauthorized: false`.
Nur so lässt sich ein schlechtes Zertifikat **beschreiben** — abgelaufen,
selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
Übertragen wird über diese Verbindung nichts.
---
## Offen ## Offen
Bewusst nicht umgesetzt, mit Begründung. Bewusst nicht umgesetzt, mit Begründung.
+69 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "qrcode-generator", "name": "qrcode-generator",
"version": "1.1.0", "version": "1.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "qrcode-generator", "name": "qrcode-generator",
"version": "1.1.0", "version": "1.6.0",
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.9.1", "@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
@@ -17,6 +17,7 @@
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.35.3",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -566,7 +567,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2065,6 +2065,72 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -4039,7 +4105,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -7795,7 +7860,6 @@
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.1.0", "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
@@ -7845,7 +7909,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC", "license": "ISC",
"optional": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
+3 -1
View File
@@ -1,11 +1,12 @@
{ {
"name": "qrcode-generator", "name": "qrcode-generator",
"version": "1.1.0", "version": "1.7.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"changelog": "node scripts/changelog.mjs",
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"db:generate": "prisma generate", "db:generate": "prisma generate",
@@ -23,6 +24,7 @@
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.35.3",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,56 @@
-- Selbst gewählte Kurz-Links, umbenennbar, mit weiterhin gültigen Vorgängern.
-- Frühere Kurz-Links eines Codes. Ohne sie würde jedes Umbenennen die bereits
-- gedruckten Codes entwerten.
CREATE TABLE "QrCodeAlias" (
"id" TEXT NOT NULL,
"domainId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"qrCodeId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "QrCodeAlias_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "QrCodeAlias_domainId_slug_key" ON "QrCodeAlias"("domainId", "slug");
CREATE INDEX "QrCodeAlias_qrCodeId_idx" ON "QrCodeAlias"("qrCodeId");
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_domainId_fkey"
FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_qrCodeId_fkey"
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Gross- und Kleinschreibung soll beim Aufrufen keine Rolle spielen. Damit
-- „Sommer" und „sommer" nicht als zwei verschiedene Kurz-Links vergeben werden
-- koennen, gilt die Eindeutigkeit ab jetzt auf der kleingeschriebenen Form.
--
-- Zuerst pruefen, ob der bestehende Bestand das ueberhaupt zulaesst. Bei
-- erzeugten Kurz-Links aus acht Zeichen ist eine solche Kollision praktisch
-- ausgeschlossen; ein stiller Datenverlust waere aber unentschuldbar, deshalb
-- bricht die Migration in dem Fall lieber ab.
DO $$
DECLARE kollisionen INT;
BEGIN
SELECT COUNT(*) INTO kollisionen FROM (
SELECT "domainId", LOWER("slug")
FROM "QrCode"
GROUP BY "domainId", LOWER("slug")
HAVING COUNT(*) > 1
) AS doppelt;
IF kollisionen > 0 THEN
RAISE EXCEPTION
'Es gibt % Kurz-Links, die sich nur in der Gross-/Kleinschreibung unterscheiden. Bitte einen davon umbenennen und die Migration erneut starten.',
kollisionen;
END IF;
END $$;
-- Der bestehende Index bleibt: Er traegt die exakten Nachschlagevorgaenge, und
-- das ist der haeufige Fall — ein gescannter Code liefert die Adresse genau so,
-- wie sie gespeichert ist. Der zweite Index kommt dazu und sorgt dafuer, dass
-- sich zwei Kurz-Links nicht nur in der Gross-/Kleinschreibung unterscheiden
-- koennen.
CREATE UNIQUE INDEX "QrCode_domainId_lower_slug_key"
ON "QrCode"("domainId", LOWER("slug"));
CREATE UNIQUE INDEX "QrCodeAlias_domainId_lower_slug_key"
ON "QrCodeAlias"("domainId", LOWER("slug"));
@@ -0,0 +1,12 @@
-- Verhalten der Installation, das sich nicht aus dem Code ergibt.
CREATE TABLE "InstanceSetting" (
"id" TEXT NOT NULL DEFAULT 'instance',
"editorsMaySetSlug" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InstanceSetting_pkey" PRIMARY KEY ("id")
);
-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur lesen muss.
INSERT INTO "InstanceSetting" ("id", "updatedAt") VALUES ('instance', CURRENT_TIMESTAMP);
@@ -0,0 +1,12 @@
-- Statische Codes lassen sich mit Namen ablegen, damit man sie wiederfindet
-- und erneut herunterladen kann.
-- Alles, was es bisher gibt, ist dynamisch.
ALTER TABLE "QrCode" ADD COLUMN "mode" TEXT NOT NULL DEFAULT 'dynamic';
CREATE INDEX "QrCode_mode_idx" ON "QrCode"("mode");
-- Ein statischer Code hat keinen Kurz-Link: Er traegt seinen Inhalt selbst und
-- fuehrt auf nichts, was dieser Server ausliefert. In PostgreSQL schliessen
-- sich mehrere NULL in einem eindeutigen Index nicht aus — die Bedingung
-- „je Domain eindeutig" bleibt fuer dynamische Codes also unveraendert.
ALTER TABLE "QrCode" ALTER COLUMN "slug" DROP NOT NULL;
@@ -0,0 +1,7 @@
-- Der Domain-Check ruft die Domain von aussen auf und prueft, ob er dabei bei
-- dieser Installation landet. Beweis dafuer ist ein HMAC ueber eine
-- Zufallszahl, gebildet mit einem Geheimnis, das den Server nie verlaesst.
--
-- Nullable und ohne Vorbelegung: Der Wert entsteht beim ersten Check. Eine
-- feste Vorbelegung waere fuer alle Installationen dieselbe und damit wertlos.
ALTER TABLE "InstanceSetting" ADD COLUMN "checkSecret" TEXT;
@@ -0,0 +1,11 @@
-- Ein Logo in der Mitte des Codes, wie man es von Firmen-Codes kennt.
--
-- Drei Angaben gehoeren zur Gestaltung: woher das Bild kommt, welche Datei es
-- ist und wie gross es sein soll. Ohne Herkunft gibt es kein Logo — deshalb
-- sind die ersten beiden Spalten leer erlaubt und ohne Vorbelegung.
ALTER TABLE "QrCode" ADD COLUMN "logoSource" TEXT;
ALTER TABLE "QrCode" ADD COLUMN "logoUploadId" TEXT;
-- Die Groesse hat eine Vorbelegung, damit bestehende Codes einen gueltigen
-- Wert tragen. Ohne Logo bleibt er wirkungslos.
ALTER TABLE "QrCode" ADD COLUMN "logoScale" INTEGER NOT NULL DEFAULT 20;
@@ -0,0 +1,25 @@
-- QR-Typen lassen sich je Gruppe abschalten.
--
-- Die globale Freigabe in "QrTypeSetting" bleibt die Grundlage. Diese Tabelle
-- traegt nur Abweichungen davon: Fehlt eine Zeile, folgt die Gruppe der
-- globalen Einstellung. Eine globale Abschaltung laesst sich hierueber nicht
-- aufheben — global aus gewinnt immer.
--
-- Eigene Tabelle statt eines zusammengesetzten Schluessels in
-- "QrTypeSetting": Der globale Fall braeuchte dort eine leere Gruppe, und
-- PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
-- nebeneinander zu.
CREATE TABLE "QrTypeGroupSetting" (
"type" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QrTypeGroupSetting_pkey" PRIMARY KEY ("type","groupId")
);
CREATE INDEX "QrTypeGroupSetting_groupId_idx" ON "QrTypeGroupSetting"("groupId");
-- Mit der Gruppe verschwindet auch ihre Abweichung.
ALTER TABLE "QrTypeGroupSetting" ADD CONSTRAINT "QrTypeGroupSetting_groupId_fkey"
FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+93 -3
View File
@@ -87,6 +87,7 @@ model Group {
memberships Membership[] memberships Membership[]
codes QrCode[] codes QrCode[]
domainGrants DomainGrant[] domainGrants DomainGrant[]
typeSettings QrTypeGroupSetting[]
} }
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer /// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
@@ -122,6 +123,7 @@ model Domain {
grants DomainGrant[] grants DomainGrant[]
codes QrCode[] codes QrCode[]
aliases QrCodeAlias[]
} }
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen /// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
@@ -154,6 +156,31 @@ model QrTypeSetting {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
/// Abweichung von der globalen Freigabe fuer eine einzelne Gruppe.
///
/// Bewusst eine eigene Tabelle statt eines zusammengesetzten Schluessels in
/// `QrTypeSetting`: Der globale Fall braeuchte dort eine leere Gruppe, und
/// PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
/// nebeneinander zu — die Eindeutigkeit der globalen Zeile waere also gerade
/// nicht gesichert.
///
/// Fehlt eine Zeile, folgt die Gruppe der globalen Einstellung. Eine Zeile
/// mit `enabled = false` schaltet den Typ **nur** in dieser Gruppe ab. Eine
/// Zeile mit `enabled = true` kann eine globale Abschaltung **nicht**
/// aufheben — global aus gewinnt immer.
model QrTypeGroupSetting {
/// Kennung aus der Typ-Registry, z. B. "website".
type String
groupId String
enabled Boolean
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@id([type, groupId])
@@index([groupId])
}
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und /// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird. /// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
/// ///
@@ -161,10 +188,18 @@ model QrTypeSetting {
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung. /// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
model QrCode { model QrCode {
id String @id @default(cuid()) id String @id @default(cuid())
/// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei /// Statisch oder dynamisch. Ein statischer Code traegt seinen Inhalt selbst
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt /// und wird nur abgelegt, damit man ihn wiederfindet und erneut
/// herunterladen kann — er hat weder Kurz-Link noch Scan-Zaehler.
mode String @default("dynamic")
/// Kurz-Link. Nur je Domain eindeutig — derselbe sprechende Slug darf auf
/// zwei Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
/// diese Eindeutigkeit über Gruppengrenzen hinweg. /// diese Eindeutigkeit über Gruppengrenzen hinweg.
slug String ///
/// Leer bei statischen Codes: Die fuehren auf nichts, was dieser Server
/// ausliefert. In PostgreSQL schliessen sich mehrere NULL in einem
/// eindeutigen Index nicht aus, die Bedingung bleibt also gueltig.
slug String?
type String type String
label String? label String?
data Json data Json
@@ -173,6 +208,14 @@ model QrCode {
size Int @default(512) size Int @default(512)
ecLevel String @default("M") ecLevel String @default("M")
margin Int @default(4) margin Int @default(4)
/// Logo in der Mitte: "branding" nimmt das Logo der Installation,
/// "upload" eine eigene Datei. Leer heisst: kein Logo.
logoSource String?
/// Die eigene Datei, wenn `logoSource` = "upload". Sie haengt ueber
/// `Upload.qrCodeId` am Code und verschwindet mit ihm.
logoUploadId String?
/// Kantenlaenge des Logos in Prozent der Kantenlaenge des Codes.
logoScale Int @default(20)
scans Int @default(0) scans Int @default(0)
lastScanAt DateTime? lastScanAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -194,11 +237,14 @@ model QrCode {
uploads Upload[] uploads Upload[]
/// Rückmeldungen, die über die Landingpage eingegangen sind. /// Rückmeldungen, die über die Landingpage eingegangen sind.
feedback Feedback[] feedback Feedback[]
/// Frühere Kurz-Links, die weiterhin hierher führen.
aliases QrCodeAlias[]
@@unique([domainId, slug]) @@unique([domainId, slug])
@@index([createdAt]) @@index([createdAt])
@@index([groupId]) @@index([groupId])
@@index([domainId]) @@index([domainId])
@@index([mode])
} }
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter /// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
@@ -237,6 +283,50 @@ model Upload {
@@index([purpose]) @@index([purpose])
} }
/// Verhalten der Installation, das sich nicht aus dem Code ergibt.
///
/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt
/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder
/// ihren Typ behalten.
model InstanceSetting {
id String @id @default("instance")
/// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern?
///
/// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert,
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
editorsMaySetSlug Boolean @default(false)
/// Geheimnis fuer den Domain-Check.
///
/// Die Pruefung ruft die Domain von aussen auf und laesst sich einen HMAC
/// ueber eine Zufallszahl zurueckgeben. Nur wer dieses Geheimnis kennt,
/// kann ihn bilden — so steht fest, dass wirklich diese Installation
/// geantwortet hat und nicht irgendein anderer Server. Es verlaesst den
/// Server nie und entsteht beim ersten Check.
checkSecret String?
updatedAt DateTime @updatedAt
}
/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt.
///
/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten —
/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes.
/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem
/// Tippfehler, den noch niemand gedruckt hat.
model QrCodeAlias {
id String @id @default(cuid())
/// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst.
domainId String
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
slug String
qrCodeId String
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([domainId, slug])
@@index([qrCodeId])
}
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe. /// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
/// ///
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner /// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
+84
View File
@@ -0,0 +1,84 @@
/**
* Schreibt CHANGELOG.md aus src/lib/changelog.ts.
*
* node scripts/changelog.mjs erzeugen
* node scripts/changelog.mjs --check nur prüfen, ob die Datei aktuell ist
*
* Warum erzeugt und nicht von Hand gepflegt: Die Anwendung zeigt denselben
* Verlauf unter /version an. Zwei gepflegte Listen liefen unweigerlich
* auseinander — und ein Changelog, der nicht stimmt, ist schlimmer als keiner.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const wurzel = join(dirname(fileURLToPath(import.meta.url)), "..");
const ziel = join(wurzel, "CHANGELOG.md");
/**
* Die Quelle ist TypeScript, dieses Skript aber schlichtes Node. Statt einen
* Übersetzer dazuzunehmen, wird der Datenteil gelesen und ausgewertet — er
* besteht ausschließlich aus Zeichenketten und Listen.
*/
function ladeVerlauf() {
const quelle = readFileSync(join(wurzel, "src", "lib", "changelog.ts"), "utf8");
const anfang = quelle.indexOf("export const CHANGELOG");
if (anfang === -1) throw new Error("CHANGELOG-Liste nicht gefunden.");
const start = quelle.indexOf("[", anfang);
const ende = quelle.indexOf("\n];", start);
if (start === -1 || ende === -1) throw new Error("Liste nicht abgrenzbar.");
const literal = quelle.slice(start, ende + 2);
return Function(`"use strict"; return (${literal});`)();
}
function formatiereDatum(iso) {
const [jahr, monat, tag] = iso.split("-");
return `${tag}.${monat}.${jahr}`;
}
function baue(verlauf) {
const zeilen = [
"# Änderungen",
"",
"Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der",
"Anwendung unter **Version und Änderungen**, erreichbar über die",
"Versionsnummer in der Fußzeile.",
"",
"> Diese Datei wird erzeugt: `npm run changelog`.",
"> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.",
"",
];
for (const eintrag of verlauf) {
zeilen.push(`## ${eintrag.version} — ${formatiereDatum(eintrag.date)}`, "");
zeilen.push(eintrag.summary, "");
for (const änderung of eintrag.changes) zeilen.push(`- ${änderung}`);
zeilen.push("");
}
return zeilen.join("\n");
}
const inhalt = baue(ladeVerlauf());
if (process.argv.includes("--check")) {
let vorhanden = "";
try {
vorhanden = readFileSync(ziel, "utf8");
} catch {
console.error("CHANGELOG.md fehlt. Erzeugen mit: npm run changelog");
process.exit(1);
}
if (vorhanden !== inhalt) {
console.error(
"CHANGELOG.md passt nicht mehr zu src/lib/changelog.ts. Neu erzeugen mit: npm run changelog",
);
process.exit(1);
}
console.log("CHANGELOG.md ist aktuell.");
} else {
writeFileSync(ziel, inhalt, "utf8");
console.log("CHANGELOG.md geschrieben.");
}
+10 -6
View File
@@ -94,12 +94,16 @@ export default async function SignInPage({ searchParams }: PageProps) {
Gruppen, Benutzer und die QR-Typen verwalten. Gruppen, Benutzer und die QR-Typen verwalten.
</Notice> </Notice>
) : oidc?.nurOidc ? ( ) : oidc?.nurOidc ? (
<p className="text-center text-sm text-slate-500"> /* Bewusst kein Hinweis darauf, dass die Passwort-Anmeldung
Die Anmeldung mit Passwort ist abgeschaltet (<code>OIDC_ONLY=true</code>). abgeschaltet ist. Diese Seite sieht jeder, der die Adresse kennt —
{setup wie die Anmeldung eingerichtet ist, geht Fremde nichts an. Wer
? " Wer sich zuerst anmeldet, wird Administrator dieser Installation." hierher gehört, sieht den einen Knopf und weiß, was zu tun ist.
: ""} Beim allerersten Aufruf ist der Hinweis dagegen nützlich. */
</p> setup ? (
<p className="text-center text-sm text-slate-500">
Wer sich zuerst anmeldet, wird Administrator dieser Installation.
</p>
) : null
) : ( ) : (
<p className="text-center text-sm text-slate-500"> <p className="text-center text-sm text-slate-500">
Passwort vergessen? Ein Administrator kann es unter Verwaltung → Passwort vergessen? Ein Administrator kann es unter Verwaltung →
+36 -4
View File
@@ -4,6 +4,10 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator"; import { QrGenerator } from "@/components/qr-generator";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { getBranding } from "@/lib/branding";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard"; import { requirePageActor } from "@/lib/auth/guard";
import { getQrTypeState } from "@/lib/qr/availability"; import { getQrTypeState } from "@/lib/qr/availability";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
@@ -47,9 +51,14 @@ function Hinweis({
} }
export default async function CreatePage({ params }: Props) { export default async function CreatePage({ params }: Props) {
await requirePageActor(); const actor = await requirePageActor();
const { type } = await params; const { type } = await params;
const state = await getQrTypeState(type);
// Welche Gruppe gilt, entscheidet mit darueber, ob der Typ hier offensteht.
const zielgruppe = await resolveTargetGroup(actor, undefined).catch(
() => undefined,
);
const state = await getQrTypeState(type, zielgruppe);
if (!state) { if (!state) {
notFound(); notFound();
@@ -60,7 +69,11 @@ export default async function CreatePage({ params }: Props) {
<Hinweis <Hinweis
icon="lock" icon="lock"
titel={`„${state.qrType.title}“ ist abgeschaltet`} titel={`„${state.qrType.title}“ ist abgeschaltet`}
text="Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben." text={
state.enabledGlobally
? "Dieser QR-Typ ist für deine Gruppe abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen für die Gruppe wieder freigeben."
: "Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben."
}
/> />
); );
} }
@@ -75,5 +88,24 @@ export default async function CreatePage({ params }: Props) {
); );
} }
return <QrGenerator typeId={state.qrType.id} />; // Ob der Kurz-Link selbst vergeben werden darf, haengt an Rolle und
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
// noch einmal.
const { editorsMaySetSlug } = await getSettings();
const maySetSlug = can(actor, "codes:slug", {
groupId: zielgruppe,
editorsMaySetSlug,
});
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
// der eigene Upload.
const { logoUploadId } = await getBranding();
return (
<QrGenerator
typeId={state.qrType.id}
maySetSlug={maySetSlug}
hasBrandingLogo={logoUploadId !== null}
/>
);
} }
+15 -3
View File
@@ -6,6 +6,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor"; import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
import { can } from "@/lib/auth/policy"; import { can } from "@/lib/auth/policy";
import { getBranding } from "@/lib/branding"; import { getBranding } from "@/lib/branding";
import { APP_VERSION } from "@/lib/changelog";
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert // Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
// werden. // werden.
@@ -128,9 +129,20 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-surface"> <footer className="border-t border-slate-200 bg-surface">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6"> <div className="mx-auto flex max-w-6xl flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-4 py-6 text-sm text-slate-500 sm:px-6">
{branding.appName} <span>
{branding.footerText ? ` · ${branding.footerText}` : null} {branding.appName}
{branding.footerText ? ` · ${branding.footerText}` : null}
</span>
{/* Welche Fassung hier läuft — nach einem Update die schnellste
Antwort auf die Frage, ob das Update auch angekommen ist. */}
<Link
href="/version"
title="Version und Änderungen"
className="rounded font-mono text-xs tabular-nums transition-colors hover:text-slate-900"
>
v{APP_VERSION}
</Link>
</div> </div>
</footer> </footer>
</div> </div>
+13 -5
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list"; import { CodesList } from "@/components/codes-list";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard"; import { requirePageActor } from "@/lib/auth/guard";
export const metadata: Metadata = { title: "Meine Codes" }; export const metadata: Metadata = { title: "Meine Codes" };
@@ -8,10 +10,16 @@ export const metadata: Metadata = { title: "Meine Codes" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function MyCodesPage() { export default async function MyCodesPage() {
await requirePageActor(); const actor = await requirePageActor();
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte // Breiter als die übrigen Seiten: Die Liste soll viele Projekte
// nebeneinander zeigen können. // nebeneinander zeigen können.
// Der Kurz-Link darf je Gruppe verschieden bearbeitbar sein; hier zaehlt,
// ob es irgendwo erlaubt ist. Die Route prueft es beim Speichern erneut,
// dann mit der Gruppe des betroffenen Codes.
const { editorsMaySetSlug } = await getSettings();
const maySetSlug = can(actor, "codes:slug", { editorsMaySetSlug });
return ( return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7"> <div className="mb-7">
@@ -19,13 +27,13 @@ export default async function MyCodesPage() {
Meine Codes Meine Codes
</h1> </h1>
<p className="mt-2 text-slate-600"> <p className="mt-2 text-slate-600">
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans,
Scans. Statische Codes erscheinen hier nicht — sie werden nicht dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“
gespeichert. lässt sich die Liste auf eine der beiden Arten einschränken.
</p> </p>
</div> </div>
<CodesList /> <CodesList maySetSlug={maySetSlug} />
</div> </div>
); );
} }
+4 -3
View File
@@ -3,15 +3,16 @@ import Link from "next/link";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { Badge, Notice } from "@/components/ui"; import { Badge, Notice } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard"; import { requirePageActor } from "@/lib/auth/guard";
import { getVisibleQrTypes } from "@/lib/qr/availability"; import { getVisibleQrTypesForActor } from "@/lib/qr/availability";
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit // Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
// änderbar — die Startseite darf deshalb nicht vorgerendert werden. // änderbar — die Startseite darf deshalb nicht vorgerendert werden.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function HomePage() { export default async function HomePage() {
await requirePageActor(); const actor = await requirePageActor();
const types = await getVisibleQrTypes(); // Wer in mehreren Gruppen ist, sieht die Vereinigung ihrer Freigaben.
const types = await getVisibleQrTypesForActor(actor);
const usableCount = types.filter((entry) => entry.usable).length; const usableCount = types.filter((entry) => entry.usable).length;
return ( return (
+83
View File
@@ -0,0 +1,83 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge, Card } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard";
import {
APP_VERSION,
CHANGELOG,
formatChangelogDate,
} from "@/lib/changelog";
export const metadata: Metadata = { title: "Version und Änderungen" };
export const dynamic = "force-dynamic";
export default async function VersionPage() {
await requirePageActor();
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Typ-Auswahl
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Version und Änderungen
</h1>
<p className="mt-2 text-slate-600">
Hier läuft Version{" "}
<span className="font-semibold text-slate-900">{APP_VERSION}</span>.
Darunter steht, was in welcher Fassung dazugekommen ist.
</p>
<p className="mt-2 text-sm text-slate-500">
Nach einem Update lohnt ein Blick hierher: Steht oben noch die alte
Nummer, läuft der Container noch mit dem alten Abbild.
</p>
</div>
<ol className="space-y-4">
{CHANGELOG.map((eintrag) => {
const laeuft = eintrag.version === APP_VERSION;
return (
<li key={eintrag.version}>
<Card>
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 className="text-lg font-semibold text-slate-900">
{eintrag.version}
</h2>
<span className="text-sm text-slate-500 tabular-nums">
{formatChangelogDate(eintrag.date)}
</span>
{laeuft ? (
<Badge tone="accent">läuft hier</Badge>
) : null}
</div>
<p className="mb-3 text-slate-600">{eintrag.summary}</p>
<ul className="space-y-1.5 text-sm text-slate-700">
{eintrag.changes.map((zeile) => (
<li key={zeile} className="flex gap-2.5">
<span
aria-hidden="true"
className="mt-2 size-1.5 shrink-0 rounded-full bg-slate-300"
/>
<span>{zeile}</span>
</li>
))}
</ul>
</Card>
</li>
);
})}
</ol>
</div>
);
}
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { SettingsAdmin } from "@/components/settings-admin";
import { requirePagePermission } from "@/lib/auth/guard";
import { getSettings } from "@/lib/settings";
export const metadata: Metadata = { title: "Einstellungen" };
export const dynamic = "force-dynamic";
export default async function SettingsPage() {
await requirePagePermission("instance:manage");
const settings = await getSettings();
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/verwaltung"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Verwaltung
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Einstellungen
</h1>
<p className="mt-2 text-slate-600">
Was Bearbeiter dürfen und was Administratoren vorbehalten bleibt.
</p>
</div>
<SettingsAdmin initial={{ editorsMaySetSlug: settings.editorsMaySetSlug }} />
</div>
);
}
+14 -1
View File
@@ -6,6 +6,7 @@ import { Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor"; import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePagePermission } from "@/lib/auth/guard"; import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding"; import { getBranding } from "@/lib/branding";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Verwaltung" }; export const metadata: Metadata = { title: "Verwaltung" };
@@ -46,13 +47,14 @@ function Tile({
export default async function AdminPage() { export default async function AdminPage() {
await requirePagePermission("instance:manage"); await requirePagePermission("instance:manage");
const [userCount, groupCount, codeCount, domainCount, branding] = const [userCount, groupCount, codeCount, domainCount, branding, settings] =
await Promise.all([ await Promise.all([
prisma.user.count(), prisma.user.count(),
prisma.group.count(), prisma.group.count(),
prisma.qrCode.count(), prisma.qrCode.count(),
prisma.domain.count(), prisma.domain.count(),
getBranding(), getBranding(),
getSettings(),
]); ]);
const eigenes = [ const eigenes = [
@@ -120,6 +122,17 @@ export default async function AdminPage() {
description="Festlegen, welche Typen auf der Startseite erscheinen." description="Festlegen, welche Typen auf der Startseite erscheinen."
hint="15 Typen insgesamt" hint="15 Typen insgesamt"
/> />
<Tile
href="/verwaltung/einstellungen"
icon="lock"
title="Einstellungen"
description="Was Bearbeiter dürfen — etwa eigene Kurz-Links vergeben."
hint={
settings.editorsMaySetSlug
? "Kurz-Links: auch Bearbeiter"
: "Kurz-Links: nur Administratoren"
}
/>
<Tile <Tile
href="/verwaltung/erscheinungsbild" href="/verwaltung/erscheinungsbild"
icon="image" icon="image"
+15 -2
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { TypeSettings, type TypeSetting } from "@/components/type-settings"; import { TypeSettings, type TypeSetting } from "@/components/type-settings";
import { requirePagePermission } from "@/lib/auth/guard"; import { requirePagePermission } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { getQrTypeAvailability } from "@/lib/qr/availability"; import { getQrTypeAvailability } from "@/lib/qr/availability";
export const metadata: Metadata = { title: "QR-Typen" }; export const metadata: Metadata = { title: "QR-Typen" };
@@ -15,16 +16,23 @@ export default async function TypeSettingsPage() {
const availability = await getQrTypeAvailability(); const availability = await getQrTypeAvailability();
const initial: TypeSetting[] = availability.map( const initial: TypeSetting[] = availability.map(
({ qrType, implemented, enabled }) => ({ ({ qrType, implemented, enabled, enabledGlobally, disabledInGroup }) => ({
id: qrType.id, id: qrType.id,
title: qrType.title, title: qrType.title,
description: qrType.description, description: qrType.description,
icon: qrType.icon, icon: qrType.icon,
implemented, implemented,
enabled, enabled,
enabledGlobally,
disabledInGroup,
}), }),
); );
const groups = await prisma.group.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return ( return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link <Link
@@ -44,6 +52,11 @@ export default async function TypeSettingsPage() {
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
anlegen. anlegen.
</p> </p>
<p className="mt-2 text-slate-600">
Die Grundeinstellung gilt für die ganze Installation. Je Gruppe lässt
sich davon abweichen — was hier abgeschaltet ist, bleibt aber überall
abgeschaltet.
</p>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-slate-500">
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
@@ -51,7 +64,7 @@ export default async function TypeSettingsPage() {
</p> </p>
</div> </div>
<TypeSettings initial={initial} /> <TypeSettings initial={initial} groups={groups} />
</div> </div>
); );
} }
@@ -0,0 +1,43 @@
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { pruefeDomain } from "@/lib/domain-check";
import { errorResponse } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
/**
* Prüft eine registrierte Domain: DNS, Zertifikat, Erreichbarkeit — und ob
* dahinter wirklich diese Installation steht.
*
* `POST`, obwohl nichts gespeichert wird: Der Aufruf stößt eine Verbindung
* nach außen an und ist nichts, was sich vorsorglich abrufen lässt.
*
* Geprüft wird ausschließlich der Hostname aus der Datenbank. Ein Hostname
* aus der Anfrage würde diese Route zum Werkzeug machen, mit dem sich vom
* Server aus beliebige Adressen abklopfen lassen.
*/
export async function POST(
_request: Request,
context: Context,
): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const { id } = await context.params;
const domain = await prisma.domain.findUnique({
where: { id },
select: { hostname: true },
});
if (!domain) {
throw new QrRequestError("Diese Domain gibt es nicht.", 404);
}
return Response.json({ report: await pruefeDomain(domain.hostname) });
} catch (error) {
return errorResponse(error);
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { getSettings, saveSettings } from "@/lib/settings";
export const dynamic = "force-dynamic";
const updateSchema = z.object({
editorsMaySetSlug: z.boolean().optional(),
});
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
return Response.json({ settings: await getSettings() });
} catch (error) {
return errorResponse(error);
}
}
export async function PATCH(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
if (Object.keys(parsed.data).length === 0) {
return jsonError("Es wurden keine Änderungen übermittelt.");
}
return Response.json({ settings: await saveSettings(parsed.data) });
} catch (error) {
return errorResponse(error);
}
}
+47 -14
View File
@@ -4,9 +4,11 @@ import { z } from "zod";
import { getActor } from "@/lib/auth/actor"; import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy"; import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { prisma } from "@/lib/db";
import { import {
getQrTypeAvailability, getQrTypeAvailability,
setQrTypeEnabled, setQrTypeEnabled,
setQrTypeEnabledForGroup,
} from "@/lib/qr/availability"; } from "@/lib/qr/availability";
import { QR_TYPE_IDS } from "@/lib/qr/type-module"; import { QR_TYPE_IDS } from "@/lib/qr/type-module";
@@ -15,23 +17,45 @@ export const dynamic = "force-dynamic";
const updateSchema = z.object({ const updateSchema = z.object({
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }), type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }), enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }),
/**
* Für welche Gruppe der Schalter gilt. Ohne Angabe die Grundeinstellung
* für die ganze Installation.
*/
groupId: z.string().trim().min(1).optional(),
}); });
/** Zustand aller QR-Typen für die Verwaltung. */ /**
export async function GET(): Promise<Response> { * Zustand aller QR-Typen für die Verwaltung.
*
* Mit `?group=<id>` die Sicht einer einzelnen Gruppe, sonst die
* Grundeinstellung.
*/
export async function GET(request: NextRequest): Promise<Response> {
try { try {
requirePermission(await getActor(), "instance:manage"); requirePermission(await getActor(), "instance:manage");
const types = await getQrTypeAvailability();
const groupId = request.nextUrl.searchParams.get("group") ?? undefined;
if (groupId !== undefined) {
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
}
const types = await getQrTypeAvailability(groupId);
return Response.json({ return Response.json({
types: types.map(({ qrType, implemented, enabled, usable }) => ({ groupId: groupId ?? null,
id: qrType.id, types: types.map(
title: qrType.title, ({ qrType, implemented, enabled, enabledGlobally, disabledInGroup, usable }) => ({
description: qrType.description, id: qrType.id,
icon: qrType.icon, title: qrType.title,
implemented, description: qrType.description,
enabled, icon: qrType.icon,
usable, implemented,
})), enabled,
enabledGlobally,
disabledInGroup,
usable,
}),
),
}); });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
@@ -48,8 +72,17 @@ export async function PATCH(request: NextRequest): Promise<Response> {
return jsonError(firstZodMessage(parsed.error)); return jsonError(firstZodMessage(parsed.error));
} }
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled); const { type, enabled, groupId } = parsed.data;
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
if (groupId === undefined) {
await setQrTypeEnabled(type, enabled);
} else {
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
await setQrTypeEnabledForGroup(type, groupId, enabled);
}
return Response.json({ type, enabled, groupId: groupId ?? null });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
} }
+7 -1
View File
@@ -1,8 +1,10 @@
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { isAuthEnabled } from "@/lib/auth/actor"; import { isAuthEnabled } from "@/lib/auth/actor";
import { import {
assertHostMatchesBaseUrl,
discover, discover,
getOidcConfig, getOidcConfig,
isOidcEnabled, isOidcEnabled,
@@ -26,7 +28,7 @@ function base64url(buffer: Buffer): string {
* zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann, * zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann,
* wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll. * wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll.
*/ */
export async function GET(): Promise<Response> { export async function GET(request: NextRequest): Promise<Response> {
try { try {
if (!isAuthEnabled()) { if (!isAuthEnabled()) {
throw new QrRequestError( throw new QrRequestError(
@@ -41,6 +43,10 @@ export async function GET(): Promise<Response> {
); );
} }
// Vor allem anderen: Kommt der Browser über die Adresse, auf die
// BASE_URL zeigt? Sonst geht der Vorgang auf dem Rückweg verloren.
assertHostMatchesBaseUrl(request.headers);
const config = getOidcConfig(); const config = getOidcConfig();
const meta = await discover(config.issuer); const meta = await discover(config.issuer);
+14 -3
View File
@@ -4,9 +4,11 @@ import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy"; import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { shortUrl } from "@/lib/env"; import { shortUrl } from "@/lib/env";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { errorResponse } from "@/lib/http"; import { errorResponse } from "@/lib/http";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import { ladeLogo } from "@/lib/qr/logo";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render"; import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import { downloadFilename, QrRequestError } from "@/lib/qr/request"; import { downloadFilename, QrRequestError } from "@/lib/qr/request";
@@ -47,13 +49,22 @@ export async function GET(
size: code.size, size: code.size,
ecLevel: code.ecLevel, ecLevel: code.ecLevel,
margin: code.margin, margin: code.margin,
logoSource: code.logoSource,
logoUploadId: code.logoUploadId,
logoScale: code.logoScale,
}); });
const logo = await ladeLogo(design);
const content = shortUrl(code.slug, code.domain.hostname); // Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
const content =
code.mode === "static" || !code.slug
? requireImplementedQrType(code.type).encode(code.data)
: shortUrl(code.slug, code.domain.hostname);
const body = const body =
format === "png" format === "png"
? new Uint8Array(await renderPng(content, design)) ? new Uint8Array(await renderPng(content, design, logo))
: await renderSvg(content, design); : await renderSvg(content, design, logo);
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": CONTENT_TYPES[format], "Content-Type": CONTENT_TYPES[format],
+133 -7
View File
@@ -10,7 +10,10 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireImplementedQrType } from "@/lib/qr/availability"; import { requireImplementedQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request"; import { assertDynamicPossible } from "@/lib/qr/request";
import { attachUploads } from "@/lib/uploads"; import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
import { assertSlugAvailable } from "@/lib/slug-resolve";
import { getSettings } from "@/lib/settings";
import { attachUpload, attachUploads, detachUpload } from "@/lib/uploads";
import { QrRequestError } from "@/lib/qr/type-module"; import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,6 +24,7 @@ const WITH_RELATIONS = {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } }, createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
} as const; } as const;
const updateSchema = z.object({ const updateSchema = z.object({
@@ -32,6 +36,17 @@ const updateSchema = z.object({
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.nullable() .nullable()
.optional(), .optional(),
/** Neuer Kurz-Link. Der bisherige bleibt als frühere Adresse gültig. */
slug: z.string().trim().max(120).optional(),
/**
* Welche früheren Adressen weiterhin gelten sollen.
*
* Bewusst als vollständige Liste und nicht als „lösche diese eine": So
* beschreibt die Anfrage den gewünschten Zustand, und die Oberfläche muss
* sich nicht merken, was sie schon geschickt hat. Was fehlt, wird
* freigegeben — und ist damit sofort tot.
*/
aliases: z.array(z.string().trim().max(120)).max(50).optional(),
}); });
async function loadCode(id: string) { async function loadCode(id: string) {
@@ -81,6 +96,15 @@ export async function PATCH(
/** Dateien, die nach der Änderung zu diesem Code gehören. */ /** Dateien, die nach der Änderung zu diesem Code gehören. */
let neueDateien: string[] | null = null; let neueDateien: string[] | null = null;
// Der Inhalt eines statischen Codes steckt im gedruckten Bild. Ihn hier zu
// ändern würde die Ablage von dem entfernen, was tatsächlich im Umlauf
// ist — der Eintrag ist ein Schnappschuss, kein steuerbares Ziel.
if (parsed.data.data !== undefined && existing.mode === "static") {
return jsonError(
"Der Inhalt eines statischen Codes lässt sich nicht ändern — er steckt im gedruckten Code selbst. Änderbar ist nur der Name.",
);
}
if (parsed.data.data !== undefined) { if (parsed.data.data !== undefined) {
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der // Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
// Verwaltung abgeschalteter Typ soll bestehende Codes nicht // Verwaltung abgeschalteter Typ soll bestehende Codes nicht
@@ -109,20 +133,122 @@ export async function PATCH(
Object.assign(update, parsed.data.design); Object.assign(update, parsed.data.design);
} }
if (Object.keys(update).length === 0) { // Ein eigenes Logo gehört zur Gestaltung, nicht zum Inhalt. Es muss
// trotzdem an den Code gehängt werden — sonst liegt die Datei herrenlos
// herum und wird nach einem Tag weggeräumt, mitsamt dem Logo im Code.
const logoVorher =
existing.logoSource === "upload" ? existing.logoUploadId : null;
const logoNachher =
parsed.data.design === undefined
? logoVorher
: parsed.data.design.logoSource === "upload"
? parsed.data.design.logoUploadId
: null;
/* ------------------------------------------------------- Kurz-Link */
const neuerSlug = parsed.data.slug?.trim();
// Ein statischer Code hat keinen Kurz-Link — sein Inhalt steckt in ihm
// selbst. Ein Umbenennen waere gegenstandslos.
if (existing.mode === "static" && neuerSlug) {
return jsonError(
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
);
}
const umbenannt =
neuerSlug !== undefined &&
existing.slug !== null &&
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
/**
* Welche früheren Adressen gelten sollen. Fehlt das Feld, bleibt alles wie
* es ist; steht es da, beschreibt es den gewünschten Zustand.
*/
const behalten =
parsed.data.aliases === undefined
? null
: new Set(parsed.data.aliases.map(normalizeSlug));
const vorhandeneAliase = await prisma.qrCodeAlias.findMany({
where: { qrCodeId: existing.id },
select: { id: true, slug: true },
});
const freigeben = behalten
? vorhandeneAliase.filter((a) => !behalten.has(normalizeSlug(a.slug)))
: [];
// Erst prüfen, wenn wirklich etwas am Kurz-Link geändert wird. Die
// Oberfläche schickt die Liste der früheren Adressen bei jeder Änderung
// mit — unverändert soll das kein zusätzliches Recht verlangen.
if (umbenannt || freigeben.length > 0) {
const { editorsMaySetSlug } = await getSettings();
requirePermission(actor, "codes:slug", {
groupId: existing.groupId,
editorsMaySetSlug,
});
}
if (umbenannt) {
const problem = describeSlugProblem(neuerSlug);
if (problem) return jsonError(problem);
await assertSlugAvailable(existing.domainId, neuerSlug, existing.id);
update.slug = normalizeSlug(neuerSlug);
}
if (
Object.keys(update).length === 0 &&
!umbenannt &&
freigeben.length === 0
) {
return jsonError("Es wurden keine Änderungen übermittelt."); return jsonError("Es wurden keine Änderungen übermittelt.");
} }
const updated = await prisma.qrCode.update({ // Umbenennen, alte Adresse sichern und Freigaben in einem Zug — sonst
where: { id }, // gäbe es einen Moment, in dem der Code unter keiner Adresse erreichbar
data: update, // ist, oder eine freigegebene Adresse bliebe hängen.
include: WITH_RELATIONS, const updated = await prisma.$transaction(async (tx) => {
if (freigeben.length > 0) {
await tx.qrCodeAlias.deleteMany({
where: { id: { in: freigeben.map((a) => a.id) } },
});
}
if (umbenannt) {
// Der bisherige Kurz-Link bleibt gültig — daran hängen gedruckte
// Codes. Freigeben lässt er sich danach einzeln.
await tx.qrCodeAlias.create({
data: {
domainId: existing.domainId,
// `umbenannt` gilt nur bei vorhandenem Kurz-Link.
slug: existing.slug as string,
qrCodeId: existing.id,
},
});
}
return tx.qrCode.update({
where: { id },
data: update,
include: WITH_RELATIONS,
});
}); });
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird // Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
// später als verwaist weggeräumt. // später als verwaist weggeräumt.
if (neueDateien !== null) { if (neueDateien !== null) {
await attachUploads(updated.id, neueDateien); await attachUploads(
updated.id,
logoNachher ? [...neueDateien, logoNachher] : neueDateien,
);
} else if (logoNachher && logoNachher !== logoVorher) {
await attachUpload(updated.id, logoNachher);
}
// Ein abgelegtes oder ausgetauschtes Logo verliert seinen Bezug und wird
// später weggeräumt.
if (logoVorher && logoVorher !== logoNachher) {
await detachUpload(updated.id, logoVorher);
} }
return Response.json({ code: toQrCodeDto(updated) }); return Response.json({ code: toQrCodeDto(updated) });
+127 -6
View File
@@ -7,12 +7,15 @@ import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { resolveTargetDomain } from "@/lib/domains"; import { resolveTargetDomain } from "@/lib/domains";
import { findStaticDuplicate } from "@/lib/duplicates";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireAvailableQrType } from "@/lib/qr/availability"; import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request"; import { assertDynamicPossible } from "@/lib/qr/request";
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module"; import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
import { generateSlug } from "@/lib/slug"; import { describeSlugProblem, generateSlug, normalizeSlug } from "@/lib/slug";
import { assertSlugAvailable } from "@/lib/slug-resolve";
import { getSettings } from "@/lib/settings";
import { attachUploads } from "@/lib/uploads"; import { attachUploads } from "@/lib/uploads";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -26,12 +29,47 @@ const createSchema = z.object({
.trim() .trim()
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.optional(), .optional(),
/**
* Statisch oder dynamisch. Ein statischer Code wird nur abgelegt, damit man
* ihn wiederfindet — er bekommt keinen Kurz-Link und zählt keine Scans.
*/
mode: z.enum(["static", "dynamic"], { error: "Ungültiger Modus." })
.default("dynamic"),
/**
* Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt.
* Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge.
*/
slug: z.string().trim().max(120).optional(),
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */ /** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
groupId: z.string().trim().min(1).optional(), groupId: z.string().trim().min(1).optional(),
/** Unter welcher Domain die Kurz-URL entsteht. */ /** Unter welcher Domain die Kurz-URL entsteht. */
domainId: z.string().trim().min(1).optional(), domainId: z.string().trim().min(1).optional(),
/**
* Eine gemeldete Dublette bewusst in Kauf nehmen. Ohne dieses Häkchen
* antwortet das Anlegen mit 409, sobald derselbe Inhalt schon als
* statischer Code in der Gruppe liegt.
*/
allowDuplicate: z.boolean().default(false),
}); });
/**
* Alle Dateien, die zu einem Code gehören.
*
* Das sind die des Typ-Moduls — ein PDF, ein Bild — **und** ein eigenes Logo
* aus der Gestaltung. Wird das vergessen, löst `attachUploads` das Logo beim
* nächsten Speichern wieder vom Code und räumt es später als herrenlos weg.
*/
function dateienDesCodes(
qrType: { uploadIds: (data: never) => string[] },
data: unknown,
design: { logoSource: string | null; logoUploadId: string | null },
): string[] {
const ids = qrType.uploadIds(data as never);
return design.logoSource === "upload" && design.logoUploadId
? [...ids, design.logoUploadId]
: ids;
}
function isUniqueViolation(error: unknown): boolean { function isUniqueViolation(error: unknown): boolean {
return ( return (
typeof error === "object" && typeof error === "object" &&
@@ -57,6 +95,7 @@ export async function GET(): Promise<Response> {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } }, createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
}, },
}); });
@@ -86,22 +125,95 @@ export async function POST(request: NextRequest): Promise<Response> {
// ist — und der Slug ist nur je Domain eindeutig. // ist — und der Slug ist nur je Domain eindeutig.
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId); const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId);
const qrType = await requireAvailableQrType(type); // Die Freigabe haengt an der Zielgruppe — global abgeschaltet gewinnt.
const qrType = await requireAvailableQrType(type, groupId);
const validated = qrType.validate(parsed.data.data); const validated = qrType.validate(parsed.data.data);
if (!validated.ok) { if (!validated.ok) {
return jsonError(validated.message); return jsonError(validated.message);
} }
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten. const istStatisch = parsed.data.mode === "static";
assertDynamicPossible(qrType, validated.data);
if (istStatisch) {
// Ein statischer Code trägt seinen Inhalt selbst — der Typ muss das
// können, sonst gäbe es nichts zu kodieren.
if (!qrType.supportsStatic) {
return jsonError(
`Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`,
);
}
if (parsed.data.slug) {
return jsonError(
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
);
}
} else {
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
assertDynamicPossible(qrType, validated.data);
}
// Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht —
// sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte.
const gewuenscht = parsed.data.slug?.trim();
if (gewuenscht) {
// Einen Kurz-Link selbst zu vergeben wiegt mehr als einen Code anzulegen.
const { editorsMaySetSlug } = await getSettings();
requirePermission(actor, "codes:slug", { groupId, editorsMaySetSlug });
const problem = describeSlugProblem(gewuenscht);
if (problem) return jsonError(problem);
await assertSlugAvailable(domainId, gewuenscht);
}
// Zweimal derselbe statische Inhalt ergibt zweimal dasselbe Bild. Das ist
// erlaubt, aber selten gewollt — deshalb ein Halt mit Rückfrage statt
// eines stillen zweiten Eintrags.
if (istStatisch && !parsed.data.allowDuplicate) {
const dublette = await findStaticDuplicate(groupId, qrType.id, validated.data);
if (dublette) {
return Response.json(
{
error: dublette.label
? `Diesen Inhalt hast du schon abgelegt — als „${dublette.label}“.`
: "Diesen Inhalt hast du schon abgelegt.",
duplicate: dublette,
},
{ status: 409 },
);
}
}
if (istStatisch) {
const created = await prisma.qrCode.create({
data: {
mode: "static",
slug: null,
type: qrType.id,
label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data),
groupId,
domainId,
createdById: actor.kind === "user" ? actor.user.id : null,
...design,
},
include: {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
},
});
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
}
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich. // Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
for (let attempt = 0; attempt < 5; attempt += 1) { for (let attempt = 0; attempt < 5; attempt += 1) {
try { try {
const created = await prisma.qrCode.create({ const created = await prisma.qrCode.create({
data: { data: {
slug: generateSlug(), slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
type: qrType.id, type: qrType.id,
label: label && label.length > 0 ? label : null, label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data), data: toJsonInput(validated.data),
@@ -114,14 +226,23 @@ export async function POST(request: NextRequest): Promise<Response> {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } }, createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
}, },
}); });
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code — // Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
// vorher lagen sie ohne Bezug herum. // vorher lagen sie ohne Bezug herum.
await attachUploads(created.id, qrType.uploadIds(validated.data)); await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 }); return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) { } catch (error) {
// Bei einem selbst gewählten Kurz-Link hilft kein zweiter Versuch: Er
// käme immer wieder auf dieselbe Adresse.
if (gewuenscht && isUniqueViolation(error)) {
return jsonError(
`Der Kurz-Link „${normalizeSlug(gewuenscht)}" ist auf dieser Domain schon vergeben.`,
409,
);
}
if (!isUniqueViolation(error)) throw error; if (!isUniqueViolation(error)) throw error;
} }
} }
+40
View File
@@ -0,0 +1,40 @@
import type { NextRequest } from "next/server";
import { bildeBeweis, getCheckSecret } from "@/lib/domain-check";
export const dynamic = "force-dynamic";
/**
* Beweis-Route für den Domain-Check.
*
* Bewusst **ohne Anmeldung**: Der Aufruf kommt von außen über die zu prüfende
* Domain und trägt keine Sitzung mit sich. Preisgegeben wird dabei nichts —
* die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Wer das
* Geheimnis nicht kennt, kann ihn nicht bilden; wer ihn sieht, lernt daraus
* nichts über das Geheimnis.
*
* Dass es diese Anwendung gibt, verrät ohnehin schon jede Landingpage.
*/
export async function GET(request: NextRequest): Promise<Response> {
const nonce = request.nextUrl.searchParams.get("nonce") ?? "";
// Eine Grenze nach oben, damit die Route nicht als Rechenknecht für fremde
// Daten taugt, und eine nach unten, damit sie nicht auf Zuruf antwortet.
if (!/^[a-f0-9]{16,64}$/.test(nonce)) {
return Response.json(
{ error: "Es fehlt eine gültige Zufallszahl." },
{ status: 400 },
);
}
try {
const proof = bildeBeweis(await getCheckSecret(), nonce);
return Response.json(
{ app: "qrcode-generator", proof },
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
console.error("Domain-Check konnte keinen Beweis bilden:", error);
return Response.json({ error: "Nicht möglich." }, { status: 500 });
}
}
+5 -3
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { getActor, requireSignedIn } from "@/lib/auth/actor"; import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { ladeLogo } from "@/lib/qr/logo";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render"; import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import { import {
downloadFilename, downloadFilename,
@@ -38,15 +39,16 @@ export async function POST(request: NextRequest): Promise<Response> {
const { qrType, data, content } = await resolveQrRequest(parsed.data); const { qrType, data, content } = await resolveQrRequest(parsed.data);
const logo = await ladeLogo(design);
const body = const body =
format === "png" format === "png"
? new Uint8Array(await renderPng(content, design)) ? new Uint8Array(await renderPng(content, design, logo))
: await renderSvg(content, design); : await renderSvg(content, design, logo);
return new Response(body, { return new Response(body, {
headers: { headers: {
"Content-Type": CONTENT_TYPES[format], "Content-Type": CONTENT_TYPES[format],
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format)}"`, "Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format, parsed.data.label)}"`,
"Cache-Control": "no-store", "Cache-Control": "no-store",
}, },
}); });
+3 -1
View File
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
import { getActor, requireSignedIn } from "@/lib/auth/actor"; import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { ladeLogo } from "@/lib/qr/logo";
import { renderSvg } from "@/lib/qr/render"; import { renderSvg } from "@/lib/qr/render";
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request"; import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
@@ -21,7 +22,8 @@ export async function POST(request: NextRequest): Promise<Response> {
} }
const { content } = await resolveQrRequest(parsed.data); const { content } = await resolveQrRequest(parsed.data);
const svg = await renderSvg(content, parsed.data.design); const logo = await ladeLogo(parsed.data.design);
const svg = await renderSvg(content, parsed.data.design, logo);
return Response.json({ return Response.json({
content, content,
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains"; import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug"; import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type Context = { params: Promise<{ slug: string }> }; // Ein Kurz-Link darf „/" enthalten (z. B. kunde-a/sommer), deshalb eine
// Sammelroute: Next liefert die Abschnitte dann als Liste.
type Context = { params: Promise<{ slug: string[] }> };
function notFound(message: string): Response { function notFound(message: string): Response {
return new Response( return new Response(
@@ -44,7 +47,8 @@ export async function GET(
request: NextRequest, request: NextRequest,
context: Context, context: Context,
): Promise<Response> { ): Promise<Response> {
const { slug } = await context.params; const abschnitte = (await context.params).slug;
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) { if (!isValidSlug(slug)) {
return notFound("Diese Kurz-Adresse ist ungültig."); return notFound("Diese Kurz-Adresse ist ungültig.");
@@ -65,9 +69,8 @@ export async function GET(
); );
} }
const code = await prisma.qrCode.findUnique({ const gefunden = await findCodeBySlug(domain.id, slug);
where: { domainId_slug: { domainId: domain.id, slug } }, const code = gefunden?.code;
});
if (!code) { if (!code) {
return notFound( return notFound(
@@ -85,7 +88,7 @@ export async function GET(
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ, // Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch // damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
// wenn das eine nicht registrierte ist, etwa eine IP im LAN). // wenn das eine nicht registrierte ist, etwa eine IP im LAN).
target = `/s/${slug}`; target = `/s/${code.slug}`;
} else { } else {
try { try {
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran, // Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
@@ -3,14 +3,15 @@ import { headers } from "next/headers";
import { qrLandings } from "@/components/landing"; import { qrLandings } from "@/components/landing";
import { LandingNotice } from "@/components/landing/shell"; import { LandingNotice } from "@/components/landing/shell";
import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains"; import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug"; import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ slug: string }> }; // Sammelroute, weil ein Kurz-Link „/" enthalten darf.
type PageProps = { params: Promise<{ slug: string[] }> };
/** /**
* Öffentliche Landingpage eines dynamischen Codes. * Öffentliche Landingpage eines dynamischen Codes.
@@ -21,16 +22,15 @@ type PageProps = { params: Promise<{ slug: string }> };
* Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`. * Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan. * Sonst zählte jedes Neuladen dieser Seite als weiterer Scan.
*/ */
async function loadCode(slug: string) { async function loadCode(abschnitte: string[] | undefined) {
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) return null; if (!isValidSlug(slug)) return null;
const matched = await resolveRequestDomain(await headers()); const matched = await resolveRequestDomain(await headers());
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true }; const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
if (!domain.isActive) return null; if (!domain.isActive) return null;
return prisma.qrCode.findUnique({ return (await findCodeBySlug(domain.id, slug))?.code ?? null;
where: { domainId_slug: { domainId: domain.id, slug } },
});
} }
export async function generateMetadata({ export async function generateMetadata({
@@ -91,6 +91,17 @@ export default async function LandingPage({ params }: PageProps) {
); );
} }
// Gefunden wird ein Code nur ueber seinen Kurz-Link; statische haben keinen
// und koennen hier deshalb gar nicht ankommen. Der Zweig ist reine Vorsorge.
if (!code.slug) {
return (
<LandingNotice title="Diese Seite gibt es nicht">
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
der ihn erstellt hat.
</LandingNotice>
);
}
return ( return (
<Landing <Landing
data={code.data} data={code.data}
+301 -76
View File
@@ -19,6 +19,7 @@ import {
import { ApiError, apiJson } from "@/lib/api-client"; import { ApiError, apiJson } from "@/lib/api-client";
import type { QrCodeDto } from "@/lib/dto"; import type { QrCodeDto } from "@/lib/dto";
import { qrTypes } from "@/lib/qr/registry"; import { qrTypes } from "@/lib/qr/registry";
import { describeSlugProblem } from "@/lib/slug";
import type { QrFormData } from "@/lib/qr/type-module"; import type { QrFormData } from "@/lib/qr/type-module";
const dateFormat = new Intl.DateTimeFormat("de-DE", { const dateFormat = new Intl.DateTimeFormat("de-DE", {
@@ -42,6 +43,8 @@ function formatShortDate(value: string | null): string {
type View = "list" | "cards"; type View = "list" | "cards";
type SortKey = "neueste" | "aelteste" | "name" | "scans"; type SortKey = "neueste" | "aelteste" | "name" | "scans";
/** Betriebsart, auf die die Liste eingeschränkt ist. */
type ModeFilter = "alle" | "dynamic" | "static";
const SORT_LABELS: Record<SortKey, string> = { const SORT_LABELS: Record<SortKey, string> = {
neueste: "Neueste zuerst", neueste: "Neueste zuerst",
@@ -50,6 +53,12 @@ const SORT_LABELS: Record<SortKey, string> = {
scans: "Meiste Scans", scans: "Meiste Scans",
}; };
const MODE_LABELS: Record<ModeFilter, string> = {
alle: "Alle Arten",
dynamic: "Nur dynamische",
static: "Nur statische",
};
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */ /** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
const VIEW_STORAGE_KEY = "qrgen.codes.view"; const VIEW_STORAGE_KEY = "qrgen.codes.view";
@@ -65,11 +74,19 @@ function displayName(code: QrCodeDto): string {
return code.label?.trim() || code.summary; return code.label?.trim() || code.summary;
} }
export function CodesList() { export function CodesList({
/** Darf der Kurz-Link geändert werden? Sonst erscheinen die Felder nicht. */
maySetSlug = true,
}: {
maySetSlug?: boolean;
}) {
const [codes, setCodes] = useState<QrCodeDto[] | null>(null); const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [editing, setEditing] = useState<QrCodeDto | null>(null); const [editing, setEditing] = useState<QrCodeDto | null>(null);
const [editSlug, setEditSlug] = useState("");
/** Welche früheren Adressen gelten sollen — was hier fehlt, wird freigegeben. */
const [editAliases, setEditAliases] = useState<string[]>([]);
const [editData, setEditData] = useState<QrFormData>({}); const [editData, setEditData] = useState<QrFormData>({});
const [editLabel, setEditLabel] = useState(""); const [editLabel, setEditLabel] = useState("");
const [editError, setEditError] = useState<string | null>(null); const [editError, setEditError] = useState<string | null>(null);
@@ -85,6 +102,7 @@ export function CodesList() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortKey>("neueste"); const [sort, setSort] = useState<SortKey>("neueste");
const [groupFilter, setGroupFilter] = useState("alle"); const [groupFilter, setGroupFilter] = useState("alle");
const [modeFilter, setModeFilter] = useState<ModeFilter>("alle");
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen // Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern. // localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
@@ -98,6 +116,14 @@ export function CodesList() {
window.localStorage.setItem(VIEW_STORAGE_KEY, next); window.localStorage.setItem(VIEW_STORAGE_KEY, next);
}; };
const changeModeFilter = (next: ModeFilter) => {
setModeFilter(next);
// Statische Codes zählen keine Scans. Bleibt die Sortierung danach
// stehen, sortiert die Liste nach lauter Nullen — und die Auswahl bietet
// eine Reihenfolge an, die es dort gar nicht gibt.
if (next === "static" && sort === "scans") setSort("neueste");
};
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes"); const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
@@ -124,9 +150,21 @@ export function CodesList() {
setEditing(code); setEditing(code);
setEditData(code.data); setEditData(code.data);
setEditLabel(code.label ?? ""); setEditLabel(code.label ?? "");
setEditSlug(code.slug ?? "");
setEditAliases(code.aliases);
setEditError(null); setEditError(null);
}; };
const slugProblem =
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
const slugGeaendert =
editing !== null &&
editing.slug !== null &&
editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
const freigegeben = editing
? editing.aliases.filter((alias) => !editAliases.includes(alias))
: [];
const EditForm = editing ? qrForms[editing.type] : undefined; const EditForm = editing ? qrForms[editing.type] : undefined;
const editValidation = useMemo( const editValidation = useMemo(
() => (editing ? qrTypes[editing.type].validate(editData) : null), () => (editing ? qrTypes[editing.type].validate(editData) : null),
@@ -139,12 +177,29 @@ export function CodesList() {
setEditError(editValidation.message); setEditError(editValidation.message);
return; return;
} }
if (slugProblem) {
setEditError(slugProblem);
return;
}
setSaving(true); setSaving(true);
setEditError(null); setEditError(null);
try { try {
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, { await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
method: "PATCH", method: "PATCH",
json: { data: editValidation.data, label: editLabel.trim() || null }, json: {
label: editLabel.trim() || null,
// Ein statischer Code ist ein Schnappschuss — Inhalt und Kurz-Link
// gehören nicht in die Anfrage.
...(editing.mode === "static"
? {}
: {
data: editValidation.data,
// Nur mitschicken, wenn es bearbeitet werden durfte.
...(maySetSlug
? { slug: editSlug.trim(), aliases: editAliases }
: {}),
}),
},
}); });
setEditing(null); setEditing(null);
await load(); await load();
@@ -179,6 +234,7 @@ export function CodesList() {
}; };
const copy = async (code: QrCodeDto) => { const copy = async (code: QrCodeDto) => {
if (!code.shortUrl) return;
try { try {
await navigator.clipboard.writeText(code.shortUrl); await navigator.clipboard.writeText(code.shortUrl);
setCopiedId(code.id); setCopiedId(code.id);
@@ -202,11 +258,25 @@ export function CodesList() {
); );
}, [codes]); }, [codes]);
// Welche Betriebsarten überhaupt vorkommen. Ein Filter, der nichts zu
// filtern hat, steht nur im Weg — deshalb erscheint er erst, wenn beide
// Arten in der Liste stehen.
const kinds = useMemo(() => {
let dynamisch = false;
let statisch = false;
for (const code of codes ?? []) {
if (code.mode === "static") statisch = true;
else dynamisch = true;
}
return { dynamisch, statisch, beide: dynamisch && statisch };
}, [codes]);
const visible = useMemo(() => { const visible = useMemo(() => {
const needle = query.trim().toLowerCase(); const needle = query.trim().toLowerCase();
const filtered = (codes ?? []).filter((code) => { const filtered = (codes ?? []).filter((code) => {
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false; if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
if (modeFilter !== "alle" && code.mode !== modeFilter) return false;
if (!needle) return true; if (!needle) return true;
return [ return [
displayName(code), displayName(code),
@@ -234,7 +304,7 @@ export function CodesList() {
} }
}); });
return sorted; return sorted;
}, [codes, query, sort, groupFilter]); }, [codes, query, sort, groupFilter, modeFilter]);
/* ---------------------------------------------------------------- View */ /* ---------------------------------------------------------------- View */
@@ -248,6 +318,10 @@ export function CodesList() {
const svgHref = (code: QrCodeDto) => const svgHref = (code: QrCodeDto) =>
`/api/codes/${code.id}/qr?format=svg&download=1`; `/api/codes/${code.id}/qr?format=svg&download=1`;
// Zeigt die Liste ausschließlich statische Codes, hat die Scan-Spalte
// nichts zu sagen — dann fällt sie weg, samt der Sortierung danach.
const nurStatische = modeFilter === "static" || (kinds.statisch && !kinds.dynamisch);
const toolbar = codes.length > 0 && ( const toolbar = codes.length > 0 && (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1 basis-56"> <div className="relative min-w-0 flex-1 basis-56">
@@ -281,17 +355,36 @@ export function CodesList() {
</Select> </Select>
) : null} ) : null}
{kinds.beide ? (
<Select
aria-label="Nach Art filtern"
className="!w-auto"
value={modeFilter}
onChange={(event) =>
changeModeFilter(event.target.value as ModeFilter)
}
>
{(Object.keys(MODE_LABELS) as ModeFilter[]).map((key) => (
<option key={key} value={key}>
{MODE_LABELS[key]}
</option>
))}
</Select>
) : null}
<Select <Select
aria-label="Sortierung" aria-label="Sortierung"
className="!w-auto" className="!w-auto"
value={sort} value={sort}
onChange={(event) => setSort(event.target.value as SortKey)} onChange={(event) => setSort(event.target.value as SortKey)}
> >
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => ( {(Object.keys(SORT_LABELS) as SortKey[])
<option key={key} value={key}> .filter((key) => key !== "scans" || !nurStatische)
{SORT_LABELS[key]} .map((key) => (
</option> <option key={key} value={key}>
))} {SORT_LABELS[key]}
</option>
))}
</Select> </Select>
<div <div
@@ -367,12 +460,13 @@ export function CodesList() {
{codes.length > 0 && visible.length === 0 ? ( {codes.length > 0 && visible.length === 0 ? (
<Card className="text-center"> <Card className="text-center">
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-600">
Kein Code passt zur Suche.{" "} Kein Code passt zu Suche und Filtern.{" "}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setQuery(""); setQuery("");
setGroupFilter("alle"); setGroupFilter("alle");
setModeFilter("alle");
}} }}
className="font-medium text-accent-text underline" className="font-medium text-accent-text underline"
> >
@@ -407,9 +501,11 @@ export function CodesList() {
Gruppe Gruppe
</th> </th>
) : null} ) : null}
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20"> {nurStatische ? null : (
Scans <th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
</th> Scans
</th>
)}
<th <th
scope="col" scope="col"
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell" className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
@@ -449,9 +545,18 @@ export function CodesList() {
<div className="truncate text-xs text-slate-500"> <div className="truncate text-xs text-slate-500">
{/* Nur der Pfad: Der Hostname ist auf dem Handy {/* Nur der Pfad: Der Hostname ist auf dem Handy
gleich für alle Zeilen, der Slug unterscheidet. */} gleich für alle Zeilen, der Slug unterscheidet. */}
<span className="font-mono md:hidden" title={code.shortUrl}> {code.shortUrl ? (
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")} <span
</span> className="font-mono md:hidden"
title={code.shortUrl}
>
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
</span>
) : (
<span className="md:hidden">
{qrTypes[code.type]?.title ?? code.type} · statisch
</span>
)}
<span className="hidden md:inline"> <span className="hidden md:inline">
{qrTypes[code.type]?.title ?? code.type} {qrTypes[code.type]?.title ?? code.type}
</span> </span>
@@ -461,28 +566,34 @@ export function CodesList() {
</td> </td>
<td className="hidden px-3 py-2.5 md:table-cell"> <td className="hidden px-3 py-2.5 md:table-cell">
<div className="flex items-center gap-1.5"> {code.shortUrl ? (
<a <div className="flex items-center gap-1.5">
href={code.shortUrl} <a
target="_blank" href={code.shortUrl}
rel="noreferrer" target="_blank"
className="truncate font-mono text-xs text-accent-text hover:underline" rel="noreferrer"
title={code.shortUrl} className="truncate font-mono text-xs text-accent-text hover:underline"
> title={code.shortUrl}
{code.shortUrl.replace(/^https?:\/\//, "")} >
</a> {code.shortUrl.replace(/^https?:\/\//, "")}
<button </a>
type="button" <button
onClick={() => void copy(code)} type="button"
title="Kurz-URL kopieren" onClick={() => void copy(code)}
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700" title="Kurz-URL kopieren"
> className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
<Icon >
name={copiedId === code.id ? "check" : "copy"} <Icon
className="size-3.5" name={copiedId === code.id ? "check" : "copy"}
/> className="size-3.5"
</button> />
</div> </button>
</div>
) : (
// Ein statischer Code hat keine Kurz-URL — sein Inhalt
// steckt in ihm selbst.
<Badge tone="neutral">statisch</Badge>
)}
</td> </td>
<td className="hidden px-3 py-2.5 sm:table-cell"> <td className="hidden px-3 py-2.5 sm:table-cell">
@@ -497,7 +608,13 @@ export function CodesList() {
{code.target.replace(/^https?:\/\//, "")} {code.target.replace(/^https?:\/\//, "")}
</a> </a>
) : ( ) : (
<span className="block truncate text-red-600" title={code.summary}> <span
className={cx(
"block truncate",
code.mode === "static" ? "text-slate-600" : "text-red-600",
)}
title={code.summary}
>
{code.summary} {code.summary}
</span> </span>
)} )}
@@ -511,9 +628,20 @@ export function CodesList() {
</td> </td>
) : null} ) : null}
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums"> {nurStatische ? null : (
{code.scans} <td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
</td> {code.mode === "static" ? (
<span
className="font-normal text-slate-400"
title="Statische Codes werden nicht gezählt"
>
—
</span>
) : (
code.scans
)}
</td>
)}
<td <td
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell" className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
@@ -539,8 +667,8 @@ export function CodesList() {
<button <button
type="button" type="button"
onClick={() => openEdit(code)} onClick={() => openEdit(code)}
title="Ziel bearbeiten" title="Bearbeiten"
aria-label={`Ziel von ${displayName(code)} bearbeiten`} aria-label={`${displayName(code)} bearbeiten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900" className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
> >
<Icon name="pencil" className="size-4" /> <Icon name="pencil" className="size-4" />
@@ -610,30 +738,41 @@ export function CodesList() {
</div> </div>
<dl className="mt-3 space-y-2 text-sm"> <dl className="mt-3 space-y-2 text-sm">
<div className="flex flex-wrap items-baseline gap-x-2"> {code.shortUrl ? (
<dt className="text-slate-500">Kurz-URL:</dt> <div className="flex flex-wrap items-baseline gap-x-2">
<dd className="flex items-center gap-2"> <dt className="text-slate-500">Kurz-URL:</dt>
<a <dd className="flex items-center gap-2">
href={code.shortUrl} <a
target="_blank" href={code.shortUrl}
rel="noreferrer" target="_blank"
className="font-mono text-xs break-all text-accent-text hover:underline" rel="noreferrer"
> className="font-mono text-xs break-all text-accent-text hover:underline"
{code.shortUrl} >
</a> {code.shortUrl}
<button </a>
type="button" <button
onClick={() => void copy(code)} type="button"
title="Kurz-URL kopieren" onClick={() => void copy(code)}
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700" title="Kurz-URL kopieren"
> className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
<Icon >
name={copiedId === code.id ? "check" : "copy"} <Icon
className="size-4" name={copiedId === code.id ? "check" : "copy"}
/> className="size-4"
</button> />
</dd> </button>
</div> </dd>
</div>
) : (
// Statisch: Der Inhalt steckt im Code selbst, es gibt keine
// Kurz-URL und nichts, was sich nachträglich umbiegen liesse.
<div className="flex flex-wrap items-baseline gap-x-2">
<dt className="text-slate-500">Art:</dt>
<dd className="text-slate-800">
statisch — Inhalt steckt im Code, nicht änderbar
</dd>
</div>
)}
<div className="flex flex-wrap items-baseline gap-x-2"> <div className="flex flex-wrap items-baseline gap-x-2">
<dt className="text-slate-500">Ziel:</dt> <dt className="text-slate-500">Ziel:</dt>
@@ -683,7 +822,7 @@ export function CodesList() {
onClick={() => openEdit(code)} onClick={() => openEdit(code)}
> >
<Icon name="pencil" className="size-3.5" /> <Icon name="pencil" className="size-3.5" />
Ziel bearbeiten Bearbeiten
</Button> </Button>
<a <a
href={`/api/codes/${code.id}/qr?format=png&download=1`} href={`/api/codes/${code.id}/qr?format=png&download=1`}
@@ -721,7 +860,7 @@ export function CodesList() {
{/* ------------------------------------------------------ Bearbeiten */} {/* ------------------------------------------------------ Bearbeiten */}
<Modal <Modal
open={editing !== null} open={editing !== null}
title="Ziel bearbeiten" title="Code bearbeiten"
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
footer={ footer={
<> <>
@@ -742,15 +881,12 @@ export function CodesList() {
{editing ? ( {editing ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-600">
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin {editing.mode === "static"
auf{" "} ? "Ein statischer Code trägt seinen Inhalt selbst — änderbar ist deshalb nur der Name, unter dem du ihn hier wiederfindest."
<code className="font-mono text-xs break-all"> : "Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt gültig, solange der Kurz-Link derselbe ist."}
{editing.shortUrl}
</code>
.
</p> </p>
{EditForm ? ( {editing.mode === "static" ? null : EditForm ? (
<EditForm <EditForm
value={editData} value={editData}
onChange={(value) => { onChange={(value) => {
@@ -766,6 +902,95 @@ export function CodesList() {
</Notice> </Notice>
)} )}
{maySetSlug && editing.mode !== "static" ? (
<>
<Field
label="Kurz-Link"
htmlFor="edit-slug"
hint="Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
>
<TextInput
id="edit-slug"
value={editSlug}
maxLength={100}
spellCheck={false}
invalid={Boolean(slugProblem)}
disabled={saving}
onChange={(event) => {
setEditSlug(event.target.value);
setEditError(null);
}}
/>
</Field>
{slugProblem ? (
<Notice tone="error">{slugProblem}</Notice>
) : slugGeaendert ? (
<Notice tone="info">
Der bisherige Kurz-Link{" "}
<code className="font-mono text-xs break-all">{editing.slug}</code>{" "}
bleibt gültig — schon gedruckte Codes funktionieren weiter. Unten
lässt er sich später freigeben.
</Notice>
) : null}
{editing.aliases.length > 0 ? (
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
Frühere Kurz-Links
</span>
<p className="mb-2 text-sm text-slate-500">
Führen weiterhin auf diesen Code. Freigeben nur, wenn damit
nichts gedruckt ist — die Adresse ist danach sofort tot.
</p>
<ul className="space-y-1.5">
{editing.aliases.map((alias) => {
const behalten = editAliases.includes(alias);
return (
<li
key={alias}
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2"
>
<code
className={
behalten
? "min-w-0 font-mono text-xs break-all text-slate-700"
: "min-w-0 font-mono text-xs break-all text-slate-400 line-through"
}
>
/r/{alias}
</code>
<Button
type="button"
variant={behalten ? "ghost" : "secondary"}
size="sm"
disabled={saving}
onClick={() =>
setEditAliases((alt) =>
behalten
? alt.filter((eintrag) => eintrag !== alias)
: [...alt, alias],
)
}
>
{behalten ? "Freigeben" : "Doch behalten"}
</Button>
</li>
);
})}
</ul>
{freigegeben.length > 0 ? (
<Notice tone="warning">
{freigegeben.length === 1
? "Eine frühere Adresse wird beim Speichern freigegeben und funktioniert danach nicht mehr."
: `${freigegeben.length} frühere Adressen werden beim Speichern freigegeben und funktionieren danach nicht mehr.`}
</Notice>
) : null}
</div>
) : null}
</>
) : null}
<Field label="Name (optional)" htmlFor="edit-label"> <Field label="Name (optional)" htmlFor="edit-label">
<TextInput <TextInput
id="edit-label" id="edit-label"
+160 -4
View File
@@ -1,15 +1,20 @@
"use client"; "use client";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { FileField } from "@/components/qr-forms/file-field";
import { Field, Notice, TextInput, cx } from "@/components/ui"; import { Field, Notice, TextInput, cx } from "@/components/ui";
import { evaluateContrast } from "@/lib/qr/contrast"; import { evaluateContrast } from "@/lib/qr/contrast";
import { import {
COLOR_PRESETS, COLOR_PRESETS,
EC_LEVELS, EC_LEVELS,
EC_LEVEL_LABELS, EC_LEVEL_LABELS,
LOGO_SCALE_WARN,
MAX_LOGO_SCALE,
MAX_SIZE, MAX_SIZE,
MIN_LOGO_SCALE,
MIN_SIZE, MIN_SIZE,
type EcLevel, type EcLevel,
type LogoSource,
type QrDesign, type QrDesign,
} from "@/lib/qr/design"; } from "@/lib/qr/design";
@@ -62,9 +67,12 @@ function ColorField({
export function DesignPanel({ export function DesignPanel({
design, design,
onChange, onChange,
/** Ist unter „Erscheinungsbild“ ein Logo hinterlegt? Sonst entfällt die Wahl. */
hasBrandingLogo = false,
}: { }: {
design: QrDesign; design: QrDesign;
onChange: (design: QrDesign) => void; onChange: (design: QrDesign) => void;
hasBrandingLogo?: boolean;
}) { }) {
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes }); const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
const contrast = evaluateContrast(design.fgColor, design.bgColor); const contrast = evaluateContrast(design.fgColor, design.bgColor);
@@ -165,7 +173,11 @@ export function DesignPanel({
<Field <Field
label="Fehlerkorrektur" label="Fehlerkorrektur"
hint={EC_LEVEL_LABELS[design.ecLevel]} hint={
design.logoSource === null
? EC_LEVEL_LABELS[design.ecLevel]
: "Mit Logo gilt H — was das Bild verdeckt, muss die Fehlerkorrektur ausgleichen."
}
> >
<div <div
role="radiogroup" role="radiogroup"
@@ -177,11 +189,14 @@ export function DesignPanel({
key={level} key={level}
type="button" type="button"
role="radio" role="radio"
aria-checked={design.ecLevel === level} aria-checked={
design.logoSource === null ? design.ecLevel === level : level === "H"
}
disabled={design.logoSource !== null}
onClick={() => patch({ ecLevel: level })} onClick={() => patch({ ecLevel: level })}
className={cx( className={cx(
"rounded-lg border py-2 text-sm font-medium transition-colors", "rounded-lg border py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
design.ecLevel === level (design.logoSource === null ? design.ecLevel : "H") === level
? "border-indigo-600 bg-accent-soft text-accent-soft-fg" ? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50", : "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
)} )}
@@ -208,6 +223,147 @@ export function DesignPanel({
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600" className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/> />
</Field> </Field>
<LogoAuswahl
design={design}
patch={patch}
hasBrandingLogo={hasBrandingLogo}
/>
</div> </div>
); );
} }
/* -------------------------------------------------------------------- Logo */
/**
* Das Bild in der Mitte des Codes.
*
* Bewusst ein eigener Abschnitt und nicht bloß ein Feld: Ein Logo zieht drei
* Entscheidungen nach sich — woher es kommt, wie groß es wird, und dass die
* Fehlerkorrektur dafür hochgesetzt wird. Das gehört sichtbar beieinander.
*/
function LogoAuswahl({
design,
patch,
hasBrandingLogo,
}: {
design: QrDesign;
patch: (changes: Partial<QrDesign>) => void;
hasBrandingLogo: boolean;
}) {
const an = design.logoSource !== null;
const waehle = (quelle: LogoSource | null) => {
if (quelle === null) {
patch({ logoSource: null, logoUploadId: null });
} else if (quelle === "branding") {
patch({ logoSource: "branding", logoUploadId: null });
} else {
patch({ logoSource: "upload" });
}
};
return (
<fieldset className="border-t border-slate-200 pt-4">
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
Logo in der Mitte
</legend>
<label className="mt-3 flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={an}
onChange={(event) =>
waehle(
event.target.checked ? (hasBrandingLogo ? "branding" : "upload") : null,
)
}
className="mt-0.5 size-4 shrink-0 rounded border-slate-300 accent-indigo-600"
/>
<span className="text-sm text-slate-700">
Ein Logo in die Mitte des Codes setzen
</span>
</label>
{an ? (
<div className="mt-4 space-y-4">
{hasBrandingLogo ? (
<div
role="radiogroup"
aria-label="Herkunft des Logos"
className="space-y-2"
>
{(
[
["branding", "Logo dieser Installation"],
["upload", "Eigenes Logo hochladen"],
] as [LogoSource, string][]
).map(([quelle, text]) => (
<label
key={quelle}
className="flex cursor-pointer items-center gap-2.5"
>
<input
type="radio"
name="logo-quelle"
checked={design.logoSource === quelle}
onChange={() => waehle(quelle)}
className="size-4 shrink-0 accent-indigo-600"
/>
<span className="text-sm text-slate-700">{text}</span>
</label>
))}
</div>
) : null}
{design.logoSource === "upload" ? (
<div className="space-y-2">
<FileField
kind="qrlogo"
label={design.logoUploadId ? "Anderes Logo wählen" : "Logo wählen"}
onUploaded={(datei) => patch({ logoUploadId: datei.fileId })}
/>
{design.logoUploadId ? null : (
<p className="text-sm text-amber-700">
Noch keine Datei gewählt — bis dahin bleibt die Mitte frei.
</p>
)}
</div>
) : null}
<Field
label={`Größe: ${design.logoScale} % der Kantenlänge`}
htmlFor="qr-logo-scale"
hint="Bezogen auf die Breite des Codes. Bei 20 % sind rund 4 % der Fläche verdeckt."
>
<input
id="qr-logo-scale"
type="range"
min={MIN_LOGO_SCALE}
max={MAX_LOGO_SCALE}
step={1}
value={design.logoScale}
onChange={(event) => patch({ logoScale: Number(event.target.value) })}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
</Field>
{design.logoScale > LOGO_SCALE_WARN ? (
<Notice tone="warning">
Ab {LOGO_SCALE_WARN} % wird es knapp. Die Fehlerkorrektur trägt das
zwar, aber manche Scanner-Apps suchen in der Mitte nach
Ausrichtungsmustern. Prüfe den fertigen Code mit mehr als einer App,
bevor er in den Druck geht.
</Notice>
) : null}
<p className="text-sm text-slate-500">
Hinter dem Logo liegt eine Fläche in der Hintergrundfarbe, damit es
sich absetzt. Ein Code mit Logo gehört vor dem Druck einmal
eingescannt — das ist die einzige Prüfung, die wirklich zählt.
</p>
</div>
) : null}
</fieldset>
);
}
+48 -2
View File
@@ -2,6 +2,9 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { DomainBericht } from "@/components/domain-bericht";
import { DomainHilfe } from "@/components/domain-hilfe";
import { HelpButton } from "@/components/help-button";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal"; import { Modal } from "@/components/modal";
import { import {
@@ -14,6 +17,7 @@ import {
TextInput, TextInput,
} from "@/components/ui"; } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client"; import { ApiError, apiJson } from "@/lib/api-client";
import type { Pruefbericht } from "@/lib/domain-check";
type Grant = { groupId: string; groupName: string; isDefault: boolean }; type Grant = { groupId: string; groupName: string; isDefault: boolean };
@@ -44,6 +48,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
const [deleting, setDeleting] = useState<AdminDomain | null>(null); const [deleting, setDeleting] = useState<AdminDomain | null>(null);
/** Prüfberichte je Domain — bleiben stehen, bis erneut geprüft wird. */
const [reports, setReports] = useState<Record<string, Pruefbericht>>({});
const [checking, setChecking] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const result = await apiJson<{ const result = await apiJson<{
@@ -122,6 +130,26 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
} }
}; };
const check = async (domain: AdminDomain) => {
setChecking(domain.id);
setError(null);
try {
const { report } = await apiJson<{ report: Pruefbericht }>(
`/api/admin/domains/${domain.id}/check`,
{ method: "POST" },
);
setReports((bisher) => ({ ...bisher, [domain.id]: report }));
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Prüfung ließ sich nicht ausführen.",
);
} finally {
setChecking(null);
}
};
if (domains === null) { if (domains === null) {
return <p className="text-sm text-slate-500">Domains werden geladen …</p>; return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
} }
@@ -134,10 +162,14 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
weiterleitet. Die Weiterleitung erkennt sie am{" "} weiterleitet. Die Weiterleitung erkennt sie am{" "}
<code className="font-mono text-xs">Host</code>-Header und prüft ihn <code className="font-mono text-xs">Host</code>-Header und prüft ihn
gegen diese Liste — ein unbekannter Hostname endet mit 404. gegen diese Liste — ein unbekannter Hostname endet mit 404. Was dafür
einzurichten ist, steht in der Kurzhilfe.
</Notice> </Notice>
<div className="flex justify-end"> <div className="flex flex-wrap items-center justify-between gap-2">
<HelpButton title="Eine Domain hinzufügen" label="Kurzhilfe">
<DomainHilfe baseUrlHostname={baseUrlHostname} />
</HelpButton>
<Button type="button" onClick={() => setCreating(true)}> <Button type="button" onClick={() => setCreating(true)}>
Domain registrieren Domain registrieren
</Button> </Button>
@@ -192,6 +224,16 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
<Icon name="pencil" className="size-3.5" /> <Icon name="pencil" className="size-3.5" />
Gruppen zuteilen Gruppen zuteilen
</Button> </Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={checking !== null}
onClick={() => void check(domain)}
>
<Icon name="scan" className="size-3.5" />
{checking === domain.id ? "Prüft …" : "Prüfen"}
</Button>
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
@@ -213,6 +255,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
Löschen Löschen
</Button> </Button>
</div> </div>
{reports[domain.id] ? (
<DomainBericht bericht={reports[domain.id]} />
) : null}
</div> </div>
</div> </div>
</Card> </Card>
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { Icon } from "@/components/icons";
import { cx } from "@/components/ui";
import type { Pruefbericht, SchrittStatus } from "@/lib/domain-check";
/**
* Das Ergebnis eines Domain-Checks.
*
* Vier Zeilen, eine je Schritt, mit dem Grund im Klartext daneben. Bewusst
* ohne Ampelfarbe als einziges Merkmal: Jede Zeile trägt ein eigenes Symbol
* und einen eigenen Text, damit sie auch ohne Farbe lesbar bleibt.
*/
const SYMBOLE: Record<SchrittStatus, { icon: "check" | "warning" | "eyeOff"; farbe: string; wort: string }> = {
ok: { icon: "check", farbe: "text-emerald-600", wort: "in Ordnung" },
warnung: { icon: "warning", farbe: "text-amber-600", wort: "Achtung" },
fehler: { icon: "warning", farbe: "text-red-600", wort: "Fehler" },
uebersprungen: { icon: "eyeOff", farbe: "text-slate-400", wort: "übersprungen" },
};
const RAHMEN: Record<Pruefbericht["gesamt"], string> = {
ok: "border-emerald-300 bg-emerald-50/60",
warnung: "border-amber-300 bg-amber-50/60",
fehler: "border-red-300 bg-red-50/60",
};
const FAZIT: Record<Pruefbericht["gesamt"], string> = {
ok: "Die Domain führt hierher.",
warnung: "Es läuft, aber etwas will beachtet werden.",
fehler: "So kommt niemand hier an.",
};
export function DomainBericht({ bericht }: { bericht: Pruefbericht }) {
return (
<div className={cx("mt-4 rounded-xl border p-4", RAHMEN[bericht.gesamt])}>
<p className="text-sm font-semibold text-slate-900">
{FAZIT[bericht.gesamt]}
</p>
<ul className="mt-3 space-y-1.5">
{bericht.schritte.map((schritt) => {
const zeichen = SYMBOLE[schritt.status];
return (
<li key={schritt.name} className="flex items-start gap-2 text-sm">
<Icon
name={zeichen.icon}
className={cx("mt-0.5 size-4 shrink-0", zeichen.farbe)}
/>
<span className="sr-only">{zeichen.wort}: </span>
<span className="w-24 shrink-0 font-medium text-slate-700">
{schritt.titel}
</span>
<span className="min-w-0 flex-1 text-slate-600">
{schritt.detail}
</span>
</li>
);
})}
</ul>
<p className="mt-3 text-xs text-slate-500">
Aufgerufen wurde{" "}
<code className="font-mono">{bericht.geprueft}</code> — von dort, wo
dieser Container läuft. Bei internem DNS oder Hairpin-NAT kann die
Prüfung scheitern, obwohl von außen alles stimmt.
</p>
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { HelpCode, HelpStep } from "@/components/help-button";
/**
* Der Inhalt der Kurzhilfe auf der Domain-Seite.
*
* Verdichtet aus dem README-Abschnitt „Hinter einem Reverse Proxy“. Bewusst
* nur die Schritte, die diese Anwendung betreffen — wie man im eigenen Proxy
* einen Eintrag anlegt, steht in dessen Handbuch besser.
*/
export function DomainHilfe({ baseUrlHostname }: { baseUrlHostname: string }) {
const beispiel = baseUrlHostname || "qr.example.com";
return (
<div className="space-y-5">
<p>
Eine Domain hier zu registrieren genügt nicht — sie muss den Browser
auch hierher führen. Dazu gehören drei Dinge außerhalb dieser
Anwendung: ein DNS-Eintrag, ein Eintrag im Reverse Proxy und ein
Zertifikat.
</p>
<ol className="space-y-4">
<HelpStep n={1} title="DNS-Eintrag anlegen">
<p>
Beim Anbieter der Domain einen <strong>A-Record</strong> (bei IPv6
zusätzlich <strong>AAAA</strong>) auf die öffentliche Adresse des
Reverse Proxy setzen. Ein CNAME auf einen bestehenden Namen tut es
auch.
</p>
<p>
Steht der Name nur im internen DNS oder ist er nur über VPN
erreichbar, funktionieren gedruckte Codes{" "}
<strong>auch nur dort</strong>. Für Aushänge, Flyer und
Verpackungen muss er öffentlich auflösbar sein.
</p>
</HelpStep>
<HelpStep n={2} title="Im Reverse Proxy weiterleiten">
<p>
Für die Domain einen eigenen Eintrag anlegen, der auf diese
Anwendung zeigt — Ziel ist der Docker-Host auf Port{" "}
<code className="font-mono text-xs">3000</code>, oder im selben
Docker-Netz der Servicename{" "}
<code className="font-mono text-xs">app</code>.
</p>
<p>
Diese drei Kopfzeilen muss der Proxy mitschicken. Nginx Proxy
Manager, Traefik und Caddy tun das von sich aus; ein blankes nginx
braucht sie im Serverblock:
</p>
<HelpCode>{`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;`}</HelpCode>
<p>
Ohne <code className="font-mono text-xs">X-Forwarded-Host</code>{" "}
landet jeder Aufruf unter der Standard-Domain, und Kurz-URLs der
Zweitdomain werden nicht gefunden.
</p>
</HelpStep>
<HelpStep n={3} title="Zertifikat anfordern">
<p>
HTTPS erledigt der Proxy. Im Nginx Proxy Manager ist das der Reiter{" "}
<em>SSL</em> mit <em>Force SSL</em>; Traefik und Caddy holen das
Zertifikat selbst, sobald der DNS-Eintrag steht.
</p>
<p>
Ohne gültiges Zertifikat brechen viele Scanner-Apps den Aufruf ohne
Erklärung ab — der Code sieht dann für die Nutzer schlicht kaputt
aus.
</p>
</HelpStep>
<HelpStep n={4} title="Domain hier registrieren">
<p>
Über <strong>Domain registrieren</strong> den Hostnamen eintragen —
genau so, wie er im Browser steht, ohne{" "}
<code className="font-mono text-xs">https://</code> und ohne Pfad:
</p>
<HelpCode>{beispiel}</HelpCode>
<p>
Die Weiterleitung prüft den Hostnamen gegen diese Liste. Ein
unbekannter Name endet mit 404 — über eine gefälschte Kopfzeile
lässt sich also keine fremde Domain ansprechen.
</p>
</HelpStep>
<HelpStep n={5} title="Einer Gruppe zuteilen">
<p>
Eine frisch registrierte Domain gehört noch niemandem. Erst über{" "}
<strong>Gruppen zuteilen</strong> können Mitglieder dieser Gruppe
dort Codes anlegen. Die als <em>Voreinstellung</em> markierte
Domain ist die, die beim Anlegen vorausgewählt ist.
</p>
</HelpStep>
<HelpStep n={6} title="Nachprüfen">
<p>
Dafür gibt es den Knopf <strong>Prüfen</strong> bei jedem Eintrag
in der Liste. Er meldet DNS, Zertifikat, Erreichbarkeit — und ob
hinter der Domain wirklich diese Installation steht.
</p>
<p>
Der Aufruf startet dort, wo dieser Container läuft. Bei internem
DNS oder Hairpin-NAT kann er scheitern, obwohl von außen alles
stimmt. Von einem fremden Netz aus hilft dann:
</p>
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
<p>
Zum Schluss einen Code der zugeteilten Gruppe anlegen, die Domain
auswählen und den fertigen Code einmal mit dem Handy scannen — das
ist die einzige Prüfung, die wirklich zählt.
</p>
</HelpStep>
</ol>
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
<h3 className="font-semibold text-slate-900">
Was <code className="font-mono text-xs">BASE_URL</code> damit zu tun hat
</h3>
<p className="mt-1 text-slate-600">
Die Umgebungsvariable{" "}
<code className="font-mono text-xs">BASE_URL</code> ist die
Standard-Adresse: Sie greift bei Aufrufen über eine nicht
registrierte Adresse und bestimmt, ob Kurz-URLs mit{" "}
<code className="font-mono text-xs">http</code> oder{" "}
<code className="font-mono text-xs">https</code> gebildet werden.
Weitere Domains werden hier registriert, nicht dort eingetragen.
</p>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useState, type ReactNode } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import { Button } from "@/components/ui";
/**
* Kurzhilfe: ein Fragezeichen neben einer Überschrift, das eine Erklärung
* aufklappt.
*
* Bewusst ein Dialog und kein Dauertext auf der Seite — die Erklärung braucht
* man einmal beim Einrichten und danach nie wieder. Sie ständig zu zeigen
* würde die Seite für alle anderen Aufrufe zumüllen.
*/
export function HelpButton({
title,
label = "Hilfe",
children,
}: {
/** Überschrift des Dialogs. */
title: string;
/** Beschriftung des Knopfes. */
label?: string;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
>
<Icon name="help" className="size-4" />
{label}
</Button>
<Modal
open={open}
size="lg"
title={title}
onClose={() => setOpen(false)}
footer={
<Button type="button" onClick={() => setOpen(false)}>
Verstanden
</Button>
}
>
<div className="max-h-[65vh] overflow-y-auto pr-1 text-sm text-slate-700">
{children}
</div>
</Modal>
</>
);
}
/* ------------------------------------------------- Bausteine für den Inhalt */
/** Ein nummerierter Schritt mit Überschrift. */
export function HelpStep({
n,
title,
children,
}: {
n: number;
title: string;
children: ReactNode;
}) {
return (
<li className="flex gap-3">
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-fg">
{n}
</span>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-slate-900">{title}</h3>
<div className="mt-1 space-y-2 text-slate-600">{children}</div>
</div>
</li>
);
}
/** Ein Befehl oder Wert zum Abtippen — auf schmalen Schirmen scrollbar. */
export function HelpCode({ children }: { children: ReactNode }) {
return (
<pre className="overflow-x-auto rounded-lg bg-slate-100 px-3 py-2 font-mono text-xs text-slate-800">
{children}
</pre>
);
}
+7
View File
@@ -238,6 +238,13 @@ const PATHS = {
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" /> <path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
</> </>
), ),
help: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.2 2.4c-.6.2-.95.75-.95 1.4v.45" />
<path d="M11.75 16.75h.01" />
</>
),
} as const; } as const;
export type IconName = keyof typeof PATHS; export type IconName = keyof typeof PATHS;
+6 -1
View File
@@ -9,12 +9,15 @@ export function Modal({
onClose, onClose,
children, children,
footer, footer,
size = "md",
}: { }: {
open: boolean; open: boolean;
title: string; title: string;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
footer?: ReactNode; footer?: ReactNode;
/** `lg` für längere Texte wie eine Kurzhilfe. */
size?: "md" | "lg";
}) { }) {
const ref = useRef<HTMLDialogElement>(null); const ref = useRef<HTMLDialogElement>(null);
@@ -39,7 +42,9 @@ export function Modal({
// Klick auf die Hintergrundfläche des Dialogs schließt ihn. // Klick auf die Hintergrundfläche des Dialogs schließt ihn.
if (event.target === ref.current) onClose(); if (event.target === ref.current) onClose();
}} }}
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50" className={`m-auto w-[calc(100%-2rem)] ${
size === "lg" ? "max-w-2xl" : "max-w-lg"
} rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50`}
> >
<div className="p-5 sm:p-6"> <div className="p-5 sm:p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2> <h2 className="text-lg font-semibold text-slate-900">{title}</h2>
+188 -25
View File
@@ -22,6 +22,7 @@ import type { QrCodeDto } from "@/lib/dto";
import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design"; import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design";
import { qrTypes } from "@/lib/qr/registry"; import { qrTypes } from "@/lib/qr/registry";
import type { QrFormData, QrTypeId } from "@/lib/qr/type-module"; import type { QrFormData, QrTypeId } from "@/lib/qr/type-module";
import { describeSlugProblem } from "@/lib/slug";
type PreviewState = { dataUrl: string; content: string }; type PreviewState = { dataUrl: string; content: string };
@@ -42,7 +43,32 @@ function sameData(a: unknown, b: unknown): boolean {
return stableStringify(a) === stableStringify(b); return stableStringify(a) === stableStringify(b);
} }
export function QrGenerator({ typeId }: { typeId: QrTypeId }) { /** Der bereits abgelegte Code, den die Dublettenprüfung gemeldet hat. */
type Dublette = { id: string; label: string | null; createdAt: string };
/** Liest die Meldung aus einer 409-Antwort — oder gibt auf. */
function leseDublette(details: unknown): Dublette | null {
if (details === null || typeof details !== "object") return null;
const feld = (details as { duplicate?: unknown }).duplicate;
if (feld === null || typeof feld !== "object") return null;
const { id, label, createdAt } = feld as Record<string, unknown>;
if (typeof id !== "string" || typeof createdAt !== "string") return null;
return { id, label: typeof label === "string" ? label : null, createdAt };
}
const dublettenDatum = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
export function QrGenerator({
typeId,
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
maySetSlug = true,
/** Ist ein Logo der Installation hinterlegt? Sonst nur eigener Upload. */
hasBrandingLogo = false,
}: {
typeId: QrTypeId;
maySetSlug?: boolean;
hasBrandingLogo?: boolean;
}) {
const qrType = qrTypes[typeId]; const qrType = qrTypes[typeId];
const Form = qrForms[typeId]; const Form = qrForms[typeId];
@@ -51,10 +77,20 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
// Typen ohne statische Variante — etwa der App-Link — starten dynamisch. // Typen ohne statische Variante — etwa der App-Link — starten dynamisch.
const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic); const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic);
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
/** Selbst gewählter Kurz-Link. Leer bedeutet: einen erzeugen lassen. */
const [wunschSlug, setWunschSlug] = useState("");
const [saved, setSaved] = useState<QrCodeDto | null>(null); const [saved, setSaved] = useState<QrCodeDto | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
/**
* Gemeldete Dublette samt dem Inhalt, für den sie gilt. Ändert sich der
* Inhalt, ist die Warnung überholt und verschwindet von selbst — ohne
* einen Effekt, der sie hinterherräumt.
*/
const [duplicate, setDuplicate] = useState<
{ treffer: Dublette; inhalt: string } | null
>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [preview, setPreview] = useState<PreviewState | null>(null); const [preview, setPreview] = useState<PreviewState | null>(null);
@@ -74,11 +110,13 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
const dataError = !validation.ok && touched ? validation.message : null; const dataError = !validation.ok && touched ? validation.message : null;
const mode = isDynamic ? "dynamic" : "static"; const mode = isDynamic ? "dynamic" : "static";
const slug = isDynamic ? saved?.slug : undefined; const slug = (isDynamic ? saved?.slug : undefined) ?? undefined;
const requestBody = useMemo( const requestBody = useMemo(
() => ({ type: typeId, data, design, mode, slug }), // Der Name geht mit: Der Server bildet daraus den Dateinamen beim
[typeId, data, design, mode, slug], // Herunterladen. Auf die Vorschau hat er keinen Einfluss.
() => ({ type: typeId, data, design, mode, slug, label: label.trim() || undefined }),
[typeId, data, design, mode, slug, label],
); );
const ready = validation.ok && designValid; const ready = validation.ok && designValid;
@@ -164,22 +202,40 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
/* ----------------------------------------------- Dynamischen Code speichern */ /* ----------------------------------------------- Dynamischen Code speichern */
// Sofortige Rückmeldung, damit man nicht erst beim Speichern erfährt, dass
// ein Zeichen nicht erlaubt ist.
const slugProblem =
wunschSlug.trim().length > 0 ? describeSlugProblem(wunschSlug) : null;
const hasUnsavedChanges = const hasUnsavedChanges =
saved !== null && saved !== null &&
(!sameData(saved.data, validation.ok ? validation.data : data) || (!sameData(saved.data, validation.ok ? validation.data : data) ||
!sameData(saved.design, design) || !sameData(saved.design, design) ||
(saved.label ?? "") !== label.trim()); (saved.label ?? "") !== label.trim());
const handleSave = useCallback(async () => { // Nur zeigen, solange der Inhalt derselbe ist, für den die Meldung kam.
if (!validation.ok || !designValid) return; const dublette =
duplicate !== null &&
duplicate.inhalt === stableStringify(validation.ok ? validation.data : data)
? duplicate.treffer
: null;
const handleSave = useCallback(async (trotzDublette = false) => {
if (!validation.ok || !designValid || slugProblem) return;
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
setDuplicate(null);
try { try {
const body = { const body = {
type: typeId, type: typeId,
data: validation.data, data: validation.data,
design, design,
mode,
label: label.trim() || undefined, label: label.trim() || undefined,
// Ein statischer Code hat keinen Kurz-Link.
slug: isDynamic ? wunschSlug.trim() || undefined : undefined,
// Nur beim zweiten Anlauf, nachdem die Warnung dastand.
allowDuplicate: trotzDublette,
}; };
const result = saved const result = saved
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, { ? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
@@ -192,18 +248,40 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
}); });
setSaved(result.code); setSaved(result.code);
} catch (error) { } catch (error) {
setSaveError( // Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage.
error instanceof ApiError const gemeldet =
? error.message error instanceof ApiError && error.status === 409
: "Der Code konnte nicht gespeichert werden.", ? leseDublette(error.details)
); : null;
if (gemeldet) {
setDuplicate({ treffer: gemeldet, inhalt: stableStringify(validation.data) });
} else {
setSaveError(
error instanceof ApiError
? error.message
: "Der Code konnte nicht gespeichert werden.",
);
}
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [validation, designValid, typeId, design, label, saved]); }, [validation, designValid, slugProblem, typeId, design, mode, isDynamic, label, wunschSlug, saved]);
/**
* Wechselt die Betriebsart und vergisst dabei einen zuvor gespeicherten
* Code. Ein abgelegter statischer Eintrag ist etwas anderes als ein
* gespeicherter dynamischer — beides gleichzeitig festzuhalten führte
* dazu, dass die Oberfläche den falschen Zustand zeigt.
*/
const wechsleModus = useCallback((dynamisch: boolean) => {
setIsDynamic(dynamisch);
setSaved(null);
setSaveError(null);
setDuplicate(null);
}, []);
const handleCopy = useCallback(async () => { const handleCopy = useCallback(async () => {
if (!saved) return; if (!saved?.shortUrl) return;
try { try {
await navigator.clipboard.writeText(saved.shortUrl); await navigator.clipboard.writeText(saved.shortUrl);
setCopied(true); setCopied(true);
@@ -286,7 +364,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
data-mode="static" data-mode="static"
aria-pressed={!isDynamic} aria-pressed={!isDynamic}
disabled={!qrType.supportsStatic} disabled={!qrType.supportsStatic}
onClick={() => setIsDynamic(false)} onClick={() => wechsleModus(false)}
className={cx( className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50", "rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
!isDynamic !isDynamic
@@ -310,7 +388,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
data-mode="dynamic" data-mode="dynamic"
aria-pressed={isDynamic} aria-pressed={isDynamic}
disabled={!qrType.supportsDynamic} disabled={!qrType.supportsDynamic}
onClick={() => setIsDynamic(true)} onClick={() => wechsleModus(true)}
className={cx( className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50", "rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
isDynamic isDynamic
@@ -331,12 +409,15 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
</button> </button>
</div> </div>
{isDynamic ? ( <div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
<Field <Field
label="Name (optional)" label="Name (optional)"
htmlFor="code-label" htmlFor="code-label"
hint="Hilft dir, den Code später in „Meine Codes“ wiederzufinden." hint={
isDynamic
? "Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
: "Bestimmt den Dateinamen beim Herunterladen — und den Eintrag, falls du ihn ablegst."
}
> >
<TextInput <TextInput
id="code-label" id="code-label"
@@ -347,7 +428,86 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
/> />
</Field> </Field>
{saved ? ( {!isDynamic || saved || !maySetSlug ? null : (
<Field
label="Kurz-Link (optional)"
htmlFor="code-slug"
error={slugProblem}
hint="Leer lassen, dann wird einer erzeugt. Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
>
<TextInput
id="code-slug"
value={wunschSlug}
maxLength={100}
spellCheck={false}
invalid={Boolean(slugProblem)}
placeholder="z. B. sommeraktion oder kunde-a/flyer"
onChange={(event) => setWunschSlug(event.target.value)}
/>
</Field>
)}
{!isDynamic ? (
/* Statisch: Der Code ist ohne Speichern fertig. Ablegen ist
ein Angebot, kein Zwang — es geht nur ums Wiederfinden.
Nach einer Inhaltsänderung ist die Ablage ein Schnappschuss
von vorher; dann wird sie wieder angeboten. */
saved && !hasUnsavedChanges ? (
<div className="space-y-3">
<Notice tone="success">
<span className="font-medium">Abgelegt.</span> Du findest
den Code jetzt unter „Meine Codes“ und kannst ihn dort
erneut herunterladen. Der Code selbst ändert sich dadurch
nicht — sein Inhalt steckt in ihm.
</Notice>
<Link
href="/meine-codes"
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-accent-text hover:text-accent-soft-fg"
>
Zu „Meine Codes“
<Icon name="external" className="size-3.5" />
</Link>
</div>
) : (
<div className="space-y-3">
{dublette ? (
<Notice tone="warning">
<span className="font-medium">
Diesen Inhalt hast du schon abgelegt
</span>{" "}
— {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "}
vom {dublettenDatum.format(new Date(dublette.createdAt))}.
Zweimal derselbe Inhalt ergibt zweimal denselben Code.
<span className="mt-1 block text-xs">
Verglichen wird nur der Inhalt, nicht die Gestaltung —
derselbe Code in anderen Farben gilt hier als Dublette.
</span>
<span className="mt-2 flex flex-wrap items-center gap-3">
<Link
href="/meine-codes"
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium underline"
>
Den vorhandenen ansehen
<Icon name="external" className="size-3.5" />
</Link>
</span>
</Notice>
) : null}
<Button
type="button"
variant="secondary"
onClick={() => void handleSave(dublette !== null)}
disabled={saving || !ready}
>
{saving
? "Legt ab …"
: dublette
? "Trotzdem ablegen"
: "In „Meine Codes“ ablegen"}
</Button>
</div>
)
) : saved ? (
<div className="space-y-3"> <div className="space-y-3">
<Notice tone="success"> <Notice tone="success">
<span className="font-medium">Code gespeichert.</span> Der <span className="font-medium">Code gespeichert.</span> Der
@@ -375,7 +535,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
{hasUnsavedChanges ? ( {hasUnsavedChanges ? (
<Button <Button
type="button" type="button"
onClick={handleSave} onClick={() => void handleSave()}
disabled={saving || !ready} disabled={saving || !ready}
> >
{saving ? "Speichert …" : "Änderungen speichern"} {saving ? "Speichert …" : "Änderungen speichern"}
@@ -407,8 +567,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
</Notice> </Notice>
<Button <Button
type="button" type="button"
onClick={handleSave} onClick={() => void handleSave()}
disabled={saving || !ready} disabled={saving || !ready || Boolean(slugProblem)}
> >
{saving ? "Speichert …" : "Dynamischen Code speichern"} {saving ? "Speichert …" : "Dynamischen Code speichern"}
</Button> </Button>
@@ -416,8 +576,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
)} )}
{saveError ? <Notice tone="error">{saveError}</Notice> : null} {saveError ? <Notice tone="error">{saveError}</Notice> : null}
</div> </div>
) : null}
</Card> </Card>
</div> </div>
@@ -426,7 +585,11 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download."> <CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
Gestaltung Gestaltung
</CardTitle> </CardTitle>
<DesignPanel design={design} onChange={setDesign} /> <DesignPanel
design={design}
onChange={setDesign}
hasBrandingLogo={hasBrandingLogo}
/>
</Card> </Card>
</div> </div>
</div> </div>
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { Card, CardTitle, Checkbox, Notice } from "@/components/ui";
export type SettingsDto = {
editorsMaySetSlug: boolean;
};
export function SettingsAdmin({ initial }: { initial: SettingsDto }) {
const [wert, setWert] = useState(initial);
const [fehler, setFehler] = useState<string | null>(null);
const [hinweis, setHinweis] = useState<string | null>(null);
const [laeuft, setLaeuft] = useState(false);
// Bewusst ohne Speichern-Knopf: Ein einzelner Schalter, der erst nach einem
// zweiten Klick wirkt, ist eine Stolperfalle.
const umschalten = async (aktiv: boolean) => {
setLaeuft(true);
setFehler(null);
setHinweis(null);
const vorher = wert;
setWert({ ...wert, editorsMaySetSlug: aktiv });
try {
const antwort = await fetch("/api/admin/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ editorsMaySetSlug: aktiv }),
});
const rumpf = await antwort.json().catch(() => null);
if (!antwort.ok) {
throw new Error(rumpf?.error ?? "Speichern nicht möglich.");
}
setWert(rumpf.settings);
setHinweis(
aktiv
? "Bearbeiter dürfen Kurz-Links jetzt selbst vergeben und ändern."
: "Kurz-Links vergeben und ändern dürfen jetzt nur noch Administratoren.",
);
} catch (error) {
setWert(vorher);
setFehler(error instanceof Error ? error.message : String(error));
} finally {
setLaeuft(false);
}
};
return (
<div className="space-y-5">
{fehler ? <Notice tone="error">{fehler}</Notice> : null}
{hinweis ? <Notice tone="success">{hinweis}</Notice> : null}
<Card>
<CardTitle hint="Gilt für alle Gruppen dieser Installation.">
Kurz-Links
</CardTitle>
<Checkbox
id="editors-may-set-slug"
label="Bearbeiter dürfen den Kurz-Link selbst vergeben und ändern"
hint="Aus: Nur Administratoren. Bearbeiter legen Codes an, der Kurz-Link wird dabei erzeugt."
checked={wert.editorsMaySetSlug}
disabled={laeuft}
onChange={(event) => void umschalten(event.target.checked)}
/>
<div className="mt-4 space-y-2 text-sm text-slate-600">
<p>
Ein Kurz-Link ist eine öffentliche Adresse und steht auf gedruckten
Codes. Wer ihn ändert, kann damit Aushänge und Flyer entwerten —
die vorherige Adresse bleibt zwar gültig, lässt sich aber auch
wieder freigeben.
</p>
<p>
Deshalb ist das standardmäßig Administratoren vorbehalten. In einem
kleinen Team, in dem alle wissen, was ein Kurz-Link bedeutet, ist
die Freigabe unbedenklich.
</p>
</div>
</Card>
</div>
);
}
+111 -13
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useState } from "react";
import { Icon, type IconName } from "@/components/icons"; import { Icon, type IconName } from "@/components/icons";
import { Badge, Card, Notice, cx } from "@/components/ui"; import { Badge, Card, Notice, Select, cx } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client"; import { ApiError, apiJson } from "@/lib/api-client";
import type { QrTypeId } from "@/lib/qr/type-module"; import type { QrTypeId } from "@/lib/qr/type-module";
@@ -13,9 +13,19 @@ export type TypeSetting = {
description: string; description: string;
icon: IconName; icon: IconName;
implemented: boolean; implemented: boolean;
/** Freigegeben in der gerade gezeigten Sicht (global oder Gruppe). */
enabled: boolean; enabled: boolean;
/** Die globale Freigabe für sich genommen. */
enabledGlobally: boolean;
/** In der gezeigten Gruppe ausdrücklich abgewählt. */
disabledInGroup: boolean;
}; };
export type GroupOption = { id: string; name: string };
/** Die Grundeinstellung, wenn keine Gruppe gewählt ist. */
const GLOBAL = "global";
function Toggle({ function Toggle({
checked, checked,
disabled, disabled,
@@ -50,29 +60,71 @@ function Toggle({
); );
} }
export function TypeSettings({ initial }: { initial: TypeSetting[] }) { export function TypeSettings({
initial,
groups = [],
}: {
initial: TypeSetting[];
groups?: GroupOption[];
}) {
const [types, setTypes] = useState(initial); const [types, setTypes] = useState(initial);
const [scope, setScope] = useState<string>(GLOBAL);
const [pending, setPending] = useState<QrTypeId | null>(null); const [pending, setPending] = useState<QrTypeId | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const inGruppe = scope !== GLOBAL;
const gruppenname = groups.find((g) => g.id === scope)?.name ?? "";
const visibleCount = types.filter((entry) => entry.enabled).length; const visibleCount = types.filter((entry) => entry.enabled).length;
const usableCount = types.filter( const usableCount = types.filter(
(entry) => entry.enabled && entry.implemented, (entry) => entry.enabled && entry.implemented,
).length; ).length;
/** Sicht wechseln — die Zustände unterscheiden sich je Gruppe. */
const wechsle = useCallback(async (next: string) => {
setScope(next);
setLoading(true);
setError(null);
try {
const result = await apiJson<{ types: TypeSetting[] }>(
next === GLOBAL ? "/api/admin/types" : `/api/admin/types?group=${next}`,
);
setTypes(result.types);
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Freigaben konnten nicht geladen werden.",
);
} finally {
setLoading(false);
}
}, []);
const toggle = async (id: QrTypeId, enabled: boolean) => { const toggle = async (id: QrTypeId, enabled: boolean) => {
const before = types; const before = types;
// Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter // Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter
// soll sich nicht zäh anfühlen. // soll sich nicht zäh anfühlen.
setTypes((current) => setTypes((current) =>
current.map((entry) => (entry.id === id ? { ...entry, enabled } : entry)), current.map((entry) =>
entry.id === id
? {
...entry,
enabled,
...(inGruppe
? { disabledInGroup: !enabled }
: { enabledGlobally: enabled }),
}
: entry,
),
); );
setPending(id); setPending(id);
setError(null); setError(null);
try { try {
await apiJson("/api/admin/types", { await apiJson("/api/admin/types", {
method: "PATCH", method: "PATCH",
json: { type: id, enabled }, json: { type: id, enabled, ...(inGruppe ? { groupId: scope } : {}) },
}); });
} catch (caught) { } catch (caught) {
setTypes(before); setTypes(before);
@@ -90,6 +142,40 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
<div className="space-y-4"> <div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null} {error ? <Notice tone="error">{error}</Notice> : null}
{groups.length > 0 ? (
<div className="flex flex-wrap items-center gap-3">
<label
htmlFor="typen-gruppe"
className="text-sm font-medium text-slate-700"
>
Freigabe für
</label>
<Select
id="typen-gruppe"
className="!w-auto"
value={scope}
disabled={loading}
onChange={(event) => void wechsle(event.target.value)}
>
<option value={GLOBAL}>Alle Gruppen (Grundeinstellung)</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
Nur: {group.name}
</option>
))}
</Select>
</div>
) : null}
{inGruppe ? (
<Notice tone="info">
Du siehst die Freigabe für <strong>{gruppenname}</strong>. Was hier
abgeschaltet ist, gilt nur für diese Gruppe. Ein Typ, der in der
Grundeinstellung aus ist, lässt sich hier nicht einschalten — die
globale Abschaltung gewinnt immer.
</Notice>
) : null}
{visibleCount === 0 ? ( {visibleCount === 0 ? (
<Notice tone="warning"> <Notice tone="warning">
Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es
@@ -138,17 +224,28 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
)} )}
</div> </div>
<p className="mt-0.5 text-sm text-slate-500"> <p className="mt-0.5 text-sm text-slate-500">
{entry.enabled {inGruppe && !entry.enabledGlobally
? entry.implemented ? "In der Grundeinstellung abgeschaltet — für diese Gruppe nicht einschaltbar."
? "Erscheint auf der Startseite und kann verwendet werden." : entry.enabled
: "Erscheint auf der Startseite als „Bald verfügbar“." ? entry.implemented
: "Ausgeblendet — erscheint nicht auf der Startseite."} ? inGruppe
? `Steht ${gruppenname} zur Verfügung.`
: "Erscheint auf der Startseite und kann verwendet werden."
: "Erscheint auf der Startseite als „Bald verfügbar“."
: inGruppe
? `Für ${gruppenname} abgeschaltet — andere Gruppen bleiben unberührt.`
: "Ausgeblendet — erscheint nicht auf der Startseite."}
</p> </p>
</div> </div>
<Toggle <Toggle
checked={entry.enabled} checked={entry.enabled}
disabled={pending === entry.id} disabled={
pending === entry.id ||
loading ||
// Global aus gewinnt immer — der Schalter steht dann still.
(inGruppe && !entry.enabledGlobally)
}
label={`${entry.title} ${entry.enabled ? "ausblenden" : "freigeben"}`} label={`${entry.title} ${entry.enabled ? "ausblenden" : "freigeben"}`}
onChange={(next) => void toggle(entry.id, next)} onChange={(next) => void toggle(entry.id, next)}
/> />
@@ -158,8 +255,9 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
</Card> </Card>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
{usableCount} von {types.length} Typen nutzbar, {visibleCount} auf der {inGruppe
Startseite sichtbar. ? `${usableCount} von ${types.length} Typen stehen ${gruppenname} zur Verfügung.`
: `${usableCount} von ${types.length} Typen nutzbar, ${visibleCount} auf der Startseite sichtbar.`}
</p> </p>
</div> </div>
); );
+33 -14
View File
@@ -1,23 +1,42 @@
/** Kleine Hilfen für Aufrufe der eigenen API aus Client-Komponenten. */ /** Kleine Hilfen für Aufrufe der eigenen API aus Client-Komponenten. */
async function readError(response: Response): Promise<string> { async function readError(response: Response): Promise<ApiError> {
let body: unknown = null;
try { try {
const body: unknown = await response.json(); body = await response.json();
if (
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
) {
return (body as { error: string }).error;
}
} catch { } catch {
// Antwort ohne JSON-Körper — unten kommt die allgemeine Meldung. // Antwort ohne JSON-Körper — unten kommt die allgemeine Meldung.
} }
return `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`;
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`;
return new ApiError(message, response.status, body);
} }
export class ApiError extends Error {} /**
* Fehler einer eigenen API-Antwort.
*
* Trägt neben der Meldung auch den Status und den Antwortkörper: Manche
* Fehler sind keine Sackgasse, sondern eine Rückfrage — etwa die gemeldete
* Dublette beim Ablegen, zu der die Oberfläche den vorhandenen Eintrag
* anzeigen will.
*/
export class ApiError extends Error {
readonly status: number;
readonly details: unknown;
constructor(message: string, status = 0, details: unknown = null) {
super(message);
this.status = status;
this.details = details;
}
}
export async function apiJson<T>( export async function apiJson<T>(
url: string, url: string,
@@ -34,7 +53,7 @@ export async function apiJson<T>(
}); });
if (!response.ok) { if (!response.ok) {
throw new ApiError(await readError(response)); throw await readError(response);
} }
if (response.status === 204) { if (response.status === 204) {
return undefined as T; return undefined as T;
@@ -55,7 +74,7 @@ export async function downloadFile(
}); });
if (!response.ok) { if (!response.ok) {
throw new ApiError(await readError(response)); throw await readError(response);
} }
const disposition = response.headers.get("Content-Disposition") ?? ""; const disposition = response.headers.get("Content-Disposition") ?? "";
+52
View File
@@ -96,6 +96,58 @@ export function redirectUri(): string {
return `${getBaseUrl()}/api/auth/oidc/callback`; return `${getBaseUrl()}/api/auth/oidc/callback`;
} }
/**
* Prüft, ob der Browser über dieselbe Adresse kommt, auf die `BASE_URL` zeigt.
*
* Warum das zwingend ist: Der Aufruf setzt einen Cookie mit dem laufenden
* Vorgang — und zwar auf dem Host, über den der Browser gerade da ist. Die
* Rückkehradresse für den Anbieter kommt dagegen aus `BASE_URL`. Weichen die
* beiden voneinander ab, schickt der Anbieter den Browser auf einen anderen
* Host, der Cookie geht dabei verloren, und die Anmeldung endet mit
* „Der Anmeldevorgang ist abgelaufen“ — einer Meldung, die die eigentliche
* Ursache gut versteckt.
*
* Deshalb hier eine frühe Prüfung mit einer Meldung, die beide Adressen nennt.
*/
export function assertHostMatchesBaseUrl(headers: Headers): void {
const angekommen = (
headers.get("x-forwarded-host") ??
headers.get("host") ??
""
)
.split(",")[0]
.trim()
.toLowerCase();
if (!angekommen) return;
let erwartet = "";
try {
erwartet = new URL(getBaseUrl()).host.toLowerCase();
} catch {
return; // Unbrauchbare BASE_URL — dafür gibt es eigene Meldungen.
}
if (angekommen === erwartet) return;
// Ins Protokoll gehören beide Adressen — dort liest nur mit, wer den Server
// bedient. Auf dem Bildschirm steht bewusst nur die, über die der Aufruf
// ohnehin kam: Die Anmeldeseite sieht jeder, der die Adresse kennt, und die
// interne Adresse der Installation geht Fremde nichts an.
console.error(
`OIDC: Host-Abweichung — Aufruf über "${angekommen}", BASE_URL zeigt auf "${erwartet}".`,
);
throw new QrRequestError(
`Die Anmeldung über den Anbieter ist für „${angekommen}" nicht eingerichtet. ` +
`In der Konfiguration steht eine andere Adresse, deshalb ginge der ` +
`Anmeldevorgang auf dem Rückweg verloren. Wer die Anwendung betreibt, ` +
`findet den erwarteten Wert unter BASE_URL — und die Einzelheiten im ` +
`Protokoll des Containers.`,
400,
);
}
type Discovery = { type Discovery = {
issuer: string; issuer: string;
authorization_endpoint: string; authorization_endpoint: string;
+27 -1
View File
@@ -21,12 +21,30 @@ export type Action =
| "codes:update" | "codes:update"
/** Codes löschen. */ /** Codes löschen. */
| "codes:delete" | "codes:delete"
/**
* Den Kurz-Link selbst vergeben oder ändern.
*
* Eigene Aktion und nicht Teil von `codes:update`, weil sie mehr wiegt: Ein
* Kurz-Link ist eine öffentliche Adresse. Wer ihn ändert, entwertet im
* Zweifel gedruckte Codes; wer ihn vergibt, besetzt eine Adresse, die
* jemand anderes gebraucht hätte. Deshalb standardmäßig nur für
* Administratoren — freigeben lässt es sich in der Verwaltung.
*/
| "codes:slug"
/** Mitglieder und Rollen einer Gruppe verwalten. */ /** Mitglieder und Rollen einer Gruppe verwalten. */
| "group:manage" | "group:manage"
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */ /** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
| "instance:manage"; | "instance:manage";
export type Scope = { groupId?: string }; export type Scope = {
groupId?: string;
/**
* Die Einstellung aus der Verwaltung, ob Bearbeiter Kurz-Links vergeben
* dürfen. Wird von der aufrufenden Stelle mitgegeben, damit die Regeln hier
* ohne Datenbankzugriff auskommen und prüfbar bleiben.
*/
editorsMaySetSlug?: boolean;
};
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 }; const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
@@ -89,6 +107,12 @@ export function can(actor: Actor, action: Action, scope: Scope = {}): boolean {
case "codes:update": case "codes:update":
case "codes:delete": case "codes:delete":
return ROLE_RANK[role] >= ROLE_RANK.EDITOR; return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
case "codes:slug":
// Administratoren der Gruppe immer, Bearbeiter nur nach Freigabe.
if (role === "ADMIN") return true;
return (
ROLE_RANK[role] >= ROLE_RANK.EDITOR && scope.editorsMaySetSlug === true
);
case "group:manage": case "group:manage":
return role === "ADMIN"; return role === "ADMIN";
default: default:
@@ -104,6 +128,8 @@ const DENIED_MESSAGES: Record<Action, string> = {
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.", "Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"codes:delete": "codes:delete":
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.", "Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"codes:slug":
"Den Kurz-Link darf hier nur ein Administrator vergeben oder ändern. Ein Administrator der Installation kann das unter Verwaltung → Einstellungen für Bearbeiter freigeben.",
"group:manage": "group:manage":
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.", "Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
"instance:manage": "instance:manage":
+139
View File
@@ -0,0 +1,139 @@
import pkg from "../../package.json";
/**
* Änderungsverlauf der Anwendung — die einzige Quelle dafür.
*
* Hieraus entsteht beides: die Seite unter `/version` und die `CHANGELOG.md`
* im Wurzelverzeichnis (`npm run changelog`). Zwei gepflegte Listen liefen
* unweigerlich auseinander; eine Prüfung wacht darüber, dass die erzeugte
* Datei zu dieser Quelle passt.
*
* Die Einträge bis 1.5.0 sind aus dem Abschnitt „Gebaut“ der Roadmap und der
* Git-Historie zusammengetragen: Versionsnummern gab es damals nicht, deshalb
* ist je **Erscheinungstag** eine gebildet. Nachträglich vergeben, aber die
* Daten selbst stimmen. Ab 1.6.0 trägt jede Fassung einen Git-Tag.
*/
export type ChangelogEntry = {
version: string;
/** ISO-Datum, an dem der Stand entstanden ist. */
date: string;
/** Eine Zeile zur Einordnung — was diese Version ausmacht. */
summary: string;
changes: string[];
};
export const CHANGELOG: readonly ChangelogEntry[] = [
{
version: "1.7.0",
date: "2026-08-21",
summary: "Logo im Code, Freigabe je Gruppe und ein Domain-Check.",
changes: [
"Ein Logo in der Mitte des Codes — das der Installation oder ein eigenes je Code, 10 bis 25 % der Kantenlänge. Die Fehlerkorrektur springt dabei auf H.",
"QR-Typen lassen sich je Gruppe freigeben. Die Grundeinstellung bleibt die Grundlage; eine globale Abschaltung gewinnt immer.",
"Der Knopf „Prüfen“ bei jeder Domain meldet DNS, Zertifikat, Erreichbarkeit — und ob dahinter wirklich diese Installation steht.",
"Eine Kurzhilfe auf der Domain-Seite führt in sechs Schritten auf, was für eine neue Domain einzurichten ist.",
"„Meine Codes“ lässt sich nach Art filtern: alle, nur dynamische, nur statische.",
"Beim Ablegen eines statischen Codes wird gewarnt, wenn derselbe Inhalt schon in der Gruppe liegt.",
],
},
{
version: "1.6.0",
date: "2026-08-21",
summary: "Sichtbare Version mit Änderungsverlauf.",
changes: [
"Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.",
"Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.",
"Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.",
],
},
{
version: "1.5.0",
date: "2026-08-20",
summary: "Statische Codes lassen sich benennen und ablegen.",
changes: [
"Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.",
"Der Name bestimmt den Dateinamen beim Herunterladen.",
"Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.",
],
},
{
version: "1.4.0",
date: "2026-08-17",
summary: "Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.",
changes: [
"Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.",
"Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.",
"Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.",
"Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.",
"Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.",
],
},
{
version: "1.3.0",
date: "2026-08-16",
summary: "Eigenes Erscheinungsbild und ein Dark Mode.",
changes: [
"Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.",
"Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.",
"Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.",
"Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.",
],
},
{
version: "1.2.0",
date: "2026-08-15",
summary: "Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.",
changes: [
"Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.",
"Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.",
"Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.",
"Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.",
],
},
{
version: "1.1.0",
date: "2026-08-14",
summary: "Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.",
changes: [
"Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.",
"Landingpages als eigene Gattung für Typen ohne fremdes Ziel.",
"Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.",
"Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.",
"Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.",
"„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.",
"Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.",
],
},
{
version: "1.0.0",
date: "2026-08-13",
summary: "Erste lauffähige Fassung.",
changes: [
"Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.",
"QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.",
"Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.",
"Download als PNG und SVG, serverseitig gerendert.",
"Update-Skript für Linux, macOS und Windows.",
],
},
];
/**
* Die laufende Version.
*
* Aus der `package.json`, damit es genau eine Stelle gibt, an der sie steht.
* Der Wert wird beim Bauen eingesetzt — im Container liegt kein Git, aus dem
* sich sonst etwas ableiten ließe.
*/
export const APP_VERSION: string = pkg.version;
/** Der Eintrag zur laufenden Version, falls er im Verlauf steht. */
export function currentEntry(): ChangelogEntry | undefined {
return CHANGELOG.find((eintrag) => eintrag.version === APP_VERSION);
}
/** Datum als „21.08.2026“ — ohne Abhängigkeit von der Zeitzone des Servers. */
export function formatChangelogDate(iso: string): string {
const [jahr, monat, tag] = iso.split("-");
return `${tag}.${monat}.${jahr}`;
}
+19 -5
View File
@@ -3,7 +3,7 @@ import "server-only";
import type { Prisma, QrCode } from "@/generated/prisma/client"; import type { Prisma, QrCode } from "@/generated/prisma/client";
import type { QrCodeDto } from "@/lib/dto"; import type { QrCodeDto } from "@/lib/dto";
import { landingUrl, shortUrl } from "@/lib/env"; import { landingUrl, shortUrl } from "@/lib/env";
import type { EcLevel } from "@/lib/qr/design"; import type { EcLevel, LogoSource } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import type { QrTypeId } from "@/lib/qr/type-module"; import type { QrTypeId } from "@/lib/qr/type-module";
@@ -31,22 +31,31 @@ export type QrCodeWithRelations = QrCode & {
group?: { name: string } | null; group?: { name: string } | null;
domain?: { hostname: string } | null; domain?: { hostname: string } | null;
createdBy?: { name: string | null; email: string } | null; createdBy?: { name: string | null; email: string } | null;
aliases?: { slug: string }[];
}; };
export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto { export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
const data = asRecord(code.data); const data = asRecord(code.data);
const qrType = getQrType(code.type); const qrType = getQrType(code.type);
const istStatisch = code.mode === "static";
let target = ""; let target = "";
let summary = ""; let summary = "";
if (qrType) { if (qrType) {
try { try {
// Ein statischer Code führt nirgendwohin — sein Inhalt steckt in ihm
// selbst. Als „Ziel“ bleibt deshalb nichts übrig; die Zusammenfassung
// sagt trotzdem, was drinsteht.
// Typen mit eigener Landingpage führen auf eine Seite dieser Anwendung. // Typen mit eigener Landingpage führen auf eine Seite dieser Anwendung.
// Die ist als „Ziel“ das Ehrlichste — und obendrein anklickbar, um sie // Die ist als „Ziel“ das Ehrlichste — und obendrein anklickbar, um sie
// sich anzusehen. // sich anzusehen.
target = qrType.hasLandingPage target =
? landingUrl(code.slug, code.domain?.hostname) istStatisch || !code.slug
: qrType.redirectTarget(data); ? ""
: qrType.hasLandingPage
? landingUrl(code.slug, code.domain?.hostname)
: qrType.redirectTarget(data);
summary = qrType.summary(data); summary = qrType.summary(data);
} catch { } catch {
// Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem // Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem
@@ -59,11 +68,13 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
return { return {
id: code.id, id: code.id,
mode: istStatisch ? "static" : "dynamic",
slug: code.slug, slug: code.slug,
aliases: (code.aliases ?? []).map((alias) => alias.slug),
type: code.type as QrTypeId, type: code.type as QrTypeId,
label: code.label, label: code.label,
data, data,
shortUrl: shortUrl(code.slug, code.domain?.hostname), shortUrl: code.slug ? shortUrl(code.slug, code.domain?.hostname) : null,
target, target,
summary, summary,
design: { design: {
@@ -72,6 +83,9 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
size: code.size, size: code.size,
ecLevel: code.ecLevel as EcLevel, ecLevel: code.ecLevel as EcLevel,
margin: code.margin, margin: code.margin,
logoSource: code.logoSource as LogoSource | null,
logoUploadId: code.logoUploadId,
logoScale: code.logoScale,
}, },
scans: code.scans, scans: code.scans,
lastScanAt: code.lastScanAt?.toISOString() ?? null, lastScanAt: code.lastScanAt?.toISOString() ?? null,
+478
View File
@@ -0,0 +1,478 @@
import "server-only";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { lookup } from "node:dns/promises";
import { connect, type PeerCertificate } from "node:tls";
import { prisma } from "@/lib/db";
/**
* Domain-Check: Führt die Domain wirklich hierher?
*
* Eine Domain zu registrieren ist der kleinste Teil der Arbeit — DNS, Reverse
* Proxy und Zertifikat liegen außerhalb dieser Anwendung, und wenn dort etwas
* fehlt, merkt man es erst am gedruckten Code, der nicht aufgeht.
*
* Die Prüfung geht deshalb den Weg der Scanner nach: Namen auflösen, das
* Zertifikat ansehen, die Adresse aufrufen — und sich beweisen lassen, dass
* am anderen Ende **diese** Installation steht. Der Beweis ist ein HMAC über
* eine frische Zufallszahl; das Geheimnis dafür verlässt den Server nie.
*
* Die Grenze der Aussage steht in der Meldung selbst: Der Aufruf startet dort,
* wo dieser Container läuft. Wer internes DNS oder Hairpin-NAT hat, bekommt
* hier womöglich einen Fehler, obwohl von außen alles stimmt.
*/
export type SchrittName = "dns" | "erreichbar" | "zertifikat" | "ziel";
export type SchrittStatus = "ok" | "warnung" | "fehler" | "uebersprungen";
export type Pruefschritt = {
name: SchrittName;
titel: string;
status: SchrittStatus;
detail: string;
};
export type Pruefbericht = {
hostname: string;
/** Die Adresse, die tatsächlich aufgerufen wurde. */
geprueft: string;
gesamt: "ok" | "warnung" | "fehler";
schritte: Pruefschritt[];
};
/** Antwort der öffentlichen Beweis-Route. */
export type Domainbeweis = { app: string; proof: string };
const APP_KENNUNG = "qrcode-generator";
const ZEITGRENZE_MS = 8000;
/* ------------------------------------------------------------- Geheimnis */
/**
* Das Geheimnis für den Beweis — beim ersten Aufruf erzeugt.
*
* Bewusst in der Datenbank und nicht im Arbeitsspeicher: Die prüfende Route
* und die antwortende Route sind zwei getrennte Bündel und teilen sich keinen
* Modulzustand. Ein Wert im Speicher wäre auf beiden Seiten ein anderer.
*/
export async function getCheckSecret(): Promise<string> {
const vorhanden = await prisma.instanceSetting.findUnique({
where: { id: "instance" },
select: { checkSecret: true },
});
if (vorhanden?.checkSecret) return vorhanden.checkSecret;
const neu = randomBytes(32).toString("hex");
// `updateMany` mit der Bedingung „noch leer“ statt eines schlichten
// `update`: Zwei gleichzeitige Prüfungen sollen nicht zwei verschiedene
// Geheimnisse hinterlassen, von denen eines sofort wieder ungültig ist.
await prisma.instanceSetting.upsert({
where: { id: "instance" },
create: { id: "instance", checkSecret: neu },
update: {},
});
await prisma.instanceSetting.updateMany({
where: { id: "instance", checkSecret: null },
data: { checkSecret: neu },
});
const danach = await prisma.instanceSetting.findUnique({
where: { id: "instance" },
select: { checkSecret: true },
});
return danach?.checkSecret ?? neu;
}
export function bildeBeweis(secret: string, nonce: string): string {
return createHmac("sha256", secret).update(nonce).digest("hex");
}
function gleich(a: string, b: string): boolean {
const links = Buffer.from(a, "utf8");
const rechts = Buffer.from(b, "utf8");
if (links.length !== rechts.length) return false;
return timingSafeEqual(links, rechts);
}
/* ------------------------------------------------------------------ DNS */
function trenneHost(hostname: string): { host: string; port: number | null } {
const treffer = /^(.+):(\d{1,5})$/.exec(hostname);
if (!treffer) return { host: hostname, port: null };
return { host: treffer[1], port: Number(treffer[2]) };
}
async function pruefeDns(host: string): Promise<Pruefschritt> {
try {
const adressen = await lookup(host, { all: true });
if (adressen.length === 0) {
return {
name: "dns",
titel: "DNS",
status: "fehler",
detail: `Für ${host} ist keine Adresse hinterlegt.`,
};
}
return {
name: "dns",
titel: "DNS",
status: "ok",
detail: `löst auf ${adressen.map((a) => a.address).join(", ")}`,
};
} catch (error) {
const code = (error as { code?: string }).code;
return {
name: "dns",
titel: "DNS",
status: "fehler",
detail:
code === "ENOTFOUND"
? `Der Name ${host} ist unbekannt — es fehlt der A- oder AAAA-Eintrag.`
: `Der Name ${host} ließ sich nicht auflösen (${code ?? "unbekannter Fehler"}).`,
};
}
}
/* ----------------------------------------------------------- Zertifikat */
type Zertifikatslage =
| { erreichbar: true; schritt: Pruefschritt }
| { erreichbar: false; schritt: Pruefschritt };
const datumsformat = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
function beschreibeZertifikat(
zertifikat: PeerCertificate,
authorized: boolean,
fehler: string | undefined,
): Pruefschritt {
const bis = new Date(zertifikat.valid_to);
const tage = Math.floor((bis.getTime() - Date.now()) / 86_400_000);
// Die Felder eines Zertifikats dürfen mehrfach vorkommen; Node liefert sie
// dann als Liste.
const ersteZeile = (wert: string | string[] | undefined): string | undefined =>
Array.isArray(wert) ? wert[0] : wert;
const aussteller =
ersteZeile(zertifikat.issuer?.O) ??
ersteZeile(zertifikat.issuer?.CN) ??
"unbekannt";
if (!authorized) {
return {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail: uebersetzeTlsFehler(fehler, aussteller, bis),
};
}
if (tage < 0) {
return {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail: `abgelaufen am ${datumsformat.format(bis)}`,
};
}
return {
name: "zertifikat",
titel: "Zertifikat",
status: tage <= 14 ? "warnung" : "ok",
detail:
tage <= 14
? `läuft in ${tage} Tagen ab (${datumsformat.format(bis)}), ausgestellt von ${aussteller}`
: `gültig bis ${datumsformat.format(bis)}, ausgestellt von ${aussteller}`,
};
}
function uebersetzeTlsFehler(
fehler: string | undefined,
aussteller: string,
bis: Date,
): string {
switch (fehler) {
case "CERT_HAS_EXPIRED":
return `abgelaufen am ${datumsformat.format(bis)}`;
case "DEPTH_ZERO_SELF_SIGNED_CERT":
case "SELF_SIGNED_CERT_IN_CHAIN":
return "selbst ausgestellt — Scanner-Apps brechen darauf ohne Erklärung ab";
case "ERR_TLS_CERT_ALTNAME_INVALID":
return "gilt für einen anderen Namen — im Proxy ist die falsche Domain hinterlegt";
case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
return `Zwischenzertifikat fehlt — der Proxy liefert die Kette unvollständig aus (${aussteller})`;
default:
return `nicht gültig (${fehler ?? "unbekannter Grund"})`;
}
}
/**
* Der Grund, aus dem ein Zertifikat durchfiel.
*
* Node liefert ihn je nach Fassung als Fehlerobjekt mit `code`, als
* Fehlerobjekt ohne `code` oder schlicht als Zeichenkette.
*/
function tlsGrund(fehler: unknown): string | undefined {
if (fehler === null || fehler === undefined) return undefined;
if (typeof fehler === "string") return fehler;
const objekt = fehler as { code?: unknown; message?: unknown };
if (typeof objekt.code === "string") return objekt.code;
if (typeof objekt.message === "string") return objekt.message;
return undefined;
}
async function pruefeZertifikat(host: string): Promise<Zertifikatslage> {
return new Promise((aufloesen) => {
// `rejectUnauthorized: false`, um ein schlechtes Zertifikat **beschreiben**
// zu können statt nur an ihm zu scheitern. Übertragen wird hierüber nichts.
const socket = connect({
host,
port: 443,
servername: host,
rejectUnauthorized: false,
timeout: ZEITGRENZE_MS,
});
const fertig = (lage: Zertifikatslage) => {
socket.removeAllListeners();
socket.destroy();
aufloesen(lage);
};
socket.once("secureConnect", () => {
fertig({
erreichbar: true,
schritt: beschreibeZertifikat(
socket.getPeerCertificate(),
socket.authorized,
tlsGrund(socket.authorizationError),
),
});
});
socket.once("timeout", () => {
fertig({
erreichbar: false,
schritt: {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail: "Auf Port 443 kam keine Antwort — läuft dort ein Webserver?",
},
});
});
socket.once("error", (error: NodeJS.ErrnoException) => {
fertig({
erreichbar: false,
schritt: {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail:
error.code === "ECONNREFUSED"
? "Port 443 nimmt keine Verbindung an — es gibt hier kein HTTPS."
: `Die TLS-Verbindung scheiterte (${error.code ?? error.message}).`,
},
});
});
});
}
/* --------------------------------------------------------------- Aufruf */
type Abrufergebnis = {
erreichbar: Pruefschritt;
ziel: Pruefschritt;
};
/**
* Der eigentliche Grund hinter einem `fetch failed`.
*
* Node verpackt Netzwerkfehler; die Meldung an der Oberfläche lautet sonst
* schlicht „fetch failed" und hilft niemandem weiter.
*/
function ursache(error: unknown): string | undefined {
const grund = (error as { cause?: unknown }).cause;
if (grund === null || grund === undefined) return undefined;
const objekt = grund as { code?: unknown; message?: unknown };
if (typeof objekt.code === "string") return objekt.code;
if (typeof objekt.message === "string") return objekt.message;
return undefined;
}
async function rufeAuf(
basis: string,
nonce: string,
secret: string,
): Promise<Abrufergebnis> {
const start = Date.now();
try {
const antwort = await fetch(`${basis}/api/domain-check?nonce=${nonce}`, {
redirect: "follow",
cache: "no-store",
signal: AbortSignal.timeout(ZEITGRENZE_MS),
headers: { "User-Agent": `${APP_KENNUNG}-domain-check` },
});
const dauer = Date.now() - start;
if (!antwort.ok) {
return {
erreichbar: {
name: "erreichbar",
titel: "Erreichbar",
status: "fehler",
detail: `HTTP ${antwort.status} nach ${dauer} ms`,
},
ziel: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail:
antwort.status === 404
? "Es antwortet zwar etwas, aber nicht diese Anwendung — der Proxy leitet woandershin."
: "Wegen der Fehlerantwort nicht feststellbar.",
},
};
}
const erreichbar: Pruefschritt = {
name: "erreichbar",
titel: "Erreichbar",
status: dauer > 3000 ? "warnung" : "ok",
detail: `HTTP ${antwort.status} nach ${dauer} ms`,
};
const koerper = (await antwort.json().catch(() => null)) as
| Partial<Domainbeweis>
| null;
if (
koerper === null ||
koerper.app !== APP_KENNUNG ||
typeof koerper.proof !== "string"
) {
return {
erreichbar,
ziel: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail:
"Die Antwort stammt nicht von dieser Anwendung — hinter der Domain steht etwas anderes.",
},
};
}
return {
erreichbar,
ziel: gleich(koerper.proof, bildeBeweis(secret, nonce))
? {
name: "ziel",
titel: "Ziel",
status: "ok",
detail: "diese Installation",
}
: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail:
"Es antwortet eine andere Installation dieser Anwendung, nicht diese hier.",
},
};
} catch (error) {
const code = (error as { code?: string; name?: string }).code;
const name = (error as { name?: string }).name;
const grund =
name === "TimeoutError"
? `Keine Antwort binnen ${ZEITGRENZE_MS / 1000} Sekunden.`
: code === "ECONNREFUSED"
? "Die Verbindung wurde abgelehnt — dort nimmt kein Webserver an."
: `Der Aufruf scheiterte (${code ?? ursache(error) ?? (error as Error).message}).`;
return {
erreichbar: {
name: "erreichbar",
titel: "Erreichbar",
status: "fehler",
detail: grund,
},
ziel: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail: "Ohne Antwort nicht feststellbar.",
},
};
}
}
/* ---------------------------------------------------------------- Ganzes */
/**
* Prüft eine registrierte Domain von diesem Server aus.
*
* Der Hostname stammt aus der Datenbank, nicht aus der Anfrage — es lässt
* sich hierüber also keine beliebige Adresse abrufen.
*/
export async function pruefeDomain(hostname: string): Promise<Pruefbericht> {
const { host, port } = trenneHost(hostname);
const secret = await getCheckSecret();
const nonce = randomBytes(16).toString("hex");
const schritte: Pruefschritt[] = [];
const dns = await pruefeDns(host);
schritte.push(dns);
// Ohne aufgelösten Namen gibt es nichts anzurufen. Die Folgeschritte
// trotzdem zu versuchen brächte drei weitere Fehlermeldungen, die alle
// dasselbe sagen — und die eine, auf die es ankommt, ginge darin unter.
if (dns.status === "fehler") {
for (const [name, titel] of [
["zertifikat", "Zertifikat"],
["erreichbar", "Erreichbar"],
["ziel", "Ziel"],
] as [SchrittName, string][]) {
schritte.push({
name,
titel,
status: "uebersprungen",
detail: "Ohne aufgelösten Namen nicht prüfbar.",
});
}
return { hostname, geprueft: host, gesamt: "fehler", schritte };
}
// Ein Eintrag mit Port meint eine Adresse ohne Reverse Proxy — etwa die
// Standard-Domain aus BASE_URL im Entwicklungsbetrieb. Dort gibt es kein
// Zertifikat zu prüfen, und der Aufruf läuft über http.
let basis: string;
if (port !== null) {
schritte.push({
name: "zertifikat",
titel: "Zertifikat",
status: "uebersprungen",
detail: `Der Eintrag nennt Port ${port} — geprüft wird ohne HTTPS.`,
});
basis = `http://${host}:${port}`;
} else {
const lage = await pruefeZertifikat(host);
schritte.push(lage.schritt);
// Ohne HTTPS lohnt der zweite Anlauf über Port 80: So steht im Bericht,
// ob überhaupt etwas antwortet — das ist der Unterschied zwischen
// „Zertifikat fehlt" und „da ist gar nichts".
basis = lage.erreichbar ? `https://${host}` : `http://${host}`;
}
const { erreichbar, ziel } = await rufeAuf(basis, nonce, secret);
schritte.push(erreichbar, ziel);
const gesamt = schritte.some((s) => s.status === "fehler")
? "fehler"
: schritte.some((s) => s.status === "warnung")
? "warnung"
: "ok";
return { hostname, geprueft: basis, gesamt, schritte };
}
+21 -6
View File
@@ -1,19 +1,31 @@
import type { EcLevel } from "@/lib/qr/design"; import type { EcLevel, LogoSource } from "@/lib/qr/design";
import type { QrTypeId } from "@/lib/qr/type-module"; import type { QrTypeId } from "@/lib/qr/type-module";
/** Statisch oder dynamisch — bestimmt, was ein Code überhaupt kann. */
export type QrCodeMode = "static" | "dynamic";
/** /**
* Darstellung eines dynamischen Codes an der API-Grenze. Bewusst frei von * Darstellung eines gespeicherten Codes an der API-Grenze. Bewusst frei von
* Server-Abhängigkeiten, damit Client-Komponenten den Typ verwenden können. * Server-Abhängigkeiten, damit Client-Komponenten den Typ verwenden können.
*/ */
export type QrCodeDto = { export type QrCodeDto = {
id: string; id: string;
slug: string; /**
* Ein statischer Code trägt seinen Inhalt selbst. Er ist nur abgelegt, damit
* man ihn wiederfindet und erneut herunterladen kann — Kurz-Link, Ziel und
* Scan-Zähler bleiben deshalb leer.
*/
mode: QrCodeMode;
/** Kurz-Link. Leer bei statischen Codes. */
slug: string | null;
/** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */
aliases: string[];
type: QrTypeId; type: QrTypeId;
label: string | null; label: string | null;
data: Record<string, unknown>; data: Record<string, unknown>;
/** Die Kurz-URL, die im QR-Code steckt. */ /** Die Kurz-URL, die im QR-Code steckt. Leer bei statischen Codes. */
shortUrl: string; shortUrl: string | null;
/** Aktuelles Weiterleitungsziel. */ /** Aktuelles Weiterleitungsziel. Leer bei statischen Codes. */
target: string; target: string;
/** Lesbare Kurzfassung des Ziels. */ /** Lesbare Kurzfassung des Ziels. */
summary: string; summary: string;
@@ -23,6 +35,9 @@ export type QrCodeDto = {
size: number; size: number;
ecLevel: EcLevel; ecLevel: EcLevel;
margin: number; margin: number;
logoSource: LogoSource | null;
logoUploadId: string | null;
logoScale: number;
}; };
scans: number; scans: number;
lastScanAt: string | null; lastScanAt: string | null;
+61
View File
@@ -0,0 +1,61 @@
import "server-only";
import { toJsonInput } from "@/lib/codes";
import { prisma } from "@/lib/db";
/**
* Dublettenprüfung für statische Codes.
*
* Ein statischer Code trägt seinen Inhalt selbst — zweimal derselbe Inhalt
* ergibt zweimal exakt dasselbe Bild. Solche Einträge sind nicht falsch, aber
* sie fluten die Liste, und beim Wiederfinden weiß niemand mehr, welcher der
* beiden gemeint war.
*
* Bewusst **nur** für statische Codes: Zwei dynamische Codes mit demselben
* Ziel sind ein üblicher Fall — zwei Kampagnen, zwei Kurz-Links, getrennte
* Zähler. Da wäre eine Warnung nur lästig.
*
* Bewusst **ohne** die Gestaltung: Verglichen wird der Inhalt. Derselbe Inhalt
* einmal in Schwarz-Weiß für den Druck und einmal in Hausfarben ist zwar
* bewusst zweimal angelegt — aber eben auch zweimal derselbe Code. Die
* Warnung sagt das und lässt sich übergehen.
*/
export type StaticDuplicate = {
id: string;
label: string | null;
createdAt: string;
};
/**
* Sucht einen bereits abgelegten statischen Code mit gleichem Typ und
* gleichem Inhalt in derselben Gruppe — den zuletzt angelegten.
*
* Die Gruppe grenzt bewusst ein: Ein Treffer aus einer fremden Gruppe wäre
* nicht bloß nutzlos, er verriete auch deren Bestand.
*/
export async function findStaticDuplicate(
groupId: string,
type: string,
data: Record<string, unknown>,
): Promise<StaticDuplicate | null> {
const treffer = await prisma.qrCode.findFirst({
where: {
mode: "static",
type,
groupId,
// Postgres vergleicht jsonb nach Struktur, nicht nach Schreibweise —
// Schlüsselreihenfolge und Leerzeichen spielen keine Rolle.
data: { equals: toJsonInput(data) },
},
orderBy: { createdAt: "desc" },
select: { id: true, label: true, createdAt: true },
});
if (!treffer) return null;
return {
id: treffer.id,
label: treffer.label,
createdAt: treffer.createdAt.toISOString(),
};
}
+143 -11
View File
@@ -1,5 +1,7 @@
import "server-only"; import "server-only";
import type { Actor } from "@/lib/auth/actor";
import { visibleGroupIds } from "@/lib/auth/policy";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getQrType, qrTypeList } from "@/lib/qr/registry"; import { getQrType, qrTypeList } from "@/lib/qr/registry";
import { import {
@@ -15,10 +17,21 @@ import {
* Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann: * Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann:
* *
* - `implemented` — steht in der Registry und sagt, ob der Typ überhaupt * - `implemented` — steht in der Registry und sagt, ob der Typ überhaupt
* ausprogrammiert ist. In V1 gilt das nur für „Website“. * ausprogrammiert ist. Seit 1.1.0 gilt das für alle fünfzehn; die
* Unterscheidung bleibt für Typen, die später dazukommen.
* - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ * - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ
* freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben. * freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben.
* *
* Die Freigabe hat zwei Ebenen. **Global** gilt für die ganze Installation
* und ist die Grundlage; **je Gruppe** lässt sich davon abweichen, damit die
* Marketingabteilung Social Media bekommt und die Werkstatt nur Website und
* PDF. Fehlt eine Abweichung, folgt die Gruppe der globalen Einstellung.
*
* **Die globale Abschaltung gewinnt immer.** Ein global abgeschalteter Typ
* lässt sich in keiner Gruppe wieder einschalten. 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.
*
* Ein abgeschalteter Typ verschwindet aus der Oberfläche und lässt sich nicht * Ein abgeschalteter Typ verschwindet aus der Oberfläche und lässt sich nicht
* mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren * mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren
* weiter** — sie sind gedruckt und verteilt, und ein Schalter in der * weiter** — sie sind gedruckt und verteilt, und ein Schalter in der
@@ -29,8 +42,15 @@ export type QrTypeAvailability = {
qrType: QrTypeModule; qrType: QrTypeModule;
/** Der Typ ist in dieser Version ausprogrammiert. */ /** Der Typ ist in dieser Version ausprogrammiert. */
implemented: boolean; implemented: boolean;
/** Die Verwaltung hat den Typ freigegeben. */ /** Die Verwaltung hat den Typ freigegeben — global und in der Gruppe. */
enabled: boolean; enabled: boolean;
/** Die globale Freigabe für sich genommen, ohne die Gruppe. */
enabledGlobally: boolean;
/**
* Nur bei einer Abfrage für eine bestimmte Gruppe gesetzt: Ist der Typ dort
* ausdrücklich abgewählt? Ohne Abweichung `false`.
*/
disabledInGroup: boolean;
/** Freigegeben und ausprogrammiert — nur dann ist er benutzbar. */ /** Freigegeben und ausprogrammiert — nur dann ist er benutzbar. */
usable: boolean; usable: boolean;
/** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */ /** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */
@@ -56,16 +76,52 @@ async function loadDisabledTypes(): Promise<ReadonlySet<string>> {
} }
} }
/** Verfügbarkeit aller Typen in Anzeigereihenfolge. */ /** Abweichungen einer Gruppe; leer, wenn sie der globalen Einstellung folgt. */
export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> { async function loadGroupDisabledTypes(
groupId: string,
): Promise<ReadonlySet<string>> {
try {
const rows = await prisma.qrTypeGroupSetting.findMany({
where: { groupId, enabled: false },
select: { type: true },
});
return new Set(rows.map((row) => row.type));
} catch (error) {
// Wie oben: im Zweifel lieber alles zeigen als eine leere Startseite.
console.error(
"Freigaben der QR-Typen je Gruppe konnten nicht geladen werden — es gilt die globale Einstellung:",
error,
);
return new Set();
}
}
/**
* Verfügbarkeit aller Typen in Anzeigereihenfolge.
*
* Ohne `groupId` gilt allein die globale Freigabe — das ist die Sicht der
* Verwaltung auf die Grundeinstellung.
*/
export async function getQrTypeAvailability(
groupId?: string,
): Promise<QrTypeAvailability[]> {
const disabled = await loadDisabledTypes(); const disabled = await loadDisabledTypes();
const inGruppeAbgewaehlt =
groupId === undefined
? new Set<string>()
: await loadGroupDisabledTypes(groupId);
return qrTypeList.map((qrType) => { return qrTypeList.map((qrType) => {
const enabled = !disabled.has(qrType.id); const global = !disabled.has(qrType.id);
const inGruppe = inGruppeAbgewaehlt.has(qrType.id);
// Global aus gewinnt immer — eine Gruppe kann das nicht aufheben.
const enabled = global && !inGruppe;
return { return {
qrType, qrType,
implemented: qrType.implemented, implemented: qrType.implemented,
enabled, enabled,
enabledGlobally: global,
disabledInGroup: inGruppe,
usable: enabled && qrType.implemented, usable: enabled && qrType.implemented,
announced: enabled && !qrType.implemented, announced: enabled && !qrType.implemented,
}; };
@@ -73,15 +129,61 @@ export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> {
} }
/** Nur die Typen, die in der Oberfläche erscheinen sollen. */ /** Nur die Typen, die in der Oberfläche erscheinen sollen. */
export async function getVisibleQrTypes(): Promise<QrTypeAvailability[]> { export async function getVisibleQrTypes(
return (await getQrTypeAvailability()).filter((entry) => entry.enabled); groupId?: string,
): Promise<QrTypeAvailability[]> {
return (await getQrTypeAvailability(groupId)).filter((entry) => entry.enabled);
}
/**
* Was jemand auf der Startseite sehen soll: die **Vereinigung** über seine
* Gruppen.
*
* Wer in der Werkstatt und im Marketing ist, sieht beides. Welche Gruppe
* beim Anlegen gilt, entscheidet sich erst dort — und wird dort auch geprüft.
*/
export async function getVisibleQrTypesForActor(
actor: Actor,
): Promise<QrTypeAvailability[]> {
const eigene = visibleGroupIds(actor);
// `null` heißt: alle Gruppen. Ohne Gruppen (oder ohne Anmeldung) bleibt es
// bei der globalen Einstellung.
const gruppen =
eigene === null
? (await prisma.group.findMany({ select: { id: true } })).map((g) => g.id)
: eigene;
if (gruppen.length === 0) return getVisibleQrTypes();
const je = await Promise.all(gruppen.map((id) => getVisibleQrTypes(id)));
const gesehen = new Set<string>();
const vereinigt: QrTypeAvailability[] = [];
// Über qrTypeList laufen statt über die Ergebnisse: so bleibt die
// Anzeigereihenfolge der Registry erhalten.
for (const qrType of qrTypeList) {
for (const liste of je) {
if (gesehen.has(qrType.id)) break;
const treffer = liste.find((entry) => entry.qrType.id === qrType.id);
if (treffer) {
gesehen.add(qrType.id);
vereinigt.push(treffer);
}
}
}
return vereinigt;
} }
export async function getQrTypeState( export async function getQrTypeState(
id: string, id: string,
groupId?: string,
): Promise<QrTypeAvailability | undefined> { ): Promise<QrTypeAvailability | undefined> {
if (!isQrTypeId(id)) return undefined; if (!isQrTypeId(id)) return undefined;
return (await getQrTypeAvailability()).find((entry) => entry.qrType.id === id); return (await getQrTypeAvailability(groupId)).find(
(entry) => entry.qrType.id === id,
);
} }
/** /**
@@ -90,8 +192,9 @@ export async function getQrTypeState(
*/ */
export async function requireAvailableQrType( export async function requireAvailableQrType(
id: string, id: string,
groupId?: string,
): Promise<QrTypeModule> { ): Promise<QrTypeModule> {
const state = await getQrTypeState(id); const state = await getQrTypeState(id, groupId);
if (!state) { if (!state) {
throw new QrRequestError("Unbekannter QR-Typ.", 404); throw new QrRequestError("Unbekannter QR-Typ.", 404);
@@ -104,7 +207,9 @@ export async function requireAvailableQrType(
} }
if (!state.enabled) { if (!state.enabled) {
throw new QrRequestError( throw new QrRequestError(
`Der QR-Typ „${state.qrType.title}“ ist in der Verwaltung abgeschaltet.`, state.enabledGlobally
? `Der QR-Typ „${state.qrType.title}“ ist für diese Gruppe abgeschaltet.`
: `Der QR-Typ „${state.qrType.title}“ ist in der Verwaltung abgeschaltet.`,
403, 403,
); );
} }
@@ -128,7 +233,7 @@ export function requireImplementedQrType(id: string): QrTypeModule {
return qrType; return qrType;
} }
/** Freigabe eines Typs setzen. */ /** Globale Freigabe eines Typs setzen. */
export async function setQrTypeEnabled( export async function setQrTypeEnabled(
id: QrTypeId, id: QrTypeId,
enabled: boolean, enabled: boolean,
@@ -139,3 +244,30 @@ export async function setQrTypeEnabled(
update: { enabled }, update: { enabled },
}); });
} }
/**
* Freigabe eines Typs für eine Gruppe setzen.
*
* `true` löscht die Abweichung, statt sie auf „an“ zu setzen: Die Gruppe
* folgt dann wieder der globalen Einstellung. Eine Zeile „an“ zu hinterlassen
* würde vortäuschen, sie könnte eine globale Abschaltung überstimmen — das
* kann sie nicht.
*/
export async function setQrTypeEnabledForGroup(
id: QrTypeId,
groupId: string,
enabled: boolean,
): Promise<void> {
if (enabled) {
await prisma.qrTypeGroupSetting.deleteMany({
where: { type: id, groupId },
});
return;
}
await prisma.qrTypeGroupSetting.upsert({
where: { type_groupId: { type: id, groupId } },
create: { type: id, groupId, enabled: false },
update: { enabled: false },
});
}
+55 -1
View File
@@ -13,6 +13,29 @@ export const EC_LEVEL_LABELS: Record<EcLevel, string> = {
export const MIN_SIZE = 256; export const MIN_SIZE = 256;
export const MAX_SIZE = 2048; export const MAX_SIZE = 2048;
/**
* Logo in der Mitte des Codes.
*
* `branding` nimmt das Logo der Installation aus dem Erscheinungsbild,
* `upload` ein eigenes, das zu diesem Code gehört. Ohne Angabe kein Logo.
*/
export const LOGO_SOURCES = ["branding", "upload"] as const;
export type LogoSource = (typeof LOGO_SOURCES)[number];
/**
* Kantenlänge des Logos in Prozent der Kantenlänge des Codes.
*
* Bei 20 % sind knapp 4 % der Fläche verdeckt — das steckt die
* Fehlerkorrektur H (30 %) mühelos weg. Die Obergrenze liegt bei 25 %, weil
* darüber weniger die Fehlerkorrektur das Problem ist als die Scanner-Apps:
* Manche suchen in der Mitte nach Ausrichtungsmustern.
*/
export const MIN_LOGO_SCALE = 10;
export const MAX_LOGO_SCALE = 25;
/** Ab hier ein Hinweis, dass es knapp wird. */
export const LOGO_SCALE_WARN = 22;
export const DEFAULT_LOGO_SCALE = 20;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const hexColor = z const hexColor = z
@@ -39,7 +62,25 @@ export const designSchema = z.object({
.int({ error: "Der Rand muss eine ganze Zahl sein." }) .int({ error: "Der Rand muss eine ganze Zahl sein." })
.min(0, { error: "Der Rand darf nicht negativ sein." }) .min(0, { error: "Der Rand darf nicht negativ sein." })
.max(16, { error: "Der Rand darf höchstens 16 Module breit sein." }), .max(16, { error: "Der Rand darf höchstens 16 Module breit sein." }),
}); logoSource: z
.enum(LOGO_SOURCES, { error: "Unbekannte Herkunft des Logos." })
.nullable()
.default(null),
logoUploadId: z.string().trim().min(1).nullable().default(null),
logoScale: z.coerce
.number({ error: "Die Logogröße muss eine Zahl sein." })
.int({ error: "Die Logogröße muss eine ganze Zahl sein." })
.min(MIN_LOGO_SCALE, {
error: `Das Logo muss mindestens ${MIN_LOGO_SCALE} % breit sein.`,
})
.max(MAX_LOGO_SCALE, {
error: `Das Logo darf höchstens ${MAX_LOGO_SCALE} % breit sein — darüber wird der Code unzuverlässig.`,
})
.default(DEFAULT_LOGO_SCALE),
}).refine(
(design) => design.logoSource !== "upload" || design.logoUploadId !== null,
{ error: "Für ein eigenes Logo fehlt die hochgeladene Datei." },
);
export type QrDesign = z.infer<typeof designSchema>; export type QrDesign = z.infer<typeof designSchema>;
@@ -49,8 +90,21 @@ export const defaultDesign: QrDesign = {
size: 512, size: 512,
ecLevel: "M", ecLevel: "M",
margin: 4, margin: 4,
logoSource: null,
logoUploadId: null,
logoScale: DEFAULT_LOGO_SCALE,
}; };
/**
* Ein Logo verdeckt einen Teil des Codes. Was fehlt, muss die Fehlerkorrektur
* ausgleichen — deshalb gilt mit Logo immer **H**, unabhängig davon, was in
* der Auswahl steht. Die Oberfläche sagt das dazu; heimlich geschieht hier
* nichts.
*/
export function effectiveEcLevel(design: QrDesign): EcLevel {
return design.logoSource === null ? design.ecLevel : "H";
}
/** Farbvorschläge für die schnelle Auswahl in der Oberfläche. */ /** Farbvorschläge für die schnelle Auswahl in der Oberfläche. */
export const COLOR_PRESETS: ReadonlyArray<{ export const COLOR_PRESETS: ReadonlyArray<{
label: string; label: string;
+52
View File
@@ -0,0 +1,52 @@
import "server-only";
import { getBranding } from "@/lib/branding";
import { prisma } from "@/lib/db";
import type { QrDesign } from "@/lib/qr/design";
import { QrRequestError } from "@/lib/qr/type-module";
/** Das Bild, das in die Mitte des Codes kommt. */
export type QrLogo = {
bytes: Buffer;
mimeType: string;
};
/**
* Holt das Logo, das die Gestaltung verlangt — oder `null`.
*
* Zwei Quellen: das Logo der Installation aus dem Erscheinungsbild, oder eine
* Datei, die zu diesem Code hochgeladen wurde. Beides liegt als Upload in der
* Datenbank.
*
* Fehlt die Datei, ist das kein stiller Ausfall: Ein Code, der ohne Logo
* herauskommt, obwohl eines eingestellt war, fiele erst nach dem Druck auf.
*/
export async function ladeLogo(design: QrDesign): Promise<QrLogo | null> {
if (design.logoSource === null) return null;
const id =
design.logoSource === "branding"
? (await getBranding()).logoUploadId
: design.logoUploadId;
if (!id) {
throw new QrRequestError(
design.logoSource === "branding"
? "Für diese Installation ist kein Logo hinterlegt — unter Verwaltung → Erscheinungsbild lässt sich eines hochladen."
: "Das hochgeladene Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
);
}
const datei = await prisma.upload.findUnique({
where: { id },
select: { data: true, mimeType: true },
});
if (!datei) {
throw new QrRequestError(
"Das Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
);
}
return { bytes: Buffer.from(datei.data), mimeType: datei.mimeType };
}
+103 -4
View File
@@ -1,8 +1,10 @@
import "server-only"; import "server-only";
import QRCode, { type QRCodeRenderersOptions } from "qrcode"; import QRCode, { type QRCodeRenderersOptions } from "qrcode";
import sharp from "sharp";
import type { QrDesign } from "@/lib/qr/design"; import { effectiveEcLevel, type QrDesign } from "@/lib/qr/design";
import type { QrLogo } from "@/lib/qr/logo";
import { UserFacingError } from "@/lib/qr/type-module"; import { UserFacingError } from "@/lib/qr/type-module";
/** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */ /** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */
@@ -10,7 +12,8 @@ export class QrRenderError extends UserFacingError {}
function toOptions(design: QrDesign): QRCodeRenderersOptions { function toOptions(design: QrDesign): QRCodeRenderersOptions {
return { return {
errorCorrectionLevel: design.ecLevel, // Mit Logo gilt immer H — siehe `effectiveEcLevel`.
errorCorrectionLevel: effectiveEcLevel(design),
margin: design.margin, margin: design.margin,
width: design.size, width: design.size,
color: { color: {
@@ -30,12 +33,103 @@ function wrap(error: unknown): never {
throw new QrRenderError(`Der QR-Code konnte nicht erzeugt werden: ${detail}`); throw new QrRenderError(`Der QR-Code konnte nicht erzeugt werden: ${detail}`);
} }
/* ------------------------------------------------------------------- Logo */
/**
* Wie das Logo in den Code kommt.
*
* Hinter dem Bild liegt eine Fläche in der Hintergrundfarbe — ohne sie stünde
* das Logo auf den Modulen und ginge optisch unter. Der Rand dieser Fläche
* ist ein Zehntel der Logobreite; das reicht, um es abzusetzen, ohne mehr
* Code zu verdecken als nötig.
*
* Das Bild selbst wird eingepasst, nicht beschnitten (`contain`): Ein
* Firmenlogo im Querformat soll als Ganzes zu sehen sein.
*/
const PAD_ANTEIL = 0.1;
/** Kantenlänge von Bild und Fläche, in Bildpunkten. */
function logoMasse(gesamt: number, prozent: number) {
const bild = Math.round((gesamt * prozent) / 100);
const flaeche = Math.round(bild * (1 + 2 * PAD_ANTEIL));
return { bild, flaeche, versatz: Math.round((gesamt - flaeche) / 2) };
}
async function legeLogoAuf(
qrPng: Buffer,
design: QrDesign,
logo: QrLogo,
): Promise<Buffer> {
const { bild, flaeche, versatz } = logoMasse(design.size, design.logoScale);
// Erst das Bild auf die Fläche einpassen, dann die Fläche auf den Code.
// Zwei Schritte, weil `extend` den Rand in der Hintergrundfarbe zieht — das
// ergibt die Fläche, ohne eine zweite Zeichnung zu brauchen.
const rand = Math.round((flaeche - bild) / 2);
const kachel = await sharp(logo.bytes)
.resize(bild, bild, { fit: "contain", background: design.bgColor })
.extend({
top: rand,
bottom: flaeche - bild - rand,
left: rand,
right: flaeche - bild - rand,
background: design.bgColor,
})
.png()
.toBuffer();
return sharp(qrPng)
.composite([{ input: kachel, top: versatz, left: versatz }])
.png()
.toBuffer();
}
/**
* Setzt das Logo in ein fertiges QR-SVG.
*
* Das SVG von `qrcode` hat ein `viewBox` in Modulen — gerechnet wird deshalb
* in Modulen und nicht in Bildpunkten. Eingefügt wird direkt vor `</svg>`,
* damit Fläche und Bild über den Modulen liegen.
*/
function legeLogoInSvg(svg: string, design: QrDesign, logo: QrLogo): string {
const treffer = /viewBox="0 0 (\d+(?:\.\d+)?) /.exec(svg);
if (!treffer) {
// Ohne viewBox ließe sich nichts sauber platzieren. Lieber ohne Logo
// ausliefern als an einer falschen Stelle.
console.error("QR-SVG ohne viewBox — Logo wird ausgelassen.");
return svg;
}
const kante = Number(treffer[1]);
const bild = (kante * design.logoScale) / 100;
const flaeche = bild * (1 + 2 * PAD_ANTEIL);
const versatz = (kante - flaeche) / 2;
const bildVersatz = (kante - bild) / 2;
const daten = `data:${logo.mimeType};base64,${logo.bytes.toString("base64")}`;
const rund = (wert: number) => Number(wert.toFixed(3));
const einsatz =
`<rect x="${rund(versatz)}" y="${rund(versatz)}" width="${rund(flaeche)}" height="${rund(flaeche)}" fill="${design.bgColor}"/>` +
`<image x="${rund(bildVersatz)}" y="${rund(bildVersatz)}" width="${rund(bild)}" height="${rund(bild)}" ` +
`preserveAspectRatio="xMidYMid meet" href="${daten}"/>`;
return svg.replace("</svg>", `${einsatz}</svg>`);
}
/* ---------------------------------------------------------------- Ausgabe */
export async function renderSvg( export async function renderSvg(
content: string, content: string,
design: QrDesign, design: QrDesign,
logo: QrLogo | null = null,
): Promise<string> { ): Promise<string> {
try { try {
return await QRCode.toString(content, { ...toOptions(design), type: "svg" }); const svg = await QRCode.toString(content, {
...toOptions(design),
type: "svg",
});
return logo === null ? svg : legeLogoInSvg(svg, design, logo);
} catch (error) { } catch (error) {
wrap(error); wrap(error);
} }
@@ -44,9 +138,14 @@ export async function renderSvg(
export async function renderPng( export async function renderPng(
content: string, content: string,
design: QrDesign, design: QrDesign,
logo: QrLogo | null = null,
): Promise<Buffer> { ): Promise<Buffer> {
try { try {
return await QRCode.toBuffer(content, { ...toOptions(design), type: "png" }); const png = await QRCode.toBuffer(content, {
...toOptions(design),
type: "png",
});
return logo === null ? png : await legeLogoAuf(png, design, logo);
} catch (error) { } catch (error) {
wrap(error); wrap(error);
} }
+28 -10
View File
@@ -36,6 +36,8 @@ export const qrRequestSchema = z.object({
slug: z.string().trim().optional(), slug: z.string().trim().optional(),
/** Domain, unter der die Kurz-URL entsteht. */ /** Domain, unter der die Kurz-URL entsteht. */
domainId: z.string().trim().optional(), domainId: z.string().trim().optional(),
/** Name des Codes — bestimmt den Dateinamen beim Herunterladen. */
label: z.string().trim().max(120).optional(),
}); });
export type QrRequest = z.infer<typeof qrRequestSchema>; export type QrRequest = z.infer<typeof qrRequestSchema>;
@@ -137,25 +139,41 @@ export async function resolveQrRequest(
}; };
} }
/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */ /** Macht aus beliebigem Text einen brauchbaren Dateinamen-Bestandteil. */
function alsDateiname(text: string): string {
return text
.toLowerCase()
.replaceAll("ä", "ae")
.replaceAll("ö", "oe")
.replaceAll("ü", "ue")
.replaceAll("ß", "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
/**
* Dateiname für den Download.
*
* Wenn ein Name vergeben wurde, gewinnt er — genau dafür ist er da: damit man
* die Datei später wiederfindet. Sonst wie bisher aus dem Inhalt abgeleitet.
*/
export function downloadFilename( export function downloadFilename(
qrType: QrTypeModule, qrType: QrTypeModule,
data: unknown, data: unknown,
extension: string, extension: string,
label?: string | null,
): string { ): string {
const ausName = label ? alsDateiname(label) : "";
if (ausName) return `qr-${ausName}.${extension}`;
let base = "qr-code"; let base = "qr-code";
try { try {
const summary = qrType const summary = qrType
.summary(data) .summary(data)
.toLowerCase() .toLowerCase();
.replaceAll("ä", "ae") const gesaeubert = alsDateiname(summary);
.replaceAll("ö", "oe") if (gesaeubert) base = `qr-${gesaeubert}`;
.replaceAll("ü", "ue")
.replaceAll("ß", "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
if (summary) base = `qr-${summary}`;
} catch { } catch {
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname. // Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
} }
+45
View File
@@ -0,0 +1,45 @@
import "server-only";
import { prisma } from "@/lib/db";
export type InstanceSettings = {
/** Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern? */
editorsMaySetSlug: boolean;
};
export const DEFAULT_SETTINGS: InstanceSettings = {
editorsMaySetSlug: false,
};
/**
* Bewusst ohne Zwischenspeicher — dieselbe Erfahrung wie beim
* Erscheinungsbild: API-Route und Seite laufen in getrennten Modulinstanzen,
* ein Puffer wäre nach dem Speichern veraltet. Es ist eine Abfrage auf den
* Primärschlüssel einer Tabelle mit einer einzigen Zeile.
*/
export async function getSettings(): Promise<InstanceSettings> {
try {
const zeile = await prisma.instanceSetting.findUnique({
where: { id: "instance" },
});
return zeile
? { editorsMaySetSlug: zeile.editorsMaySetSlug }
: DEFAULT_SETTINGS;
} catch (error) {
// Ein Datenbankproblem darf nicht dazu führen, dass keine Seite erscheint.
// Im Zweifel gilt die zurückhaltendere Einstellung.
console.error("Einstellungen konnten nicht geladen werden:", error);
return DEFAULT_SETTINGS;
}
}
export async function saveSettings(
update: Partial<InstanceSettings>,
): Promise<InstanceSettings> {
await prisma.instanceSetting.upsert({
where: { id: "instance" },
update,
create: { id: "instance", ...update },
});
return getSettings();
}
+91
View File
@@ -0,0 +1,91 @@
import "server-only";
import type { QrCode } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { QrRequestError } from "@/lib/qr/type-module";
import { isValidSlug, normalizeSlug } from "@/lib/slug";
/**
* Sucht den Code zu einem Kurz-Link — einschließlich der früheren Adressen.
*
* Zwei Stufen, aus einem Grund: Der häufige Fall ist ein gescannter Code, und
* der liefert die Adresse genau so, wie sie gespeichert ist. Dieser Zugriff
* geht über den exakten Index. Erst wenn er nichts findet, wird
* großschreibungsunabhängig gesucht und danach unter den früheren Adressen —
* beides seltene Fälle, etwa wenn jemand die Adresse abtippt.
*/
export async function findCodeBySlug(
domainId: string,
slug: string,
): Promise<{ code: QrCode; ueberFrueheAdresse: boolean } | null> {
if (!isValidSlug(slug)) return null;
const exakt = await prisma.qrCode.findUnique({
where: { domainId_slug: { domainId, slug } },
});
if (exakt) return { code: exakt, ueberFrueheAdresse: false };
const normalisiert = normalizeSlug(slug);
const egalGeschrieben = await prisma.qrCode.findFirst({
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
});
if (egalGeschrieben) {
return { code: egalGeschrieben, ueberFrueheAdresse: false };
}
const alias = await prisma.qrCodeAlias.findFirst({
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
include: { qrCode: true },
});
if (alias) return { code: alias.qrCode, ueberFrueheAdresse: true };
return null;
}
/**
* Ist dieser Kurz-Link auf dieser Domain noch frei?
*
* Belegt ist er auch dann, wenn ihn ein anderer Code als **frühere** Adresse
* hält — sonst könnte man einem fremden Code seine gedruckten Adressen
* wegnehmen.
*/
export async function assertSlugAvailable(
domainId: string,
slug: string,
/** Der eigene Code darf seine eigenen Adressen natürlich behalten. */
eigenerCodeId?: string,
): Promise<void> {
const normalisiert = normalizeSlug(slug);
const belegtVomCode = await prisma.qrCode.findFirst({
where: {
domainId,
slug: { equals: normalisiert, mode: "insensitive" },
...(eigenerCodeId ? { id: { not: eigenerCodeId } } : {}),
},
select: { id: true },
});
if (belegtVomCode) {
throw new QrRequestError(
`Der Kurz-Link „${normalisiert}" ist auf dieser Domain schon vergeben.`,
409,
);
}
const belegtAlsAlias = await prisma.qrCodeAlias.findFirst({
where: {
domainId,
slug: { equals: normalisiert, mode: "insensitive" },
...(eigenerCodeId ? { qrCodeId: { not: eigenerCodeId } } : {}),
},
select: { id: true },
});
if (belegtAlsAlias) {
throw new QrRequestError(
`Der Kurz-Link „${normalisiert}" gehört als frühere Adresse noch zu einem ` +
`anderen Code. Wer ihn freigibt, kann ihn danach hier verwenden.`,
409,
);
}
}
+91 -3
View File
@@ -8,8 +8,12 @@ const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
export const SLUG_LENGTH = 8; export const SLUG_LENGTH = 8;
/** Muster, gegen das eingehende Slugs geprüft werden. */ /** Höchstens so viele durch `/` getrennte Abschnitte. */
export const SLUG_PATTERN = /^[0-9A-Za-z]{4,32}$/; export const MAX_SLUG_SEGMENTS = 3;
/** Länge eines Abschnitts. */
export const MAX_SEGMENT_LENGTH = 48;
/** Länge des ganzen Kurz-Links. */
export const MAX_SLUG_LENGTH = 100;
export function generateSlug(length: number = SLUG_LENGTH): string { export function generateSlug(length: number = SLUG_LENGTH): string {
let slug = ""; let slug = "";
@@ -19,6 +23,90 @@ export function generateSlug(length: number = SLUG_LENGTH): string {
return slug; return slug;
} }
/**
* Vergleichsform eines Kurz-Links.
*
* Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: Wer
* `qr.firma.de/r/Sommeraktion` abtippt, soll dort landen, wo
* `sommeraktion` liegt. Deshalb wird überall gegen diese Form verglichen —
* beim Auflösen wie bei der Prüfung auf Eindeutigkeit.
*/
export function normalizeSlug(value: string): string {
return value.trim().toLowerCase();
}
/** Ein einzelner Abschnitt: Kleinbuchstaben, Ziffern, Bindestriche. */
const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* Prüft einen Kurz-Link, wie er in der Adresse stehen darf.
*
* Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und `/` als Trenner —
* damit sind Adressen wie `kunde-a/sommeraktion` möglich. Bewusst **nicht**
* erlaubt: Umlaute und Akzente (sie werden in Adressen unleserlich kodiert),
* Leerzeichen, Unterstriche sowie Bindestriche am Rand eines Abschnitts.
*
* Selbst erzeugte Kurz-Links aus dem gemischten Alphabet oben bestehen diese
* Prüfung ebenfalls, weil verglichen wird, nachdem kleingeschrieben wurde.
*
* Führende, doppelte und abschließende Schrägstriche gelten als ungültig — und
* zwar auch beim Auflösen. Sonst führten `/r/sommer` und `/r//sommer` auf
* denselben Code, und es gäbe zwei Adressen für dieselbe Sache.
*/
export function isValidSlug(value: string): boolean { export function isValidSlug(value: string): boolean {
return SLUG_PATTERN.test(value); const slug = normalizeSlug(value);
if (slug.length === 0 || slug.length > MAX_SLUG_LENGTH) return false;
if (slug.startsWith("/") || slug.endsWith("/") || slug.includes("//")) {
return false;
}
const abschnitte = slug.split("/");
if (abschnitte.length > MAX_SLUG_SEGMENTS) return false;
return abschnitte.every(
(abschnitt) =>
abschnitt.length > 0 &&
abschnitt.length <= MAX_SEGMENT_LENGTH &&
SEGMENT.test(abschnitt),
);
}
/**
* Prüft einen selbst gewählten Kurz-Link und liefert eine deutsche Begründung,
* wenn er nicht taugt. Ausführlicher als `isValidSlug`, weil hier jemand
* zusieht und wissen will, was falsch war.
*/
export function describeSlugProblem(eingabe: string): string | null {
const roh = eingabe.trim();
if (roh.length === 0) return "Bitte gib einen Kurz-Link ein.";
if (/^\/|\/$/.test(roh)) {
return "Der Kurz-Link darf nicht mit „/“ beginnen oder enden.";
}
if (roh.includes("//")) {
return "Zwei Schrägstriche hintereinander sind nicht möglich.";
}
if (roh.length > MAX_SLUG_LENGTH) {
return `Der Kurz-Link ist zu lang (höchstens ${MAX_SLUG_LENGTH} Zeichen).`;
}
const slug = normalizeSlug(roh);
const abschnitte = slug.split("/");
if (abschnitte.length > MAX_SLUG_SEGMENTS) {
return `Höchstens ${MAX_SLUG_SEGMENTS} durch „/“ getrennte Abschnitte.`;
}
for (const abschnitt of abschnitte) {
if (abschnitt.length > MAX_SEGMENT_LENGTH) {
return `Jeder Abschnitt darf höchstens ${MAX_SEGMENT_LENGTH} Zeichen lang sein.`;
}
if (/^-|-$/.test(abschnitt)) {
return "Ein Bindestrich darf nicht am Anfang oder Ende eines Abschnitts stehen.";
}
if (!SEGMENT.test(abschnitt)) {
return "Erlaubt sind Buchstaben ohne Umlaute, Ziffern, Bindestriche und „/“ als Trenner.";
}
}
return null;
} }
+10
View File
@@ -35,6 +35,16 @@ export const UPLOAD_KINDS = {
// das dann bei jedem Seitenaufruf mitgeht. // das dann bei jedem Seitenaufruf mitgeht.
maxBytes: 2 * 1024 * 1024, maxBytes: 2 * 1024 * 1024,
}, },
qrlogo: {
label: "Logo im Code",
// Ohne GIF: Ein bewegtes Bild wird beim Einsetzen ohnehin zum Standbild,
// und die Auswahl soll nicht mehr versprechen, als hinten herauskommt.
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/avif"],
accept: "image/png,image/jpeg,image/webp,image/avif",
// Das Logo landet auf wenigen hundert Bildpunkten in der Mitte des Codes.
// Mehr als zwei Megabyte bringen dort nichts.
maxBytes: 2 * 1024 * 1024,
},
favicon: { favicon: {
label: "Favicon", label: "Favicon",
// Zusätzlich ICO — viele haben genau das noch herumliegen. // Zusätzlich ICO — viele haben genau das noch herumliegen.
+30
View File
@@ -189,6 +189,36 @@ export async function attachUploads(
}); });
} }
/**
* Hängt **eine** Datei an einen Code, ohne die übrigen anzurühren.
*
* Gebraucht für das Logo aus der Gestaltung: Ändert sich nur das Aussehen
* eines Codes, sollen die Dateien seines Inhalts unberührt bleiben.
*/
export async function attachUpload(
qrCodeId: string,
uploadId: string,
): Promise<void> {
await prisma.upload.updateMany({
// Dieselbe Bedingung wie oben: keine fremde Datei an sich ziehen.
where: { id: uploadId, OR: [{ qrCodeId: null }, { qrCodeId }] },
data: { qrCodeId },
});
}
/**
* Löst eine Datei wieder vom Code — sie wird danach als herrenlos weggeräumt.
*/
export async function detachUpload(
qrCodeId: string,
uploadId: string,
): Promise<void> {
await prisma.upload.updateMany({
where: { id: uploadId, qrCodeId },
data: { qrCodeId: null },
});
}
/** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */ /** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */
const ORPHAN_HOURS = 24; const ORPHAN_HOURS = 24;