26 Commits
Author SHA1 Message Date
florian.wilde 6743dc007d Merge #20: Befunde der Sicherheitsprüfung 2026-08-24 07:41:07 +00:00
Florian Wilde 367d9c540b Bremsen, Sperren und CSP: die offenen Punkte schliessen
S3: Aus der Kontosperre wird eine Wartezeit. Die alte Sperre wies auch das
richtige Passwort ab — wer die Adresse eines Administrators kannte, konnte
ihn alle 15 Minuten neu aussperren. Jetzt bleiben vier Fehlversuche
folgenlos, danach verdoppelt sich die Wartezeit bis zu einer Minute. Der
Besitzer kommt spaetestens nach einer Minute hinein, ein Fremder bekommt
einen Versuch pro Minute. Ausgewertet wird sie vor der Passwortpruefung,
damit ein ohnehin abgewiesener Versuch keine 128 MiB kostet.

S4: Drei Stufen statt einer. Die Bremse je Absender liest
X-Forwarded-For nur noch mit TRUST_PROXY=true — ohne Proxy waehlt den Wert
jeder Aufrufer selbst, und eine so umgehbare Bremse ist eine Behauptung.
Dazu die Wartezeit je Konto, die an der Datenbank haengt, und eine
Obergrenze fuer die ganze Installation: 60 Passwortpruefungen und 120
Rueckmeldungen je Minute. Die letzte Stufe ist die einzige, die sich nicht
ueber Kopfzeilen umgehen laesst, und sie schuetzt den Speicher.

CSP: Eine vollstaendige Policy mit Nonce je Anfrage, strict-dynamic,
default-src self, object-src none, base-uri und form-action self. Skripte
ohne den Nonce dieser Anfrage laufen nicht mehr. Zwei Lockerungen bleiben
und stehen begruendet in der Doku: style-src erlaubt unsafe-inline, weil
ein Nonce keine style-Attribute deckt, und media-src erlaubt https, weil
der Typ Video ausdruecklich Direktlinks annimmt.

Protokoll: Sicherheitsereignisse als JSON auf die Standardausgabe —
Fehlversuche, OIDC-Verknuepfungen samt abgelehnten, Vergabe von
Administratorrechten. Ohne Passwoerter, Token und Adressen.

Refs #20
2026-08-24 01:43:00 +02:00
Florian Wilde 1e0d04d0db Befunde der Sicherheitspruefung abarbeiten
S1 (hoch): Die OIDC-Rueckkehr verknuepft eine Identitaet mit einem
bestehenden Konto nur bei bestaetigter E-Mail — geprueft wurde das auch,
nur war die Bestaetigung als `email_verified !== false` berechnet. Damit
galt auch ein fehlender Claim als bestaetigt, und genau den schicken
manche Anbieter gar nicht. Jetzt zaehlt allein ein ausdrueckliches `true`.

S2 (hoch): `isAuthEnabled()` verlangte den Wert "true"; alles andere hiess
"aus". Eine vergessene Variable ergab eine Anwendung, in der jeder
Besucher alles darf, und nichts sah danach aus. Umgedreht: Abgeschaltet
wird nur, wer ausdruecklich "false" setzt. Die Vorgaben in Compose und
.env.example stehen auf true, und der Container warnt beim Start
mehrzeilig, solange die Anmeldung aus ist.

S5 (mittel): Der OIDC-Bootstrap las `user.count()` und legte danach an —
zwei gleichzeitige erste Anmeldungen konnten beide Administrator werden.
Jetzt in einer serialisierbaren Transaktion, wie die lokale
Ersteinrichtung es laengst tut.

S6 (niedrig): Die Herkunftspruefung verglich nur den Host, nicht das
Schema. Das Schema kommt jetzt aus BASE_URL — die eine Angabe zum
Ursprung, ueber die der Aufrufer nicht mitbestimmt.

S3, S4 und S7 bleiben offen und stehen begruendet in docs/sicherheit.md.
Bei S3 wurde der naheliegende Ausweg geprueft und verworfen: Waehrend der
Sperre weiter Passwoerter zu pruefen kostet 128 MiB je Versuch und
tauschte eine Sperre gegen ein Konto gegen eine gegen alle.

Refs #20
2026-08-24 01:02:03 +02:00
florian.wilde 4e2d222e86 Merge: Version 1.7.1 — Bilder zu den neuen Funktionen 2026-08-21 10:23:58 +00:00
Florian Wilde fcc63ff69a Version 1.7.1: Bilder zu den neuen Funktionen
Die Bedienungsanleitung hatte zu jedem alten Abschnitt ein Bild und zu
keinem der sechs neuen. Sechs Aufnahmen kommen dazu: Logo im Code,
Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und der
Bericht einer geprueften Domain.

Das Skript dafuer lief seit einer Umbenennung ins Leere — es wartete auf
einen Dialogtitel "Ziel bearbeiten", der inzwischen "Code bearbeiten"
heisst. Damit sind auch die uebrigen sechzehn Bilder neu; sie stammten
noch aus 1.2.0 und zeigten eine aeltere Oberflaeche.

schuss() kann jetzt einen Ausschnitt aufnehmen statt der ganzen Seite —
fuer Dialoge und einzelne Karten, bei denen die volle Seite mehr verdeckt
als zeigt. Das Beispiellogo entsteht im Skript, damit kein ungepflegtes
Bild im Repo liegt.

In der Kopfdoku steht jetzt, dass BASE_URL der Instanz auf dieselbe
Adresse zeigen muss wie BASE — sonst erreicht die Anwendung sich beim
Domain-Check nicht selbst und das Bild zeigt einen Fehlschlag.
2026-08-21 12:23:15 +02:00
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
88 changed files with 4117 additions and 432 deletions
+18 -4
View File
@@ -14,12 +14,26 @@ BASE_URL=http://localhost:3000
APP_PORT=3000
# Anmeldung mit Benutzern, Gruppen und Rollen.
# false — jeder, der die App erreicht, darf alles (Einzelplatz/LAN).
# true — Anmeldung erforderlich. Beim ersten Aufruf legst du den ersten
# Administrator an.
# Administrator an. Das ist die Vorgabe, auch wenn die Zeile fehlt.
# false — jeder, der die App erreicht, darf alles: Verwaltung, Uploads,
# saemtliche Codes. Nur fuer Einzelplatz- oder LAN-Betrieb, und
# nur, wenn niemand Fremdes an den Rechner kommt. Der Container
# warnt beim Start deutlich, solange das so steht.
# Bewusst hier und nicht in der Oberfläche: so kann man sich nicht aussperren.
# Wer den Zugang verliert, setzt den Wert zurück auf false und startet neu.
AUTH_ENABLED=false
# Wer den Zugang verliert, setzt den Wert auf false und startet neu.
AUTH_ENABLED=true
# Steht ein Reverse Proxy davor, der `X-Forwarded-For` selbst setzt?
# false — Vorgabe. Die Kopfzeile wird nicht gelesen. Ohne Proxy koennte
# sie jeder Aufrufer selbst waehlen; eine Bremse, die sich so
# umgehen laesst, waere eine Behauptung.
# true — Nur setzen, wenn wirklich ein Proxy davorsteht und die Kopfzeile
# ueberschreibt. Sonst schenkst du jedem Aufrufer beliebig viele
# Anmeldeversuche.
# Unabhaengig davon gelten immer: die Wartezeit je Konto und die Obergrenze
# fuer die ganze Installation.
TRUST_PROXY=false
# Groesste erlaubte Datei fuer die Typen "PDF" und "Bild", in Megabyte.
# Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr
+93
View File
@@ -0,0 +1,93 @@
# Ä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.1 — 21.08.2026
Bilder zu den neuen Funktionen in der Bedienungsanleitung.
- Die Bedienungsanleitung zeigt die Funktionen aus 1.7.0 jetzt auch im Bild: Logo im Code, Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und Domain-Bericht.
- Alle übrigen Bilder mit erneuert — sie stammten noch aus 1.2.0.
- Das Skript, das die Bilder erzeugt, lief seit einer Umbenennung ins Leere und tut es wieder.
## 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.
+81 -11
View File
@@ -18,21 +18,28 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
Ziel ändern, löschen.
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Filter nach Gruppe
und nach Art, Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch
mit Namen ablegen; liegt derselbe Inhalt schon dort, wird gewarnt.
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
erhoben, ohne Name und IP-Adresse.
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
Administrator, Bearbeiter und Betrachter.
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt — mit Kurzhilfe
zur Einrichtung und einer Prüfung, ob eine Domain wirklich hierher führt.
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst.
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst —
für die ganze Installation oder je Gruppe.
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
Admin-Bereich einstellbar.
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
eigene Wahl.
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
nachträglich änderbar — die vorherige Adresse bleibt gültig.
- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt
zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
von selbst; alles Nötige steht in einer `.env`.
@@ -47,7 +54,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
Rollen und der Weg zurück
- **[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,
Härtungen und was bewusst offen bleibt
@@ -283,7 +291,7 @@ sie eintragen.
| --- | --- | --- |
| `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 |
| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Die Bremse je Absender entfällt; Wartezeit je Konto und Obergrenze der Instanz greifen weiter |
Für ein reines nginx (ohne NPM) heißt das:
@@ -297,6 +305,14 @@ 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.
> **`TRUST_PROXY=true` erst setzen, wenn der Proxy wirklich steht.** Die
> Anwendung liest `X-Forwarded-For` nur mit dieser Angabe. Ohne Proxy könnte
> die Kopfzeile jeder Aufrufer selbst wählen und sich damit beliebig viele
> Anmeldeversuche verschaffen — eine Bremse, die sich so umgehen lässt, wäre
> eine Behauptung. Zwei Bremsen wirken unabhängig davon immer: die Wartezeit
> je Konto und die Obergrenze für die ganze Installation. Näheres in
> [docs/sicherheit.md](docs/sicherheit.md).
### Größe der Uploads
PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von
@@ -349,6 +365,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
ansprechen.
### Nachprüfen, ob eine Domain wirklich hierher führt
Auf der Domain-Seite steht bei jedem Eintrag ein Knopf **Prüfen**. Er geht den
Weg der Scanner nach und meldet vier Dinge:
| Schritt | Was geprüft wird |
| --- | --- |
| DNS | Löst der Name auf? Auf welche Adressen? |
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
| Ziel | Steht dahinter **diese** Installation? |
Der letzte Schritt ist der eigentliche Wert. Die Anwendung ruft dazu unter der
Domain eine eigene Route auf und lässt sich einen HMAC über eine frische
Zufallszahl zurückgeben; das Geheimnis dafür steht in der Datenbank und
verlässt den Server nie. Nur so unterscheidet sich „die Domain zeigt
irgendwohin“ von „die Domain zeigt hierher“.
> **Grenze der Aussage:** Der Aufruf startet dort, wo der Container läuft.
> Wer internes DNS oder Hairpin-NAT hat, bekommt womöglich einen Fehler,
> obwohl von außen alles stimmt. Umgekehrt gilt: Meldet die Prüfung von innen
> Erfolg, heißt das noch nicht, dass die Domain auch von außen erreichbar ist.
> Dafür bleibt der Blick von einem fremden Netz aus.
Zwei Dinge, die daraus folgen:
- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende
@@ -579,12 +619,32 @@ Zwei Eigenschaften sind dabei zu unterscheiden:
| | 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 |
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
umgesetzt, erscheint als Kachel „Bald verfügbar“.
#### Freigabe je Gruppe
Die Auswahl **Freigabe für** über der Liste hat zwei Stufen:
- **Alle Gruppen (Grundeinstellung)** — gilt für die ganze Installation.
- **Nur: \<Gruppe\>** — weicht für diese eine Gruppe davon ab. Die
Marketingabteilung bekommt Social Media, die Werkstatt nur Website und PDF.
Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung. **Die globale
Abschaltung gewinnt immer:** Ein Typ, der in der Grundeinstellung aus ist,
lässt sich in keiner Gruppe einschalten — der Schalter steht dort sichtbar
still und sagt warum. Andersherum wäre „hier abgeschaltet“ keine verlässliche
Aussage mehr, und wer den Schalter umlegt, müsste alle Gruppen durchsehen, um
zu wissen, was er getan hat.
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
in Werkstatt und Marketing ist, sieht beides. Welche Freigabe gilt, entscheidet
sich erst beim Anlegen — dort zählt die gewählte Gruppe, und die Route prüft
das noch einmal.
> **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab,
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
@@ -614,13 +674,21 @@ Dazu kommen
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen.
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen,
- ein **Filter nach Art**, sobald dynamische und statische Codes
nebeneinander liegen.
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
Kartenansicht.
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
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
@@ -960,8 +1028,10 @@ deutschen Meldung.
| `DELETE` | `/api/codes/{id}` | Code löschen |
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
| `GET` | `/api/admin/types` | Zustand aller QR-Typen |
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten |
| `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) |
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten (`groupId` optional) |
| `POST` | `/api/admin/domains/{id}/check` | Domain prüfen: DNS, Zertifikat, Erreichbarkeit, Ziel |
| `GET` | `/api/domain-check` | Beweis für die Domain-Prüfung (`?nonce=…`, ohne Anmeldung) |
Beispiel:
+6 -1
View File
@@ -37,7 +37,12 @@ services:
BASE_URL: ${BASE_URL:-http://localhost:3000}
DATABASE_URL: ${DATABASE_URL}
# Anmeldung mit Benutzern, Gruppen und Rollen.
AUTH_ENABLED: ${AUTH_ENABLED:-false}
# Ohne Angabe: an. Abgeschaltet wird nur, wer ausdruecklich
# AUTH_ENABLED=false setzt — siehe .env.example.
AUTH_ENABLED: ${AUTH_ENABLED:-true}
# Nur einschalten, wenn wirklich ein Proxy davorsteht — siehe
# .env.example.
TRUST_PROXY: ${TRUST_PROXY:-false}
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30}
SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90}
+12
View File
@@ -5,5 +5,17 @@ set -e
echo "→ Datenbankmigrationen werden angewendet …"
node /app/node_modules/prisma/build/index.js migrate deploy
# Eine Anwendung ohne Anmeldung sieht genauso aus wie eine mit — deshalb
# muss sie es sagen. Wer das hier im Protokoll sieht und nicht erwartet hat,
# soll sofort wissen, woran er ist.
if [ "$(printf '%s' "${AUTH_ENABLED:-}" | tr '[:upper:]' '[:lower:]')" = "false" ]; then
echo "" >&2
echo " !! ACHTUNG: AUTH_ENABLED=false — die Anwendung laeuft OHNE Anmeldung." >&2
echo " !! Jeder, der sie erreicht, darf alles: Verwaltung, Uploads, saemtliche" >&2
echo " !! Codes anlegen, aendern und loeschen. Das ist nur fuer Einzelplatz-" >&2
echo " !! oder LAN-Betrieb gedacht und gehoert nicht ins offene Netz." >&2
echo "" >&2
fi
echo "→ Anwendung wird gestartet …"
exec "$@"
+103 -3
View File
@@ -112,12 +112,42 @@ gescannt zu werden.
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
trennen kann.
### Ein Logo in die Mitte setzen
![Ein Logo in der Mitte des Codes](bilder/16-logo-im-code.png)
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
Hier stehen alle **dynamischen** Codes. Statische erscheinen nicht — sie
werden nicht gespeichert.
Hier stehen die **dynamischen** Codes und die statischen, die du abgelegt
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)
@@ -128,6 +158,11 @@ Projekte nebeneinander.
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
liegen.
- Ein **Filter nach Art** erscheint, sobald dynamische und statische Codes
nebeneinander liegen: *Alle Arten*, *Nur dynamische*, *Nur statische*.
![Die Liste, eingeschränkt auf statische Codes](bilder/18-meine-codes-filter.png)
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.
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
@@ -135,6 +170,23 @@ Vorschau. Die Wahl merkt sich der Browser.
![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.
![Warnung vor einem doppelt abgelegten Code](bilder/17-dublettenwarnung.png)
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
![Dialog zum Ändern des Ziels](bilder/06-ziel-bearbeiten.png)
@@ -247,6 +299,27 @@ sich Typen abschalten; sie verschwinden dann aus der Auswahl.
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
**Freigabe je Gruppe.** Über der Liste steht die Auswahl **Freigabe für**:
![Freigabe der Typen für eine einzelne Gruppe](bilder/19-verwaltung-typen-gruppe.png)
| 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 verwalten](bilder/13-verwaltung-gruppen.png)
@@ -275,7 +348,34 @@ mehreren Gruppen dienen; die Codes bleiben trotzdem strikt nach Gruppe
getrennt.
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.
![Die Kurzhilfe zum Hinzufügen einer Domain](bilder/20-domain-kurzhilfe.png)
**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:
![Der Bericht einer geprüften Domain](bilder/21-domain-check.png)
| 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.
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+137 -4
View File
@@ -102,7 +102,59 @@ Browser landet.
| Ziel später änderbar | nein | ja, jederzeit |
| Scans gezählt | nein | ja, mit Datum des letzten |
| Braucht diesen Server | nie | dauerhaft |
| In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler |
| In „Meine Codes“ | auf Wunsch, als Schnappschuss | immer |
### Ein statischer Code mit Namen
Beide Betriebsarten haben ein Feld **Name (optional)**. Es tut zweierlei:
- 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
@@ -263,8 +315,9 @@ Die **Vorschau** wird serverseitig gerendert und aktualisiert sich bei jeder
## Meine Codes
Zeigt alle dynamischen Codes — statische erscheinen nicht, weil sie nicht
gespeichert werden.
Zeigt die dynamischen Codes und die statischen, die abgelegt wurden. Ein
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
mit großer Vorschau. Die Wahl merkt sich der Browser.
@@ -272,6 +325,10 @@ gespeichert werden.
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
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.
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
@@ -374,6 +431,53 @@ Domains werden in der Verwaltung angelegt und Gruppen zugeteilt.
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
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
@@ -448,7 +552,7 @@ auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
| --- | --- |
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
| **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 |
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
| **Domains** | anlegen, Gruppen zuteilen, abschalten |
@@ -460,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
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
@@ -482,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
Alles, was die Oberfläche tut, geht auch über HTTP:
+30 -114
View File
@@ -7,86 +7,21 @@ Aufwände sind grobe Einschätzungen, keine Zusagen.
## 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
Proxy, Migrationen beim Start
- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein
Formular, ohne Eingriff in den übrigen Code
- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und
Scan-Zähler
- Gestaltung: Farben mit Kontrastwarnung, Größe, Fehlerkorrektur, Rand
- 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
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes,
1.6.0 den Versionshinweis und 1.7.0 sechs Erweiterungen auf einmal — Logo im
Code, Freigabe je Gruppe, Domain-Check, Dublettenprüfung, Filter nach Art und
die Kurzhilfe zu den Domains.
### Typen einzeln freigeben (14.08.2026)
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.
### Sprechende Kurz-Links (17.08.2026)
Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die
vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde
jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren
vorbehalten, per Einstellung für Bearbeiter freigebbar.
Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1).
### Dokumentation (15.08.2026)
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
Damit sind zwei Punkte erledigt, die hier lange unter „Als Nächstes“ standen:
**QR-Typen je Gruppe freigeben** und **Logo im QR-Code**.
---
@@ -95,27 +30,7 @@ Die Bilder erzeugt ein Skript, damit sie nicht veralten.
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
Person, die den Code kennt.
### 1. QR-Typen je Gruppe freigeben · 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
### 1. Statistik über die Zeit · mittel
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
@@ -129,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
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,
samt seiner Dateien und Rückmeldungen.
@@ -139,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
bekommen und die gedruckten Codes zeigten plötzlich woandershin.
### 4. Logo im QR-Code · 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
### 3. Ablaufdatum und Zeitsteuerung · mittel
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
@@ -169,6 +71,20 @@ Geplant, in zwei Stufen:
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
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
+159 -3
View File
@@ -7,6 +7,9 @@
* Voraussetzungen:
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
* Codes im Bild landen), erreichbar unter BASE
* - `BASE_URL` der Instanz muss auf **dieselbe** Adresse zeigen wie BASE.
* Sonst scheitert der Domain-Check im Bild 21, weil die Anwendung sich
* selbst unter der eingetragenen Adresse nicht erreicht.
* - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
* Regelbetrieb
* - Playwright: npm i -D playwright && npx playwright install chromium
@@ -18,6 +21,7 @@ import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { chromium } from "playwright";
import sharp from "sharp";
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
@@ -197,16 +201,47 @@ const handy = await browser.newContext({
locale: "de-DE",
});
async function schuss(context, pfad, datei, { ganzeSeite = true, vorher } = {}) {
/**
* Nimmt eine Seite auf.
*
* `bereich` grenzt auf einen Ausschnitt ein — für Dialoge und einzelne Karten,
* bei denen die ganze Seite mehr verdeckt als zeigt.
*/
async function schuss(
context,
pfad,
datei,
{ ganzeSeite = true, vorher, bereich } = {},
) {
const page = await context.newPage();
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
if (vorher) await vorher(page);
await page.waitForTimeout(300);
await page.screenshot({ path: join(ZIEL, datei), fullPage: ganzeSeite });
const ziel = join(ZIEL, datei);
if (bereich) {
await page.locator(bereich).first().screenshot({ path: ziel });
} else {
await page.screenshot({ path: ziel, fullPage: ganzeSeite });
}
await page.close();
console.log(" ✓", datei);
}
/**
* Ein Beispiellogo für die Aufnahmen zum Logo im Code.
*
* Erzeugt statt mitgeliefert: So liegt kein Bild im Repo, das niemand pflegt,
* und die Farbe passt zu den übrigen Aufnahmen.
*/
async function beispiellogo() {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">
<rect width="256" height="256" rx="56" fill="#312E81"/>
<circle cx="128" cy="128" r="66" fill="none" stroke="#FFFFFF" stroke-width="18"/>
<circle cx="128" cy="128" r="20" fill="#FFFFFF"/>
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}
console.log("Bilder werden erzeugt:");
await schuss(desktop, "/", "01-startseite.png");
@@ -242,7 +277,7 @@ await schuss(desktop, "/meine-codes", "06-ziel-bearbeiten.png", {
await page.waitForTimeout(400);
// Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt.
await page.locator("tbody tr").last().getByRole("button", { name: /bearbeiten/ }).click();
await page.getByRole("heading", { name: "Ziel bearbeiten" }).waitFor();
await page.getByRole("heading", { name: "Code bearbeiten" }).waitFor();
},
});
@@ -258,8 +293,129 @@ await schuss(desktop, "/verwaltung/gruppen", "13-verwaltung-gruppen.png");
await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png");
await schuss(desktop, "/konto", "15-konto.png");
/* ------------------------------------------- Seit 1.7.0: die neuen Funktionen */
const logo = await beispiellogo();
// Logo in der Mitte: ganze Seite, damit Auswahl und Vorschau zusammen im Bild
// sind — das eine erklärt das andere.
await schuss(desktop, "/erstellen/website", "16-logo-im-code.png", {
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/nordwind");
await page.getByRole("checkbox", { name: /Logo in die Mitte/ }).check();
await page.setInputFiles('input[type="file"]', {
name: "logo.png",
mimeType: "image/png",
buffer: logo,
});
await page.waitForTimeout(2500);
},
});
// Für die Dublettenwarnung braucht es einen bereits abgelegten statischen Code.
const statisch = await api("/api/codes", {
method: "POST",
json: {
type: "website",
data: { url: "https://example.com/aushang" },
design: DESIGN,
mode: "static",
label: "Aushang Eingang",
},
});
if (statisch.status === 201) angelegt.push(statisch.body.code);
await schuss(desktop, "/erstellen/website", "17-dublettenwarnung.png", {
bereich: "section:has-text('Art des Codes')",
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/aushang");
await page.locator('button[data-mode="static"]').click();
await page.waitForTimeout(900);
await page.getByRole("button", { name: /ablegen/i }).click();
await page.getByText("Diesen Inhalt hast du schon abgelegt").waitFor({
timeout: 15000,
});
},
});
// Filter nach Art — sichtbar, weil jetzt beide Arten in der Liste stehen.
await schuss(desktop, "/meine-codes", "18-meine-codes-filter.png", {
vorher: async (page) => {
await page.getByLabel("Nach Art filtern").selectOption("static");
await page.waitForTimeout(600);
},
});
// Freigabe je Gruppe: eine zweite Gruppe, damit die Auswahl etwas zu wählen hat.
const gruppe = await api("/api/admin/groups", {
method: "POST",
json: { name: "Marketing" },
});
const gruppeId = gruppe.body?.group?.id ?? null;
if (gruppeId) {
// Ein Typ in dieser Gruppe abgeschaltet, damit das Bild den Fall zeigt,
// um den es geht.
await api("/api/admin/types", {
method: "PATCH",
json: { type: "payment", enabled: false, groupId: gruppeId },
});
}
await schuss(desktop, "/verwaltung/typen", "19-verwaltung-typen-gruppe.png", {
vorher: async (page) => {
if (gruppeId) {
await page.getByLabel("Freigabe für").selectOption(gruppeId);
await page.waitForTimeout(900);
}
},
});
// Kurzhilfe: nur der Dialog, die Seite dahinter trägt nichts bei.
await schuss(desktop, "/verwaltung/domains", "20-domain-kurzhilfe.png", {
bereich: "dialog[open]",
vorher: async (page) => {
await page.getByRole("button", { name: "Kurzhilfe" }).click();
await page.locator("dialog[open]").waitFor();
await page.waitForTimeout(400);
},
});
// Domain-Check: der Bericht zur eigenen Adresse. Weil BASE_URL und der Port
// des Containers hier zusammenpassen, geht die Prüfung durch — genau der
// Fall, den die Anleitung zeigen soll.
await schuss(desktop, "/verwaltung/domains", "21-domain-check.png", {
vorher: async (page) => {
await page.getByRole("button", { name: "Prüfen" }).first().click();
await page
.locator("text=/Die Domain führt hierher|So kommt niemand hier an/")
.first()
.waitFor({ timeout: 30000 });
await page.waitForTimeout(400);
},
});
await browser.close();
/* --- Was über die Oberfläche entstand, kennt `angelegt` noch nicht --------- */
const bestand = await api("/api/codes");
for (const eintrag of bestand.body?.codes ?? []) {
if (!angelegt.some((vorhanden) => vorhanden.id === eintrag.id)) {
angelegt.push(eintrag);
}
}
// Die Abweichung und die Gruppe wieder zurücknehmen — das Skript soll eine
// Instanz so hinterlassen, wie es sie vorgefunden hat.
if (gruppeId) {
await api("/api/admin/types", {
method: "PATCH",
json: { type: "payment", enabled: true, groupId: gruppeId },
});
await api(`/api/admin/groups/${gruppeId}`, { method: "DELETE" });
}
/* ------------------------------------------------------------ Aufräumen */
for (const eintrag of angelegt) {
+215 -1
View File
@@ -1,7 +1,9 @@
# Anmeldung und Sitzungen — Durchsicht nach OWASP
Durchsicht der Anmeldung, der Sitzungsverwaltung und der Berechtigungen gegen
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026.
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026, ergänzt am 24.08.2026
um die Befunde einer externen Codeprüfung (Punkte 11 bis 14 und die drei
offenen Punkte am Ende).
Geprüft gegen:
@@ -163,6 +165,171 @@ Nutzung abgewiesen —, aber die Tabelle wuchs unbegrenzt.
**Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung)
wird beiläufig aufgeräumt.
### 11. OIDC wertete eine unbestätigte E-Mail als bestätigt
**Befund:** Der schwerste der externen Prüfung. Findet die OIDC-Rückkehr ein
lokales Konto mit derselben E-Mail-Adresse, hängt sie die Identität des
Anbieters daran — aber nur, wenn die Adresse **bestätigt** ist. Genau das
prüfte der Code auch, nur war die Bestätigung falsch berechnet:
```ts
emailVerified: payload.email_verified !== false
```
Damit galt nicht nur `true` als bestätigt, sondern auch ein **fehlender
Claim**, `null`, `0` und jede Zeichenkette. Ein Anbieter, der
`email_verified` gar nicht mitschickt — das ist erlaubt und kommt vor —,
hätte die Verknüpfung geöffnet. Wer sich dort eine fremde Adresse eintragen
kann, hätte damit das hiesige Konto übernommen, im schlimmsten Fall das eines
Administrators.
**Behoben:** Nur ein ausdrückliches `true` zählt. Alles andere führt zur
bisherigen Meldung „der Anbieter hat sie nicht bestätigt“, und die
Verknüpfung unterbleibt.
**Was das nicht war:** kein Angriff ohne Zugriff auf den Anbieter — das
ID-Token muss weiterhin gültig signiert sein und Aussteller, Empfänger und
Nonce bestehen.
### 12. Eine fehlende Angabe schaltete die Anmeldung ab
**Befund:** `isAuthEnabled()` verlangte den Wert `true`; alles andere hieß
„aus“. Eine vergessene oder falsch geschriebene Variable ergab damit eine
Anwendung, in der jeder Besucher alles darf — und nichts sah danach aus. Die
Compose-Vorgabe stand zusätzlich auf `false`.
**Behoben:** Umgedreht. Abgeschaltet wird nur, wer ausdrücklich `false`
setzt; ein fehlender Wert bedeutet „an“. Die Vorgaben in `docker-compose.yml`
und `.env.example` stehen auf `true`. Steht die Anmeldung aus, schreibt der
Container beim Start eine mehrzeilige Warnung ins Protokoll.
Der offene Betrieb bleibt möglich — er ist für Einzelplatz und LAN gedacht
und dokumentiert. Er ist nur nicht mehr das, was man versehentlich bekommt.
### 13. Wettlauf beim ersten Administrator über OIDC
**Befund:** Der OIDC-Pfad las `user.count()` und legte danach an. Zwei
gleichzeitige erste Anmeldungen konnten sich beide für die erste halten und
beide als Instanz-Administrator entstehen. Die Passwort-Ersteinrichtung
sicherte denselben Ablauf längst mit einer serialisierbaren Transaktion.
**Behoben:** Derselbe Schutz auch hier — Zählen und Anlegen liegen in einer
Transaktion mit `isolationLevel: "Serializable"`.
### 14. Die Herkunftsprüfung verglich das Schema nicht
**Befund:** `isSameOrigin()` verglich nur den Host. `http://example.com` galt
damit als eigen, obwohl die Anwendung unter `https://example.com` läuft.
**Behoben:** Das Schema wird mitverglichen, und es kommt aus `BASE_URL` — der
einen Angabe zum Ursprung, über die der Aufrufer nicht mitbestimmt.
**Zur Einordnung:** Die Prüfung vergleicht weiterhin gegen den `Host`- bzw.
`X-Forwarded-Host`-Header der Anfrage. Aus einem Browser heraus ist das nicht
zu missbrauchen — `Host` darf Skriptcode nicht setzen, und ein selbst
gesetztes `X-Forwarded-Host` macht die Anfrage zu einer, die erst einen
CORS-Vorabflug bestehen müsste, den die Anwendung nicht beantwortet. Das
verbleibende Unbehagen ist das Vertrauensmodell insgesamt; dazu unten mehr.
### 15. Die Kontosperre konnte den Besitzer aussperren
**Befund:** Acht Fehlversuche sperrten ein Konto für 15 Minuten — auch gegen
das **richtige** Passwort. Wer die Adresse eines Administrators kannte,
konnte die Sperre alle 15 Minuten erneuern und die Verwaltung praktisch
dauerhaft schließen. Ein Nachweis, dass der Anfragende der Kontoinhaber ist,
war dafür nicht nötig.
**Behoben:** Aus der Sperre wurde eine **Wartezeit**. Die ersten vier
Fehlversuche bleiben folgenlos, danach verdoppelt sie sich bis zu einer
Minute:
| Fehlversuche | Wartezeit |
| --- | --- |
| bis 4 | keine |
| 5 | 1 s |
| 7 | 4 s |
| 10 | 32 s |
| ab 11 | 60 s |
Für den Besitzer heißt das im schlimmsten Fall eine Minute statt einer
Viertelstunde Aussperrung. Für jemanden, der Passwörter durchprobiert, einen
Versuch pro Minute — das ist die eigentliche Aufgabe der Bremse, und die
erfüllt sie weiterhin.
Die Wartezeit wird **vor** der Passwortprüfung ausgewertet. Ein Versuch, der
ohnehin abgewiesen wird, kostet damit keine 128 MiB.
### 16. Die Bremsen ließen sich über eine Kopfzeile umgehen
**Befund:** Der Absender wurde aus `X-Forwarded-For` gelesen. Ohne
vorgeschalteten Proxy — und die mitgelieferte Compose-Konfiguration
veröffentlicht den Port direkt — wählt ihn jeder Aufrufer selbst und umgeht
damit Login- wie Feedback-Bremse.
**Behoben:** Drei Stufen statt einer, in `src/lib/auth/throttle.ts`:
1. **Je Absender** — nur noch, wenn `TRUST_PROXY=true` gesetzt ist. Ohne
diese Angabe wird die Kopfzeile gar nicht gelesen. Eine Bremse, die sich
mit einem selbst gewählten Wert umgehen lässt, ist keine; dann lieber
ehrlich keine.
2. **Je Konto** — die Wartezeit aus Punkt 15. Sie hängt an der Kennung des
Kontos, nicht an einer Kopfzeile, und ist deshalb nicht zu umgehen.
3. **Für die ganze Installation** — eine Obergrenze von 60 Passwortprüfungen
je Minute, und 120 Rückmeldungen je Minute. Das ist die Stufe, die den
Speicher schützt: Ohne sie könnte jemand mit genügend gleichzeitigen
Anfragen den Server allein über die Hash-Berechnung lahmlegen.
Was bleibt: Die Zähler liegen im Arbeitsspeicher eines Prozesses. Bei
mehreren Instanzen bräuchte es einen gemeinsamen Zähler — siehe „Offen“.
### 17. Skripte liefen ohne Content-Security-Policy
**Befund:** Die Policy bestand aus `frame-ancestors 'none'`. Gegen
eingeschleusten Skriptcode wirkte sie nicht.
**Behoben:** Eine vollständige Policy mit **Nonce je Anfrage**. Skripte
laufen nur, wenn sie den Nonce dieser einen Anfrage tragen oder von einem
Skript geladen wurden, das selbst einen hatte (`strict-dynamic`). Ein
Skript, das über eine Lücke in die Seite gerät, hat keinen — und läuft
nicht.
Dazu `default-src 'self'`, `object-src 'none'`, `base-uri 'self'`,
`form-action 'self'`. `frame-src` nennt ausschließlich die beiden
Video-Anbieter, die der Typ „Video“ einbetten kann.
**Zwei bewusste Lockerungen**, damit sie nicht als Versehen gelesen werden:
- `style-src` erlaubt weiterhin `'unsafe-inline'`. Ein Nonce wirkt nur auf
`<style>`-Blöcke, nicht auf `style="…"` an einzelnen Elementen; die
Oberfläche benutzt solche Attribute an rund einem Dutzend Stellen. Über
ein Stilattribut lässt sich kein Code ausführen, der Gewinn wäre gering.
- `media-src` erlaubt jede `https:`-Quelle. Der Typ „Video“ nimmt
ausdrücklich auch einen Direktlink auf eine Videodatei entgegen, und wo
die liegt, weiß nur der, der ihn einträgt.
`upgrade-insecure-requests` steht nur, wenn `BASE_URL` auf `https` zeigt —
auf einer bewusst über HTTP betriebenen Installation würde die Regel die
eigenen Ressourcen hochstufen und alles zerlegen.
### 18. Sicherheitsereignisse standen nirgends
**Befund:** Fehlversuche, OIDC-Verknüpfungen und die Vergabe von
Administratorrechten hinterließen keine Spur.
**Behoben:** `src/lib/auth/protokoll.ts` schreibt je Ereignis eine Zeile als
JSON auf die Standardausgabe — damit landet sie in `docker compose logs` und
lässt sich von dort weiterreichen, ohne dass die Anwendung ein Werkzeug
dafür kennen muss.
Protokolliert werden: fehlgeschlagene Anmeldungen samt Zahl der Versuche und
Wartezeit, abgelehnte OIDC-Verknüpfungen mit Grund, erfolgte Verknüpfungen
und jede Vergabe von Administratorrechten.
**Was ausdrücklich nicht hineingehört:** Passwörter, Sitzungstoken und
vollständige E-Mail-Adressen. Konten stehen mit ihrer Id da. Ein Protokoll
wird kopiert und verschickt und ist selten so gut geschützt wie die
Datenbank.
---
## Was schon vorher stimmte
@@ -191,10 +358,57 @@ 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
Bewusst nicht umgesetzt, mit Begründung.
**Die Zähler gelten je Prozess.** Bremsen und Wartezeiten liegen im
Arbeitsspeicher und sind nach einem Neustart weg. Bei **einer** Instanz — dem
Regelfall dieser Anwendung — ist das verschmerzbar: Die Wartezeit je Konto
steht in der Datenbank und übersteht den Neustart, nur die Zähler je Absender
und die Obergrenze der Instanz beginnen von vorn.
Wer mehrere Instanzen hinter einem Lastverteiler betreibt, braucht einen
gemeinsamen, atomaren Zähler — üblicherweise Redis. Das ist ein weiterer
Dienst und lohnt sich erst, wenn es die zweite Instanz wirklich gibt.
**Prisma hängt an einer Advisory.** `npm audit --omit=dev` meldet
[GHSA-ggr8-5vv4-36mx](https://github.com/advisories/GHSA-ggr8-5vv4-36mx) in
`deepmerge-ts@7.1.5`, mitgeschleppt über `@prisma/config`. Es geht um
Stapelüberlauf beim Zusammenführen rekursiver Objekte.
Aus HTTP-Eingaben entsteht hier keine Prisma-Konfiguration, die praktische
Ausnutzbarkeit ist also gering. Die Prisma-CLI liegt aber im Laufzeit-Abbild
und läuft bei jedem Containerstart für die Migrationen — deshalb steht der
Punkt hier und nicht unter „egal“. Eine Fassung ohne die Advisory gibt es
noch nicht: 7.9.1 ist die neueste und hängt weiterhin an `deepmerge-ts@7.1.5`.
`npm audit fix --force` würde auf Prisma 6 zurückstufen und ist deshalb keine
Lösung. Zu tun: beobachten und aktualisieren, sobald es eine passende Fassung
gibt.
**E-Mail-Verifizierung.** Es gibt keine — und sie würde derzeit auch nichts
bringen. Die Anwendung kennt **keine Selbstregistrierung**: Es gibt nur
`login`, `setup`, `logout` und `change-password`. Konten legt ein
+69 -6
View File
@@ -1,12 +1,12 @@
{
"name": "qrcode-generator",
"version": "1.1.0",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qrcode-generator",
"version": "1.1.0",
"version": "1.6.0",
"dependencies": {
"@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1",
@@ -17,6 +17,7 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -566,7 +567,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -2065,6 +2065,72 @@
"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": {
"version": "4.3.3",
"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",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -7795,7 +7860,6 @@
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
@@ -7845,7 +7909,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
+3 -1
View File
@@ -1,11 +1,12 @@
{
"name": "qrcode-generator",
"version": "1.1.0",
"version": "1.7.1",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"changelog": "node scripts/changelog.mjs",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
@@ -23,6 +24,7 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -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;
+54 -3
View File
@@ -87,6 +87,7 @@ model Group {
memberships Membership[]
codes QrCode[]
domainGrants DomainGrant[]
typeSettings QrTypeGroupSetting[]
}
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
@@ -155,6 +156,31 @@ model QrTypeSetting {
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
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
///
@@ -162,10 +188,18 @@ model QrTypeSetting {
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
model QrCode {
id String @id @default(cuid())
/// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
/// Statisch oder dynamisch. Ein statischer Code traegt seinen Inhalt selbst
/// 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.
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
label String?
data Json
@@ -174,6 +208,14 @@ model QrCode {
size Int @default(512)
ecLevel String @default("M")
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)
lastScanAt DateTime?
createdAt DateTime @default(now())
@@ -202,6 +244,7 @@ model QrCode {
@@index([createdAt])
@@index([groupId])
@@index([domainId])
@@index([mode])
}
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
@@ -253,6 +296,14 @@ model InstanceSetting {
/// 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
}
+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.");
}
+27 -5
View File
@@ -5,6 +5,7 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
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";
@@ -52,7 +53,12 @@ function Hinweis({
export default async function CreatePage({ params }: Props) {
const actor = await requirePageActor();
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) {
notFound();
@@ -63,7 +69,11 @@ export default async function CreatePage({ params }: Props) {
<Hinweis
icon="lock"
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."
}
/>
);
}
@@ -82,8 +92,20 @@ export default async function CreatePage({ params }: Props) {
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
// noch einmal.
const { editorsMaySetSlug } = await getSettings();
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
const maySetSlug = can(actor, "codes:slug", {
groupId: zielgruppe,
editorsMaySetSlug,
});
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
// 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 { can } from "@/lib/auth/policy";
import { getBranding } from "@/lib/branding";
import { APP_VERSION } from "@/lib/changelog";
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
// werden.
@@ -128,9 +129,20 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
<main className="flex-1">{children}</main>
<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">
{branding.appName}
{branding.footerText ? ` · ${branding.footerText}` : null}
<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">
<span>
{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>
</footer>
</div>
+3 -3
View File
@@ -27,9 +27,9 @@ export default async function MyCodesPage() {
Meine Codes
</h1>
<p className="mt-2 text-slate-600">
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der
Scans. Statische Codes erscheinen hier nicht — sie werden nicht
gespeichert.
Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans,
dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“
lässt sich die Liste auf eine der beiden Arten einschränken.
</p>
</div>
+4 -3
View File
@@ -3,15 +3,16 @@ import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge, Notice } from "@/components/ui";
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
// änderbar — die Startseite darf deshalb nicht vorgerendert werden.
export const dynamic = "force-dynamic";
export default async function HomePage() {
await requirePageActor();
const types = await getVisibleQrTypes();
const actor = await requirePageActor();
// Wer in mehreren Gruppen ist, sieht die Vereinigung ihrer Freigaben.
const types = await getVisibleQrTypesForActor(actor);
const usableCount = types.filter((entry) => entry.usable).length;
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>
);
}
+15 -2
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Icon } from "@/components/icons";
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
import { requirePagePermission } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { getQrTypeAvailability } from "@/lib/qr/availability";
export const metadata: Metadata = { title: "QR-Typen" };
@@ -15,16 +16,23 @@ export default async function TypeSettingsPage() {
const availability = await getQrTypeAvailability();
const initial: TypeSetting[] = availability.map(
({ qrType, implemented, enabled }) => ({
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup }) => ({
id: qrType.id,
title: qrType.title,
description: qrType.description,
icon: qrType.icon,
implemented,
enabled,
enabledGlobally,
disabledInGroup,
}),
);
const groups = await prisma.group.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
@@ -44,6 +52,11 @@ export default async function TypeSettingsPage() {
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
anlegen.
</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">
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
@@ -51,7 +64,7 @@ export default async function TypeSettingsPage() {
</p>
</div>
<TypeSettings initial={initial} />
<TypeSettings initial={initial} groups={groups} />
</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);
}
}
+47 -14
View File
@@ -4,9 +4,11 @@ import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { prisma } from "@/lib/db";
import {
getQrTypeAvailability,
setQrTypeEnabled,
setQrTypeEnabledForGroup,
} from "@/lib/qr/availability";
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
@@ -15,23 +17,45 @@ export const dynamic = "force-dynamic";
const updateSchema = z.object({
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
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 {
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({
types: types.map(({ qrType, implemented, enabled, usable }) => ({
id: qrType.id,
title: qrType.title,
description: qrType.description,
icon: qrType.icon,
implemented,
enabled,
usable,
})),
groupId: groupId ?? null,
types: types.map(
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup, usable }) => ({
id: qrType.id,
title: qrType.title,
description: qrType.description,
icon: qrType.icon,
implemented,
enabled,
enabledGlobally,
disabledInGroup,
usable,
}),
),
});
} catch (error) {
return errorResponse(error);
@@ -48,8 +72,17 @@ export async function PATCH(request: NextRequest): Promise<Response> {
return jsonError(firstZodMessage(parsed.error));
}
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled);
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
const { type, enabled, groupId } = parsed.data;
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) {
return errorResponse(error);
}
+43 -14
View File
@@ -11,6 +11,7 @@ import {
type OidcIdentity,
} from "@/lib/auth/oidc";
import { createSession } from "@/lib/auth/session";
import { sicherheitsereignis } from "@/lib/auth/protokoll";
import { prisma } from "@/lib/db";
import { getBaseUrl } from "@/lib/env";
@@ -100,6 +101,12 @@ async function findeOderLegeAn(
// jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
// übernehmen.
if (!identity.emailVerified) {
// Der Versuch gehört ins Protokoll: Wenn er gehäuft auftritt, stimmt
// entweder die Anbieterkonfiguration nicht — oder jemand probiert es.
sicherheitsereignis("oidc-verknuepfung-abgelehnt", {
konto: nachEmail.id,
grund: "email-nicht-bestaetigt",
});
return {
fehler:
"Es gibt hier schon ein Konto mit dieser E-Mail-Adresse, der Anbieter hat sie aber nicht bestätigt.",
@@ -116,6 +123,12 @@ async function findeOderLegeAn(
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
},
});
// Eine Verknüpfung verschiebt eine Vertrauensgrenze: Ab jetzt kommt man
// über den Anbieter in dieses Konto. Das soll nachvollziehbar sein.
sicherheitsereignis("oidc-konto-verknuepft", {
konto: verknuepft.id,
administrator: verknuepft.isInstanceAdmin,
});
return { id: verknuepft.id, isActive: verknuepft.isActive };
}
@@ -128,21 +141,37 @@ async function findeOderLegeAn(
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
const istErster = (await prisma.user.count()) === 0;
const angelegt = await prisma.user.create({
data: {
email: identity.email,
name: identity.name,
passwordHash: null,
oidcSubject: identity.subject,
isInstanceAdmin: istAdmin ?? istErster,
lastLoginAt: new Date(),
memberships: standardgruppe
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
: undefined,
//
// Serialisierbar, aus demselben Grund wie dort: Zwischen „gibt es schon
// jemanden?" und dem Anlegen könnte sonst eine zweite Anmeldung
// dazwischenkommen, und beide würden sich für die erste halten. Am Ende
// stünden zwei Administratoren da, von denen einer ungebeten ist.
const angelegt = await prisma.$transaction(
async (tx) => {
const istErster = (await tx.user.count()) === 0;
return tx.user.create({
data: {
email: identity.email,
name: identity.name,
passwordHash: null,
oidcSubject: identity.subject,
isInstanceAdmin: istAdmin ?? istErster,
lastLoginAt: new Date(),
memberships: standardgruppe
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
: undefined,
},
});
},
});
{ isolationLevel: "Serializable" },
);
if (angelegt.isInstanceAdmin) {
sicherheitsereignis("administrator-vergeben", {
konto: angelegt.id,
weg: istAdmin === undefined ? "erster-benutzer" : "gruppe-des-anbieters",
});
}
return { id: angelegt.id, isActive: angelegt.isActive };
}
+57 -22
View File
@@ -17,18 +17,31 @@ import {
} from "@/lib/auth/session";
import {
clearAttempts,
kontoWartezeit,
requestOrigin,
teureRechnungErlaubt,
tooManyAttempts,
verbleibendeKontowartezeit,
} from "@/lib/auth/throttle";
import { sicherheitsereignis } from "@/lib/auth/protokoll";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
/** Sperrdauer nach zu vielen Fehlversuchen. */
const MAX_ATTEMPTS = 8;
const LOCK_MINUTES = 15;
/**
* Wartezeit statt Sperre.
*
* Früher stand hier eine Sperre von 15 Minuten nach acht Fehlversuchen. Die
* hatte einen Haken: Sie wies auch das **richtige** Passwort ab. Wer die
* Adresse eines Administrators kannte, konnte ihn damit alle 15 Minuten neu
* aussperren und die Verwaltung praktisch dauerhaft schließen.
*
* Jetzt wächst nur die Wartezeit zwischen zwei Versuchen, bis zu einer
* Minute — siehe `kontoWartezeit`. Der Besitzer kommt spätestens nach einer
* Minute hinein, ein Fremder bekommt einen Versuch pro Minute.
*/
/**
* Wird gegen einen nicht vorhandenen Benutzer gerechnet, damit die
@@ -248,14 +261,31 @@ async function handleSetup(
async function handleLogin(
input: z.infer<typeof loginSchema>,
absender: string,
/** Absender laut vertrauenswürdigem Proxy — `null`, wenn keiner davorsteht. */
absender: string | null,
): Promise<Response> {
// Vor allem anderen: Wer viele Konten durchprobiert, löst nie eine
// Kontosperre aus — dagegen hilft nur eine Bremse je Absender.
const wartezeit = tooManyAttempts(`login:${absender}`);
if (wartezeit !== null) {
// Wer viele Konten durchprobiert, löst nie eine Wartezeit für ein
// einzelnes Konto aus — dagegen hilft nur eine Bremse je Absender. Sie
// greift nur hinter einem Proxy, dem ausdrücklich vertraut wird; sonst
// wählte der Aufrufer seine Kennung selbst.
if (absender !== null) {
const wartezeit = tooManyAttempts(`login:${absender}`);
if (wartezeit !== null) {
throw new QrRequestError(
`Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`,
429,
);
}
}
// Der Deckel der ganzen Installation. Er steht vor jeder Passwortprüfung,
// auch vor der gegen den Attrappen-Hash: Beide kosten gleich viel Speicher,
// und wer sie auslösen darf, könnte den Server sonst allein damit
// lahmlegen. Das ist die einzige Bremse, die sich nicht über Kopfzeilen
// umgehen lässt.
if (!teureRechnungErlaubt()) {
throw new QrRequestError(
`Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`,
"Zurzeit gehen sehr viele Anmeldeversuche ein. Bitte versuche es in einer Minute erneut.",
429,
);
}
@@ -275,12 +305,15 @@ async function handleLogin(
throw invalid;
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const minutes = Math.ceil(
(user.lockedUntil.getTime() - Date.now()) / 60000,
);
// Wartezeit des Kontos — **vor** der Passwortprüfung, damit ein Versuch,
// der ohnehin abgewiesen wird, keine 128 MiB kostet.
const kontoWarten = verbleibendeKontowartezeit(
user.failedLogins,
user.lockedUntil,
);
if (kontoWarten > 0) {
throw new QrRequestError(
`Zu viele Fehlversuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.`,
`Zu viele Fehlversuche. Bitte versuche es in ${kontoWarten} Sekunde${kontoWarten === 1 ? "" : "n"} erneut.`,
429,
);
}
@@ -295,15 +328,17 @@ async function handleLogin(
if (!(await verifyPassword(input.password, user.passwordHash))) {
const failedLogins = user.failedLogins + 1;
// `lockedUntil` heißt jetzt „frühestens wieder ab“: der Zeitpunkt des
// Fehlversuchs. Wie lange das bedeutet, rechnet `kontoWartezeit` aus der
// Zahl der Fehlversuche — so bleibt die Spalte, aber nicht die Sperre.
await prisma.user.update({
where: { id: user.id },
data: {
failedLogins,
lockedUntil:
failedLogins >= MAX_ATTEMPTS
? new Date(Date.now() + LOCK_MINUTES * 60 * 1000)
: null,
},
data: { failedLogins, lockedUntil: new Date() },
});
sicherheitsereignis("anmeldung-fehlgeschlagen", {
konto: user.id,
fehlversuche: failedLogins,
wartezeit: kontoWartezeit(failedLogins),
});
throw invalid;
}
@@ -333,7 +368,7 @@ async function handleLogin(
},
});
clearAttempts(`login:${absender}`);
if (absender !== null) clearAttempts(`login:${absender}`);
await createSession(user.id);
return Response.json({ ok: true, email: user.email });
}
+14 -3
View File
@@ -4,9 +4,11 @@ import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { shortUrl } from "@/lib/env";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { errorResponse } from "@/lib/http";
import { designSchema } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry";
import { ladeLogo } from "@/lib/qr/logo";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
@@ -47,13 +49,22 @@ export async function GET(
size: code.size,
ecLevel: code.ecLevel,
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 =
format === "png"
? new Uint8Array(await renderPng(content, design))
: await renderSvg(content, design);
? new Uint8Array(await renderPng(content, design, logo))
: await renderSvg(content, design, logo);
const headers: Record<string, string> = {
"Content-Type": CONTENT_TYPES[format],
+44 -3
View File
@@ -13,7 +13,7 @@ import { assertDynamicPossible } from "@/lib/qr/request";
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
import { assertSlugAvailable } from "@/lib/slug-resolve";
import { getSettings } from "@/lib/settings";
import { attachUploads } from "@/lib/uploads";
import { attachUpload, attachUploads, detachUpload } from "@/lib/uploads";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
@@ -96,6 +96,15 @@ export async function PATCH(
/** Dateien, die nach der Änderung zu diesem Code gehören. */
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) {
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
// Verwaltung abgeschalteter Typ soll bestehende Codes nicht
@@ -124,11 +133,31 @@ export async function PATCH(
Object.assign(update, parsed.data.design);
}
// 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);
/**
@@ -191,7 +220,8 @@ export async function PATCH(
await tx.qrCodeAlias.create({
data: {
domainId: existing.domainId,
slug: existing.slug,
// `umbenannt` gilt nur bei vorhandenem Kurz-Link.
slug: existing.slug as string,
qrCodeId: existing.id,
},
});
@@ -207,7 +237,18 @@ export async function PATCH(
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
// später als verwaist weggeräumt.
if (neueDateien !== null) {
await attachUploads(updated.id, neueDateien);
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) });
+95 -4
View File
@@ -7,6 +7,7 @@ import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { resolveTargetDomain } from "@/lib/domains";
import { findStaticDuplicate } from "@/lib/duplicates";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
@@ -28,6 +29,12 @@ const createSchema = z.object({
.trim()
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.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.
@@ -37,8 +44,32 @@ const createSchema = z.object({
groupId: z.string().trim().min(1).optional(),
/** Unter welcher Domain die Kurz-URL entsteht. */
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 {
return (
typeof error === "object" &&
@@ -94,15 +125,33 @@ export async function POST(request: NextRequest): Promise<Response> {
// ist — und der Slug ist nur je Domain eindeutig.
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);
if (!validated.ok) {
return jsonError(validated.message);
}
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
assertDynamicPossible(qrType, validated.data);
const istStatisch = parsed.data.mode === "static";
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.
@@ -117,6 +166,48 @@ export async function POST(request: NextRequest): Promise<Response> {
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.
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
@@ -140,7 +231,7 @@ export async function POST(request: NextRequest): Promise<Response> {
});
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
// 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 });
} catch (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 });
}
}
+38 -5
View File
@@ -1,6 +1,7 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { requestOrigin } from "@/lib/auth/throttle";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
@@ -31,6 +32,31 @@ const FENSTER_MS = 60_000;
const MAX_PRO_FENSTER = 10;
const zaehler = new Map<string, { anzahl: number; bis: number }>();
/**
* Obergrenze für die ganze Installation.
*
* Die Bremse je Absender greift nur hinter einem Proxy, dem vertraut wird —
* sonst wählt der Aufrufer seine Kennung selbst. Dieser Deckel gilt immer
* und begrenzt, wie schnell die Tabelle überhaupt wachsen kann.
*
* Großzügig bemessen: Selbst ein gut besuchter Aushang bekommt keine
* hundertzwanzig Rückmeldungen in der Minute.
*/
const MAX_PRO_MINUTE_INSGESAMT = 120;
const MINUTE_MS = 60 * 1000;
let gesamtFenster = 0;
let gesamtAnzahl = 0;
function insgesamtZuSchnell(): boolean {
const jetzt = Date.now();
if (jetzt - gesamtFenster > MINUTE_MS) {
gesamtFenster = jetzt;
gesamtAnzahl = 0;
}
gesamtAnzahl += 1;
return gesamtAnzahl > MAX_PRO_MINUTE_INSGESAMT;
}
function zuSchnell(schluessel: string): boolean {
const jetzt = Date.now();
const eintrag = zaehler.get(schluessel);
@@ -58,12 +84,19 @@ export async function POST(request: NextRequest): Promise<Response> {
return jsonError(firstZodMessage(parsed.error));
}
const herkunft =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"unbekannt";
// Erst der Deckel für alle — er lässt sich nicht über Kopfzeilen umgehen.
if (insgesamtZuSchnell()) {
return jsonError(
"Zurzeit gehen sehr viele Rückmeldungen ein. Bitte versuche es gleich noch einmal.",
429,
);
}
if (zuSchnell(herkunft)) {
// Dann die Bremse je Absender. Sie sagt nur etwas aus, wenn ein Proxy
// davorsteht, dem ausdrücklich vertraut wird; sonst wählte der Aufrufer
// seine Kennung selbst und die Bremse wäre eine Behauptung.
const herkunft = requestOrigin(request.headers);
if (herkunft !== null && zuSchnell(herkunft)) {
return jsonError(
"Zu viele Rückmeldungen in kurzer Zeit. Bitte versuche es gleich noch einmal.",
429,
+5 -3
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { ladeLogo } from "@/lib/qr/logo";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import {
downloadFilename,
@@ -38,15 +39,16 @@ export async function POST(request: NextRequest): Promise<Response> {
const { qrType, data, content } = await resolveQrRequest(parsed.data);
const logo = await ladeLogo(design);
const body =
format === "png"
? new Uint8Array(await renderPng(content, design))
: await renderSvg(content, design);
? new Uint8Array(await renderPng(content, design, logo))
: await renderSvg(content, design, logo);
return new Response(body, {
headers: {
"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",
},
});
+3 -1
View File
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { ladeLogo } from "@/lib/qr/logo";
import { renderSvg } from "@/lib/qr/render";
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 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({
content,
+11 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
import { accentCss } from "@/lib/branding-colors";
@@ -47,6 +48,11 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function RootLayout({ children }: LayoutProps<"/">) {
const branding = await getBranding();
// Der Nonce dieser Anfrage, gesetzt in `src/proxy.ts`. Ohne ihn lehnt der
// Browser die beiden eingebetteten Blöcke unten ab — sie sind die einzigen
// der Anwendung.
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html lang="de" className="h-full">
<body className="flex min-h-full flex-col">
@@ -55,7 +61,10 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
Wer der Systemeinstellung folgt, braucht das nicht — das erledigt
`light-dark()` im CSS von allein.
*/}
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
<script
nonce={nonce}
dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }}
/>
{/*
Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit
wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine
@@ -64,6 +73,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
die Voreinstellung zurück.
*/}
<style
nonce={nonce}
dangerouslySetInnerHTML={{ __html: accentCss(branding.accentColor) }}
/>
{children}
+11
View File
@@ -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 (
<Landing
data={code.data}
+184 -74
View File
@@ -43,6 +43,8 @@ function formatShortDate(value: string | null): string {
type View = "list" | "cards";
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> = {
neueste: "Neueste zuerst",
@@ -51,6 +53,12 @@ const SORT_LABELS: Record<SortKey, string> = {
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. */
const VIEW_STORAGE_KEY = "qrgen.codes.view";
@@ -94,6 +102,7 @@ export function CodesList({
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortKey>("neueste");
const [groupFilter, setGroupFilter] = useState("alle");
const [modeFilter, setModeFilter] = useState<ModeFilter>("alle");
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
@@ -107,6 +116,14 @@ export function CodesList({
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 () => {
try {
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
@@ -133,7 +150,7 @@ export function CodesList({
setEditing(code);
setEditData(code.data);
setEditLabel(code.label ?? "");
setEditSlug(code.slug);
setEditSlug(code.slug ?? "");
setEditAliases(code.aliases);
setEditError(null);
};
@@ -141,7 +158,9 @@ export function CodesList({
const slugProblem =
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
const slugGeaendert =
editing !== null && editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
editing !== null &&
editing.slug !== null &&
editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
const freigegeben = editing
? editing.aliases.filter((alias) => !editAliases.includes(alias))
: [];
@@ -168,12 +187,18 @@ export function CodesList({
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
method: "PATCH",
json: {
data: editValidation.data,
label: editLabel.trim() || null,
// Nur mitschicken, wenn es überhaupt bearbeitet werden durfte.
...(maySetSlug
? { slug: editSlug.trim(), aliases: editAliases }
: {}),
// 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);
@@ -209,6 +234,7 @@ export function CodesList({
};
const copy = async (code: QrCodeDto) => {
if (!code.shortUrl) return;
try {
await navigator.clipboard.writeText(code.shortUrl);
setCopiedId(code.id);
@@ -232,11 +258,25 @@ export function CodesList({
);
}, [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 needle = query.trim().toLowerCase();
const filtered = (codes ?? []).filter((code) => {
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
if (modeFilter !== "alle" && code.mode !== modeFilter) return false;
if (!needle) return true;
return [
displayName(code),
@@ -264,7 +304,7 @@ export function CodesList({
}
});
return sorted;
}, [codes, query, sort, groupFilter]);
}, [codes, query, sort, groupFilter, modeFilter]);
/* ---------------------------------------------------------------- View */
@@ -278,6 +318,10 @@ export function CodesList({
const svgHref = (code: QrCodeDto) =>
`/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 && (
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1 basis-56">
@@ -311,17 +355,36 @@ export function CodesList({
</Select>
) : 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
aria-label="Sortierung"
className="!w-auto"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
>
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => (
<option key={key} value={key}>
{SORT_LABELS[key]}
</option>
))}
{(Object.keys(SORT_LABELS) as SortKey[])
.filter((key) => key !== "scans" || !nurStatische)
.map((key) => (
<option key={key} value={key}>
{SORT_LABELS[key]}
</option>
))}
</Select>
<div
@@ -397,12 +460,13 @@ export function CodesList({
{codes.length > 0 && visible.length === 0 ? (
<Card className="text-center">
<p className="text-sm text-slate-600">
Kein Code passt zur Suche.{" "}
Kein Code passt zu Suche und Filtern.{" "}
<button
type="button"
onClick={() => {
setQuery("");
setGroupFilter("alle");
setModeFilter("alle");
}}
className="font-medium text-accent-text underline"
>
@@ -437,9 +501,11 @@ export function CodesList({
Gruppe
</th>
) : null}
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
Scans
</th>
{nurStatische ? null : (
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
Scans
</th>
)}
<th
scope="col"
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
@@ -479,9 +545,18 @@ export function CodesList({
<div className="truncate text-xs text-slate-500">
{/* Nur der Pfad: Der Hostname ist auf dem Handy
gleich für alle Zeilen, der Slug unterscheidet. */}
<span className="font-mono md:hidden" title={code.shortUrl}>
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
</span>
{code.shortUrl ? (
<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">
{qrTypes[code.type]?.title ?? code.type}
</span>
@@ -491,28 +566,34 @@ export function CodesList({
</td>
<td className="hidden px-3 py-2.5 md:table-cell">
<div className="flex items-center gap-1.5">
<a
href={code.shortUrl}
target="_blank"
rel="noreferrer"
className="truncate font-mono text-xs text-accent-text hover:underline"
title={code.shortUrl}
>
{code.shortUrl.replace(/^https?:\/\//, "")}
</a>
<button
type="button"
onClick={() => void copy(code)}
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"}
className="size-3.5"
/>
</button>
</div>
{code.shortUrl ? (
<div className="flex items-center gap-1.5">
<a
href={code.shortUrl}
target="_blank"
rel="noreferrer"
className="truncate font-mono text-xs text-accent-text hover:underline"
title={code.shortUrl}
>
{code.shortUrl.replace(/^https?:\/\//, "")}
</a>
<button
type="button"
onClick={() => void copy(code)}
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"}
className="size-3.5"
/>
</button>
</div>
) : (
// Ein statischer Code hat keine Kurz-URL — sein Inhalt
// steckt in ihm selbst.
<Badge tone="neutral">statisch</Badge>
)}
</td>
<td className="hidden px-3 py-2.5 sm:table-cell">
@@ -527,7 +608,13 @@ export function CodesList({
{code.target.replace(/^https?:\/\//, "")}
</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}
</span>
)}
@@ -541,9 +628,20 @@ export function CodesList({
</td>
) : null}
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
{code.scans}
</td>
{nurStatische ? null : (
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
{code.mode === "static" ? (
<span
className="font-normal text-slate-400"
title="Statische Codes werden nicht gezählt"
>
—
</span>
) : (
code.scans
)}
</td>
)}
<td
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
@@ -640,30 +738,41 @@ export function CodesList({
</div>
<dl className="mt-3 space-y-2 text-sm">
<div className="flex flex-wrap items-baseline gap-x-2">
<dt className="text-slate-500">Kurz-URL:</dt>
<dd className="flex items-center gap-2">
<a
href={code.shortUrl}
target="_blank"
rel="noreferrer"
className="font-mono text-xs break-all text-accent-text hover:underline"
>
{code.shortUrl}
</a>
<button
type="button"
onClick={() => void copy(code)}
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"}
className="size-4"
/>
</button>
</dd>
</div>
{code.shortUrl ? (
<div className="flex flex-wrap items-baseline gap-x-2">
<dt className="text-slate-500">Kurz-URL:</dt>
<dd className="flex items-center gap-2">
<a
href={code.shortUrl}
target="_blank"
rel="noreferrer"
className="font-mono text-xs break-all text-accent-text hover:underline"
>
{code.shortUrl}
</a>
<button
type="button"
onClick={() => void copy(code)}
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"}
className="size-4"
/>
</button>
</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">
<dt className="text-slate-500">Ziel:</dt>
@@ -772,11 +881,12 @@ export function CodesList({
{editing ? (
<div className="space-y-4">
<p className="text-sm text-slate-600">
Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt
gültig, solange der Kurz-Link derselbe ist.
{editing.mode === "static"
? "Ein statischer Code trägt seinen Inhalt selbst — änderbar ist deshalb nur der Name, unter dem du ihn hier wiederfindest."
: "Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt gültig, solange der Kurz-Link derselbe ist."}
</p>
{EditForm ? (
{editing.mode === "static" ? null : EditForm ? (
<EditForm
value={editData}
onChange={(value) => {
@@ -792,7 +902,7 @@ export function CodesList({
</Notice>
)}
{maySetSlug ? (
{maySetSlug && editing.mode !== "static" ? (
<>
<Field
label="Kurz-Link"
+160 -4
View File
@@ -1,15 +1,20 @@
"use client";
import { Icon } from "@/components/icons";
import { FileField } from "@/components/qr-forms/file-field";
import { Field, Notice, TextInput, cx } from "@/components/ui";
import { evaluateContrast } from "@/lib/qr/contrast";
import {
COLOR_PRESETS,
EC_LEVELS,
EC_LEVEL_LABELS,
LOGO_SCALE_WARN,
MAX_LOGO_SCALE,
MAX_SIZE,
MIN_LOGO_SCALE,
MIN_SIZE,
type EcLevel,
type LogoSource,
type QrDesign,
} from "@/lib/qr/design";
@@ -62,9 +67,12 @@ function ColorField({
export function DesignPanel({
design,
onChange,
/** Ist unter „Erscheinungsbild“ ein Logo hinterlegt? Sonst entfällt die Wahl. */
hasBrandingLogo = false,
}: {
design: QrDesign;
onChange: (design: QrDesign) => void;
hasBrandingLogo?: boolean;
}) {
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
const contrast = evaluateContrast(design.fgColor, design.bgColor);
@@ -165,7 +173,11 @@ export function DesignPanel({
<Field
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
role="radiogroup"
@@ -177,11 +189,14 @@ export function DesignPanel({
key={level}
type="button"
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 })}
className={cx(
"rounded-lg border py-2 text-sm font-medium transition-colors",
design.ecLevel === level
"rounded-lg border py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
(design.logoSource === null ? design.ecLevel : "H") === level
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
: "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"
/>
</Field>
<LogoAuswahl
design={design}
patch={patch}
hasBrandingLogo={hasBrandingLogo}
/>
</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 { DomainBericht } from "@/components/domain-bericht";
import { DomainHilfe } from "@/components/domain-hilfe";
import { HelpButton } from "@/components/help-button";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import {
@@ -14,6 +17,7 @@ import {
TextInput,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
import type { Pruefbericht } from "@/lib/domain-check";
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);
/** 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 () => {
try {
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) {
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
weiterleitet. Die Weiterleitung erkennt sie am{" "}
<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>
<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)}>
Domain registrieren
</Button>
@@ -192,6 +224,16 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
<Icon name="pencil" className="size-3.5" />
Gruppen zuteilen
</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
type="button"
variant="secondary"
@@ -213,6 +255,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
Löschen
</Button>
</div>
{reports[domain.id] ? (
<DomainBericht bericht={reports[domain.id]} />
) : null}
</div>
</div>
</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" />
</>
),
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;
export type IconName = keyof typeof PATHS;
+6 -1
View File
@@ -9,12 +9,15 @@ export function Modal({
onClose,
children,
footer,
size = "md",
}: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
/** `lg` für längere Texte wie eine Kurzhilfe. */
size?: "md" | "lg";
}) {
const ref = useRef<HTMLDialogElement>(null);
@@ -39,7 +42,9 @@ export function Modal({
// Klick auf die Hintergrundfläche des Dialogs schließt ihn.
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">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
+152 -26
View File
@@ -43,13 +43,31 @@ function sameData(a: unknown, b: unknown): boolean {
return stableStringify(a) === stableStringify(b);
}
/** 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 Form = qrForms[typeId];
@@ -65,6 +83,14 @@ export function QrGenerator({
const [saved, setSaved] = useState<QrCodeDto | null>(null);
const [saving, setSaving] = useState(false);
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 [preview, setPreview] = useState<PreviewState | null>(null);
@@ -84,11 +110,13 @@ export function QrGenerator({
const dataError = !validation.ok && touched ? validation.message : null;
const mode = isDynamic ? "dynamic" : "static";
const slug = isDynamic ? saved?.slug : undefined;
const slug = (isDynamic ? saved?.slug : undefined) ?? undefined;
const requestBody = useMemo(
() => ({ type: typeId, data, design, mode, slug }),
[typeId, data, design, mode, slug],
// Der Name geht mit: Der Server bildet daraus den Dateinamen beim
// 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;
@@ -185,17 +213,29 @@ export function QrGenerator({
!sameData(saved.design, design) ||
(saved.label ?? "") !== label.trim());
const handleSave = useCallback(async () => {
// Nur zeigen, solange der Inhalt derselbe ist, für den die Meldung kam.
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);
setSaveError(null);
setDuplicate(null);
try {
const body = {
type: typeId,
data: validation.data,
design,
mode,
label: label.trim() || undefined,
slug: wunschSlug.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
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
@@ -208,18 +248,40 @@ export function QrGenerator({
});
setSaved(result.code);
} catch (error) {
setSaveError(
error instanceof ApiError
? error.message
: "Der Code konnte nicht gespeichert werden.",
);
// Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage.
const gemeldet =
error instanceof ApiError && error.status === 409
? 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 {
setSaving(false);
}
}, [validation, designValid, slugProblem, typeId, design, label, wunschSlug, 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 () => {
if (!saved) return;
if (!saved?.shortUrl) return;
try {
await navigator.clipboard.writeText(saved.shortUrl);
setCopied(true);
@@ -302,7 +364,7 @@ export function QrGenerator({
data-mode="static"
aria-pressed={!isDynamic}
disabled={!qrType.supportsStatic}
onClick={() => setIsDynamic(false)}
onClick={() => wechsleModus(false)}
className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
!isDynamic
@@ -326,7 +388,7 @@ export function QrGenerator({
data-mode="dynamic"
aria-pressed={isDynamic}
disabled={!qrType.supportsDynamic}
onClick={() => setIsDynamic(true)}
onClick={() => wechsleModus(true)}
className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
isDynamic
@@ -347,12 +409,15 @@ export function QrGenerator({
</button>
</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
label="Name (optional)"
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
id="code-label"
@@ -363,9 +428,7 @@ export function QrGenerator({
/>
</Field>
{/* Nur beim Anlegen: Danach steht der Kurz-Link im
Bearbeiten-Dialog, wo auch die früheren Adressen liegen. */}
{saved || !maySetSlug ? null : (
{!isDynamic || saved || !maySetSlug ? null : (
<Field
label="Kurz-Link (optional)"
htmlFor="code-slug"
@@ -384,7 +447,67 @@ export function QrGenerator({
</Field>
)}
{saved ? (
{!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">
<Notice tone="success">
<span className="font-medium">Code gespeichert.</span> Der
@@ -412,7 +535,7 @@ export function QrGenerator({
{hasUnsavedChanges ? (
<Button
type="button"
onClick={handleSave}
onClick={() => void handleSave()}
disabled={saving || !ready}
>
{saving ? "Speichert …" : "Änderungen speichern"}
@@ -444,7 +567,7 @@ export function QrGenerator({
</Notice>
<Button
type="button"
onClick={handleSave}
onClick={() => void handleSave()}
disabled={saving || !ready || Boolean(slugProblem)}
>
{saving ? "Speichert …" : "Dynamischen Code speichern"}
@@ -453,8 +576,7 @@ export function QrGenerator({
)}
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
</div>
) : null}
</div>
</Card>
</div>
@@ -463,7 +585,11 @@ export function QrGenerator({
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
Gestaltung
</CardTitle>
<DesignPanel design={design} onChange={setDesign} />
<DesignPanel
design={design}
onChange={setDesign}
hasBrandingLogo={hasBrandingLogo}
/>
</Card>
</div>
</div>
+111 -13
View File
@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import { useCallback, useState } from "react";
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 type { QrTypeId } from "@/lib/qr/type-module";
@@ -13,9 +13,19 @@ export type TypeSetting = {
description: string;
icon: IconName;
implemented: boolean;
/** Freigegeben in der gerade gezeigten Sicht (global oder Gruppe). */
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({
checked,
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 [scope, setScope] = useState<string>(GLOBAL);
const [pending, setPending] = useState<QrTypeId | null>(null);
const [loading, setLoading] = useState(false);
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 usableCount = types.filter(
(entry) => entry.enabled && entry.implemented,
).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 before = types;
// Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter
// soll sich nicht zäh anfühlen.
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);
setError(null);
try {
await apiJson("/api/admin/types", {
method: "PATCH",
json: { type: id, enabled },
json: { type: id, enabled, ...(inGruppe ? { groupId: scope } : {}) },
});
} catch (caught) {
setTypes(before);
@@ -90,6 +142,40 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
<div className="space-y-4">
{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 ? (
<Notice tone="warning">
Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es
@@ -138,17 +224,28 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
)}
</div>
<p className="mt-0.5 text-sm text-slate-500">
{entry.enabled
? entry.implemented
? "Erscheint auf der Startseite und kann verwendet werden."
: "Erscheint auf der Startseite als „Bald verfügbar“."
: "Ausgeblendet — erscheint nicht auf der Startseite."}
{inGruppe && !entry.enabledGlobally
? "In der Grundeinstellung abgeschaltet — für diese Gruppe nicht einschaltbar."
: entry.enabled
? entry.implemented
? 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>
</div>
<Toggle
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"}`}
onChange={(next) => void toggle(entry.id, next)}
/>
@@ -158,8 +255,9 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
</Card>
<p className="text-sm text-slate-500">
{usableCount} von {types.length} Typen nutzbar, {visibleCount} auf der
Startseite sichtbar.
{inGruppe
? `${usableCount} von ${types.length} Typen stehen ${gruppenname} zur Verfügung.`
: `${usableCount} von ${types.length} Typen nutzbar, ${visibleCount} auf der Startseite sichtbar.`}
</p>
</div>
);
+33 -14
View File
@@ -1,23 +1,42 @@
/** 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 {
const body: unknown = 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;
}
body = await response.json();
} catch {
// 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>(
url: string,
@@ -34,7 +53,7 @@ export async function apiJson<T>(
});
if (!response.ok) {
throw new ApiError(await readError(response));
throw await readError(response);
}
if (response.status === 204) {
return undefined as T;
@@ -55,7 +74,7 @@ export async function downloadFile(
});
if (!response.ok) {
throw new ApiError(await readError(response));
throw await readError(response);
}
const disposition = response.headers.get("Content-Disposition") ?? "";
+6 -1
View File
@@ -23,9 +23,14 @@ export type Actor =
* Bewusst eine Umgebungsvariable und kein Schalter in der Oberfläche: so
* kann man sich nicht aussperren. Wer den Zugang verliert, setzt
* `AUTH_ENABLED=false` in der .env und startet neu.
*
* **Abgeschaltet wird nur auf ausdrückliche Ansage.** Ein fehlender oder
* leerer Wert bedeutet „an“. Vorher war es umgekehrt: Wer die Variable
* vergaß, bekam eine Anwendung, in der jeder Besucher alles darf — und
* merkte es nicht, weil nichts danach aussah.
*/
export function isAuthEnabled(): boolean {
return (process.env.AUTH_ENABLED ?? "").trim().toLowerCase() === "true";
return (process.env.AUTH_ENABLED ?? "").trim().toLowerCase() !== "false";
}
export async function getActor(): Promise<Actor> {
+8 -1
View File
@@ -326,7 +326,14 @@ export async function exchangeCode(options: {
return {
subject,
email,
emailVerified: payload.email_verified !== false,
// Nur ein ausdrückliches `true` zählt. Ein fehlender Claim, `null`, `0`
// oder die Zeichenkette „true" gelten als **nicht** bestätigt.
//
// Vorher stand hier `!== false`, und damit war das Gegenteil der Fall:
// Ein Anbieter, der den Claim gar nicht schickt, hätte die Verknüpfung
// mit einem bestehenden Konto erlaubt — genau das, was die Prüfung in
// der Callback-Route verhindern soll.
emailVerified: payload.email_verified === true,
name,
groups: readGroups(payload, config.groupsClaim),
};
+43
View File
@@ -0,0 +1,43 @@
import "server-only";
/**
* Protokoll für Sicherheitsereignisse.
*
* Eine Zeile je Ereignis, als JSON, auf `stdout`. Damit landet sie in
* `docker compose logs` und lässt sich von dort in jedes Werkzeug
* weiterreichen, ohne dass die Anwendung eines davon kennen muss.
*
* **Was hier nicht hineingehört:** Passwörter, Sitzungstoken, vollständige
* E-Mail-Adressen. Ein Protokoll wird kopiert, verschickt und selten so gut
* geschützt wie die Datenbank. Konten stehen deshalb mit ihrer Id da, nicht
* mit ihrer Adresse — wer die Zuordnung braucht, hat ohnehin Zugriff auf die
* Verwaltung.
*/
export type Sicherheitsereignis =
| "anmeldung-fehlgeschlagen"
| "anmeldung-gesperrt"
| "oidc-konto-verknuepft"
| "oidc-verknuepfung-abgelehnt"
| "administrator-vergeben"
| "instanz-ohne-anmeldung";
/**
* Schreibt ein Ereignis.
*
* Bewusst `console.log` und kein Protokollrahmenwerk: Die Anwendung läuft in
* einem Container, und dort ist die Standardausgabe der übliche Weg.
*/
export function sicherheitsereignis(
ereignis: Sicherheitsereignis,
daten: Record<string, string | number | boolean | null> = {},
): void {
console.log(
JSON.stringify({
art: "sicherheit",
ereignis,
zeit: new Date().toISOString(),
...daten,
}),
);
}
+116 -14
View File
@@ -1,16 +1,26 @@
import "server-only";
/**
* Grobe Bremse gegen automatisiertes Durchprobieren.
* Bremsen gegen automatisiertes Durchprobieren.
*
* Ergänzt die Kontosperre, ersetzt sie aber nicht: Die Sperre schützt ein
* einzelnes Konto, diese Bremse den Server vor jemandem, der viele Konten
* gleichzeitig durchprobiert — dabei löst die Sperre nämlich nie aus.
* Drei Stufen, die verschiedene Dinge schützen:
*
* Im Arbeitsspeicher und damit pro Container. Bei einer selbst gehosteten
* Anwendung dieser Größe ist das angemessen; ein verteilter Zähler bräuchte
* einen weiteren Dienst. Die Adresse dient nur als Schlüssel und wird
* nirgends gespeichert.
* 1. **Je Absender** (`tooManyAttempts`) — greift nur, wenn ein Proxy davor
* steht, dem ausdrücklich vertraut wird. Sonst wählt der Aufrufer seine
* Kennung selbst und die Bremse wäre eine Behauptung.
* 2. **Je Konto** (`kontoWartezeit`) — eine ansteigende Wartezeit zwischen
* zwei Versuchen. Sie ersetzt die frühere harte Sperre: Wer das Passwort
* kennt, kommt nach höchstens einer Minute hinein; wer es nicht kennt,
* bekommt einen Versuch pro Minute.
* 3. **Für die ganze Installation** (`teureRechnungErlaubt`) — eine
* Obergrenze für Passwortprüfungen. Sie ist die einzige Stufe, die sich
* nicht über Kopfzeilen umgehen lässt, und schützt den Speicher: Eine
* Prüfung kostet rund 128 MiB.
*
* Alles im Arbeitsspeicher und damit je Container. Bei einer selbst
* gehosteten Anwendung dieser Größe ist das angemessen; ein verteilter
* Zähler bräuchte einen weiteren Dienst. Adressen dienen nur als Schlüssel
* und werden nirgends gespeichert.
*/
type Eintrag = { anzahl: number; bis: number };
@@ -53,17 +63,109 @@ export function clearAttempts(schluessel: string): void {
zaehler.delete(schluessel);
}
/* ------------------------------------------------------- Proxy-Vertrauen */
/**
* Absender einer Anfrage, so gut es hinter einem Proxy geht.
* Steht ein Proxy davor, dem zu trauen ist?
*
* `X-Forwarded-For` ist fälschbar, wenn kein Proxy davorsteht — deshalb ist
* das hier eine Bremse und keine Sperre: Wer den Wert fälscht, umgeht sie,
* aber die Kontosperre greift weiterhin.
* `X-Forwarded-For` kann jeder Aufrufer selbst setzen. Nur wenn ein Proxy
* davorsteht, der die Kopfzeile **überschreibt**, sagt sie etwas aus. Das
* kann die Anwendung nicht erkennen — Next.js-Routen kommen an die Adresse
* der Gegenstelle gar nicht heran —, also muss es eingestellt werden.
*
* Ohne Angabe: kein Vertrauen. Eine Bremse, die sich mit einer selbst
* gewählten Kopfzeile umgehen lässt, ist keine.
*/
export function requestOrigin(headers: Headers): string {
export function trustsProxy(): boolean {
return (process.env.TRUST_PROXY ?? "").trim().toLowerCase() === "true";
}
/**
* Absender einer Anfrage — oder `null`, wenn er sich nicht feststellen lässt.
*
* `null` heißt nicht „egal“, sondern „unbekannt“: Der Aufrufer bekommt dann
* keine eigene Bremse, wohl aber die Obergrenze der ganzen Installation zu
* spüren.
*/
export function requestOrigin(headers: Headers): string | null {
if (!trustsProxy()) return null;
return (
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
headers.get("x-real-ip") ||
"unbekannt"
null
);
}
/* ----------------------------------------------------- Wartezeit je Konto */
/**
* Wartezeit zwischen zwei Anmeldeversuchen desselben Kontos, in Sekunden.
*
* Ersetzt die frühere Sperre von 15 Minuten. Der Unterschied ist der, auf
* den es ankommt: Eine Sperre weist auch das **richtige** Passwort ab —
* wer die Adresse eines Administrators kennt, konnte ihn damit dauerhaft
* aussperren. Eine Wartezeit verzögert nur.
*
* Die ersten Fehlversuche bleiben ohne Folge; danach verdoppelt sich die
* Wartezeit bis zu einer Minute. Für den Besitzer heißt das im schlimmsten
* Fall eine Minute, für jemanden, der Passwörter durchprobiert, einen
* Versuch pro Minute.
*/
const FREIVERSUCHE = 4;
const MAX_WARTEZEIT_S = 60;
export function kontoWartezeit(fehlversuche: number): number {
if (fehlversuche <= FREIVERSUCHE) return 0;
return Math.min(2 ** (fehlversuche - FREIVERSUCHE - 1), MAX_WARTEZEIT_S);
}
/**
* Wie lange ein Konto noch warten muss, in Sekunden — oder 0.
*
* `letzterVersuch` ist der Zeitpunkt des letzten Fehlversuchs.
*/
export function verbleibendeKontowartezeit(
fehlversuche: number,
letzterVersuch: Date | null,
): number {
if (!letzterVersuch) return 0;
const noetig = kontoWartezeit(fehlversuche);
if (noetig === 0) return 0;
const vergangen = (Date.now() - letzterVersuch.getTime()) / 1000;
return Math.max(0, Math.ceil(noetig - vergangen));
}
/* ------------------------------------------------- Obergrenze der Instanz */
/**
* Obergrenze für Passwortprüfungen in der ganzen Installation.
*
* Eine Prüfung kostet rund 128 MiB Arbeitsspeicher (scrypt mit N = 2¹⁷).
* Ohne Deckel könnte jemand mit genügend gleichzeitigen Anfragen den Server
* allein dadurch lahmlegen — unabhängig davon, welche Kopfzeilen er schickt.
*
* Das ist die einzige Stufe, die sich nicht umgehen lässt. Sie ist bewusst
* großzügig: Im normalen Betrieb meldet sich niemand sechzigmal in der
* Minute an.
*/
const TEURE_RECHNUNGEN_PRO_MINUTE = 60;
const MINUTE_MS = 60 * 1000;
let fensterBeginn = 0;
let fensterAnzahl = 0;
/**
* Fragt eine teure Rechnung an. `false` heißt: gerade nicht, bitte später.
*
* Wird **vor** der Passwortprüfung aufgerufen, nicht danach — sonst wäre der
* Speicher schon belegt, wenn der Deckel greift.
*/
export function teureRechnungErlaubt(): boolean {
const jetzt = Date.now();
if (jetzt - fensterBeginn > MINUTE_MS) {
fensterBeginn = jetzt;
fensterAnzahl = 0;
}
fensterAnzahl += 1;
return fensterAnzahl <= TEURE_RECHNUNGEN_PRO_MINUTE;
}
+149
View File
@@ -0,0 +1,149 @@
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.1",
date: "2026-08-21",
summary: "Bilder zu den neuen Funktionen in der Bedienungsanleitung.",
changes: [
"Die Bedienungsanleitung zeigt die Funktionen aus 1.7.0 jetzt auch im Bild: Logo im Code, Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und Domain-Bericht.",
"Alle übrigen Bilder mit erneuert — sie stammten noch aus 1.2.0.",
"Das Skript, das die Bilder erzeugt, lief seit einer Umbenennung ins Leere und tut es wieder.",
],
},
{
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}`;
}
+17 -5
View File
@@ -3,7 +3,7 @@ import "server-only";
import type { Prisma, QrCode } from "@/generated/prisma/client";
import type { QrCodeDto } from "@/lib/dto";
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 type { QrTypeId } from "@/lib/qr/type-module";
@@ -38,16 +38,24 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
const data = asRecord(code.data);
const qrType = getQrType(code.type);
const istStatisch = code.mode === "static";
let target = "";
let summary = "";
if (qrType) {
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.
// Die ist als „Ziel“ das Ehrlichste — und obendrein anklickbar, um sie
// sich anzusehen.
target = qrType.hasLandingPage
? landingUrl(code.slug, code.domain?.hostname)
: qrType.redirectTarget(data);
target =
istStatisch || !code.slug
? ""
: qrType.hasLandingPage
? landingUrl(code.slug, code.domain?.hostname)
: qrType.redirectTarget(data);
summary = qrType.summary(data);
} catch {
// Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem
@@ -60,12 +68,13 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
return {
id: code.id,
mode: istStatisch ? "static" : "dynamic",
slug: code.slug,
aliases: (code.aliases ?? []).map((alias) => alias.slug),
type: code.type as QrTypeId,
label: code.label,
data,
shortUrl: shortUrl(code.slug, code.domain?.hostname),
shortUrl: code.slug ? shortUrl(code.slug, code.domain?.hostname) : null,
target,
summary,
design: {
@@ -74,6 +83,9 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
size: code.size,
ecLevel: code.ecLevel as EcLevel,
margin: code.margin,
logoSource: code.logoSource as LogoSource | null,
logoUploadId: code.logoUploadId,
logoScale: code.logoScale,
},
scans: code.scans,
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 };
}
+19 -6
View File
@@ -1,21 +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";
/** 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.
*/
export type QrCodeDto = {
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;
label: string | null;
data: Record<string, unknown>;
/** Die Kurz-URL, die im QR-Code steckt. */
shortUrl: string;
/** Aktuelles Weiterleitungsziel. */
/** Die Kurz-URL, die im QR-Code steckt. Leer bei statischen Codes. */
shortUrl: string | null;
/** Aktuelles Weiterleitungsziel. Leer bei statischen Codes. */
target: string;
/** Lesbare Kurzfassung des Ziels. */
summary: string;
@@ -25,6 +35,9 @@ export type QrCodeDto = {
size: number;
ecLevel: EcLevel;
margin: number;
logoSource: LogoSource | null;
logoUploadId: string | null;
logoScale: number;
};
scans: number;
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(),
};
}
+9
View File
@@ -26,6 +26,15 @@ export function isSameOrigin(headers: Headers): boolean {
return false;
}
// Das Schema kommt aus `BASE_URL`, nicht aus der Anfrage: Es ist die eine
// Angabe zum Ursprung, über die der Aufrufer nicht mitbestimmt. Ohne diesen
// Vergleich gälte `http://example.com` als eigen, obwohl die Anwendung nur
// unter `https://example.com` läuft.
const erwartetesSchema = (process.env.BASE_URL ?? "").startsWith("http://")
? "http:"
: "https:";
if (herkunft.protocol !== erwartetesSchema) return false;
const host = headers.get("x-forwarded-host") ?? headers.get("host");
return Boolean(host) && herkunft.host === host;
}
+143 -11
View File
@@ -1,5 +1,7 @@
import "server-only";
import type { Actor } from "@/lib/auth/actor";
import { visibleGroupIds } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { getQrType, qrTypeList } from "@/lib/qr/registry";
import {
@@ -15,10 +17,21 @@ import {
* Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann:
*
* - `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
* 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
* mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren
* weiter** — sie sind gedruckt und verteilt, und ein Schalter in der
@@ -29,8 +42,15 @@ export type QrTypeAvailability = {
qrType: QrTypeModule;
/** Der Typ ist in dieser Version ausprogrammiert. */
implemented: boolean;
/** Die Verwaltung hat den Typ freigegeben. */
/** Die Verwaltung hat den Typ freigegeben — global und in der Gruppe. */
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. */
usable: boolean;
/** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */
@@ -56,16 +76,52 @@ async function loadDisabledTypes(): Promise<ReadonlySet<string>> {
}
}
/** Verfügbarkeit aller Typen in Anzeigereihenfolge. */
export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> {
/** Abweichungen einer Gruppe; leer, wenn sie der globalen Einstellung folgt. */
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 inGruppeAbgewaehlt =
groupId === undefined
? new Set<string>()
: await loadGroupDisabledTypes(groupId);
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 {
qrType,
implemented: qrType.implemented,
enabled,
enabledGlobally: global,
disabledInGroup: inGruppe,
usable: 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. */
export async function getVisibleQrTypes(): Promise<QrTypeAvailability[]> {
return (await getQrTypeAvailability()).filter((entry) => entry.enabled);
export async function getVisibleQrTypes(
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(
id: string,
groupId?: string,
): Promise<QrTypeAvailability | 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(
id: string,
groupId?: string,
): Promise<QrTypeModule> {
const state = await getQrTypeState(id);
const state = await getQrTypeState(id, groupId);
if (!state) {
throw new QrRequestError("Unbekannter QR-Typ.", 404);
@@ -104,7 +207,9 @@ export async function requireAvailableQrType(
}
if (!state.enabled) {
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,
);
}
@@ -128,7 +233,7 @@ export function requireImplementedQrType(id: string): QrTypeModule {
return qrType;
}
/** Freigabe eines Typs setzen. */
/** Globale Freigabe eines Typs setzen. */
export async function setQrTypeEnabled(
id: QrTypeId,
enabled: boolean,
@@ -139,3 +244,30 @@ export async function setQrTypeEnabled(
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 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 hexColor = z
@@ -39,7 +62,25 @@ export const designSchema = z.object({
.int({ error: "Der Rand muss eine ganze Zahl sein." })
.min(0, { error: "Der Rand darf nicht negativ 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>;
@@ -49,8 +90,21 @@ export const defaultDesign: QrDesign = {
size: 512,
ecLevel: "M",
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. */
export const COLOR_PRESETS: ReadonlyArray<{
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 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";
/** 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 {
return {
errorCorrectionLevel: design.ecLevel,
// Mit Logo gilt immer H — siehe `effectiveEcLevel`.
errorCorrectionLevel: effectiveEcLevel(design),
margin: design.margin,
width: design.size,
color: {
@@ -30,12 +33,103 @@ function wrap(error: unknown): never {
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(
content: string,
design: QrDesign,
logo: QrLogo | null = null,
): Promise<string> {
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) {
wrap(error);
}
@@ -44,9 +138,14 @@ export async function renderSvg(
export async function renderPng(
content: string,
design: QrDesign,
logo: QrLogo | null = null,
): Promise<Buffer> {
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) {
wrap(error);
}
+28 -10
View File
@@ -36,6 +36,8 @@ export const qrRequestSchema = z.object({
slug: z.string().trim().optional(),
/** Domain, unter der die Kurz-URL entsteht. */
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>;
@@ -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(
qrType: QrTypeModule,
data: unknown,
extension: string,
label?: string | null,
): string {
const ausName = label ? alsDateiname(label) : "";
if (ausName) return `qr-${ausName}.${extension}`;
let base = "qr-code";
try {
const summary = qrType
.summary(data)
.toLowerCase()
.replaceAll("ä", "ae")
.replaceAll("ö", "oe")
.replaceAll("ü", "ue")
.replaceAll("ß", "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
if (summary) base = `qr-${summary}`;
.toLowerCase();
const gesaeubert = alsDateiname(summary);
if (gesaeubert) base = `qr-${gesaeubert}`;
} catch {
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
}
+10
View File
@@ -35,6 +35,16 @@ export const UPLOAD_KINDS = {
// das dann bei jedem Seitenaufruf mitgeht.
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: {
label: "Favicon",
// 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. */
const ORPHAN_HOURS = 24;
+72 -8
View File
@@ -26,17 +26,68 @@ import { isSameOrigin } from "@/lib/http";
const SCHREIBEND = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/**
* Schutzheader.
* Woher eingebettete Videos kommen dürfen.
*
* Bewusst ohne vollständige `Content-Security-Policy` für Skripte: Next.js
* bringt eingebettete Skripte mit, dafür bräuchte es Nonces durch die ganze
* Anwendung. Eine halbherzige Policy mit `unsafe-inline` sähe nur nach
* Schutz aus. Was hier steht, wirkt dagegen sofort.
* Der Video-Typ bettet YouTube und Vimeo ein — beide brauchen einen eigenen
* Eintrag, sonst bliebe der Rahmen nach dem Klick leer. Mehr steht hier
* nicht: Was nicht eingebettet wird, gehört auch nicht in die Liste.
*/
const VIDEO_QUELLEN = [
"https://www.youtube-nocookie.com",
"https://www.youtube.com",
"https://player.vimeo.com",
].join(" ");
/**
* Die Content-Security-Policy dieser Anfrage.
*
* Skripte laufen nur mit dem Nonce dieser einen Anfrage oder weil ein Skript
* sie geladen hat, das selbst eines hatte (`strict-dynamic`). Ein Skript,
* das über eine Lücke in die Seite gerät, hat keines — und läuft nicht.
*
* **Warum `style-src` weiter `unsafe-inline` erlaubt:** Ein Nonce wirkt nur
* auf `<style>`-Blöcke, nicht auf `style="…"` an einzelnen Elementen. Die
* Oberfläche benutzt solche Attribute an rund einem Dutzend Stellen, etwa
* für die gewählte Akzentfarbe. Sie zu verbieten hieße, sie vorher alle
* aufzulösen; der Gewinn wäre gering, denn über ein Stilattribut lässt sich
* kein Code ausführen. Das ist eine bewusste Grenze, keine Nachlässigkeit.
*
* `upgrade-insecure-requests` nur bei HTTPS: Auf einer Installation, die
* bewusst über HTTP läuft, würde die Regel die eigenen Ressourcen auf HTTPS
* hochstufen und damit alles zerlegen.
*/
function contentSecurityPolicy(nonce: string): string {
const entwicklung = process.env.NODE_ENV === "development";
const ueberHttps = (process.env.BASE_URL ?? "").startsWith("https://");
return [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
// `unsafe-eval` braucht React nur in der Entwicklung, für die
// Fehleraufbereitung im Browser.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
// `data:` für die Vorschau des QR-Codes, `blob:` für den Download.
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
`frame-src ${VIDEO_QUELLEN}`,
// Videos duerfen als Direktlink irgendwo liegen — der Typ „Video" nimmt
// ausdruecklich auch eine Adresse auf eine MP4-Datei entgegen. `https:`
// statt einer Liste: Welche Adresse jemand eintraegt, weiss nur er.
"media-src 'self' https:",
...(ueberHttps ? ["upgrade-insecure-requests"] : []),
].join("; ");
}
/** Schutzheader, die unabhängig von der Anfrage gelten. */
const HEADER: Record<string, string> = {
// Kein Einbetten in fremde Seiten — sonst ließe sich die Verwaltung hinter
// einer harmlos aussehenden Seite verstecken und fernsteuern.
"Content-Security-Policy": "frame-ancestors 'none'",
// einer harmlos aussehenden Seite verstecken und fernsteuern. Steht auch
// in der CSP; dieser Header erreicht ältere Browser.
"X-Frame-Options": "DENY",
// Der Browser soll den Inhaltstyp nicht raten.
"X-Content-Type-Options": "nosniff",
@@ -58,7 +109,20 @@ export function proxy(request: NextRequest) {
);
}
const response = NextResponse.next();
// Ein frischer Nonce je Anfrage. Next.js liest ihn aus der CSP der
// **Anfrage** und hängt ihn an seine eigenen Skripte — deshalb muss die
// Kopfzeile an beiden Stellen stehen, an der Anfrage und an der Antwort.
const nonce = crypto.randomUUID().replaceAll("-", "");
const csp = contentSecurityPolicy(nonce);
const anfrage = new Headers(request.headers);
anfrage.set("x-nonce", nonce);
anfrage.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers: anfrage } });
if (!response.headers.has("Content-Security-Policy")) {
response.headers.set("Content-Security-Policy", csp);
}
for (const [name, wert] of Object.entries(HEADER)) {
// Routen, die eigene Regeln setzen — etwa die Sandbox für hochgeladene
// Dateien —, sollen sie behalten.