13 Commits
Author SHA1 Message Date
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 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
35 changed files with 2044 additions and 234 deletions
+74
View File
@@ -0,0 +1,74 @@
# Ä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.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.
+37 -4
View File
@@ -19,7 +19,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
- **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.
Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen
ablegen, um sie wiederzufinden.
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
erhoben, ohne Name und IP-Adresse.
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
@@ -33,6 +34,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
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 +50,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
@@ -349,6 +353,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
@@ -614,13 +642,18 @@ 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. 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.
+45 -1
View File
@@ -102,7 +102,36 @@ 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.
### Der Kurz-Link
@@ -482,6 +511,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:
+9 -79
View File
@@ -7,86 +7,16 @@ 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
### 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.
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
und 1.6.0 diesen Versionshinweis.
---
+23
View File
@@ -191,6 +191,29 @@ Konto nur bei **bestätigter** E-Mail-Adresse.
---
## Der Domain-Check und seine Grenzen
Auf der Domain-Seite ruft der Knopf **Prüfen** die eingetragene Domain vom
Server aus auf. Drei Dinge grenzen das ein:
- Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer
aus der Anfrage. Sonst wäre die Route ein Werkzeug, mit dem sich vom Server
aus beliebige Adressen abklopfen ließen.
- Auslösen darf sie nur, wer `instance:manage` hat — dieselbe Berechtigung,
die auch Domains anlegt.
- Die Route, die den Beweis liefert (`/api/domain-check`), ist **ohne
Anmeldung** erreichbar; der Aufruf kommt ja von außen. Sie gibt nichts
preis: Die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Ohne
das Geheimnis lässt er sich nicht bilden, und aus ihm folgt nichts über das
Geheimnis. Es entsteht beim ersten Check und verlässt den Server nie.
Beim Zertifikat läuft die Verbindung bewusst mit `rejectUnauthorized: false`.
Nur so lässt sich ein schlechtes Zertifikat **beschreiben** — abgelaufen,
selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
Übertragen wird über diese Verbindung nichts.
---
## Offen
Bewusst nicht umgesetzt, mit Begründung.
+2 -1
View File
@@ -1,11 +1,12 @@
{
"name": "qrcode-generator",
"version": "1.1.0",
"version": "1.6.0",
"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",
@@ -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;
+20 -3
View File
@@ -162,10 +162,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
@@ -202,6 +210,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 +262,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.");
}
+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>
+83
View File
@@ -0,0 +1,83 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge, Card } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard";
import {
APP_VERSION,
CHANGELOG,
formatChangelogDate,
} from "@/lib/changelog";
export const metadata: Metadata = { title: "Version und Änderungen" };
export const dynamic = "force-dynamic";
export default async function VersionPage() {
await requirePageActor();
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Typ-Auswahl
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Version und Änderungen
</h1>
<p className="mt-2 text-slate-600">
Hier läuft Version{" "}
<span className="font-semibold text-slate-900">{APP_VERSION}</span>.
Darunter steht, was in welcher Fassung dazugekommen ist.
</p>
<p className="mt-2 text-sm text-slate-500">
Nach einem Update lohnt ein Blick hierher: Steht oben noch die alte
Nummer, läuft der Container noch mit dem alten Abbild.
</p>
</div>
<ol className="space-y-4">
{CHANGELOG.map((eintrag) => {
const laeuft = eintrag.version === APP_VERSION;
return (
<li key={eintrag.version}>
<Card>
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 className="text-lg font-semibold text-slate-900">
{eintrag.version}
</h2>
<span className="text-sm text-slate-500 tabular-nums">
{formatChangelogDate(eintrag.date)}
</span>
{laeuft ? (
<Badge tone="accent">läuft hier</Badge>
) : null}
</div>
<p className="mb-3 text-slate-600">{eintrag.summary}</p>
<ul className="space-y-1.5 text-sm text-slate-700">
{eintrag.changes.map((zeile) => (
<li key={zeile} className="flex gap-2.5">
<span
aria-hidden="true"
className="mt-2 size-1.5 shrink-0 rounded-full bg-slate-300"
/>
<span>{zeile}</span>
</li>
))}
</ul>
</Card>
</li>
);
})}
</ol>
</div>
);
}
@@ -0,0 +1,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);
}
}
+7 -1
View File
@@ -4,6 +4,7 @@ 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";
@@ -49,7 +50,12 @@ export async function GET(
margin: code.margin,
});
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))
+19 -1
View File
@@ -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
@@ -127,8 +136,16 @@ export async function PATCH(
/* ------------------------------------------------------- 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 +208,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,
},
});
+74 -2
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,6 +44,12 @@ 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),
});
function isUniqueViolation(error: unknown): boolean {
@@ -101,8 +114,25 @@ export async function POST(request: NextRequest): Promise<Response> {
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 +147,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, qrType.uploadIds(validated.data));
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 {
+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 });
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ export async function POST(request: NextRequest): Promise<Response> {
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",
},
});
+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"
+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>
);
}
+128
View File
@@ -0,0 +1,128 @@
"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>Von außerhalb des eigenen Netzes:</p>
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
<p>
Kommt eine Antwort dieser Anwendung, stimmen DNS, Proxy und
Zertifikat. Danach 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>
+144 -25
View File
@@ -43,6 +43,21 @@ 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. */
@@ -65,6 +80,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 +107,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 +210,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 +245,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 +361,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 +385,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 +406,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 +425,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 +444,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 +532,7 @@ export function QrGenerator({
{hasUnsavedChanges ? (
<Button
type="button"
onClick={handleSave}
onClick={() => void handleSave()}
disabled={saving || !ready}
>
{saving ? "Speichert …" : "Änderungen speichern"}
@@ -444,7 +564,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 +573,7 @@ export function QrGenerator({
)}
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
</div>
) : null}
</div>
</Card>
</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") ?? "";
+126
View File
@@ -0,0 +1,126 @@
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 sind aus dem Abschnitt „Gebaut“ der Roadmap und der
* Git-Historie zusammengetragen. Versionsnummern gab es dabei nie — es
* existieren keine Tags —, deshalb ist je **Erscheinungstag** eine Version
* gebildet. Das ist nachträglich vergeben, aber die Daten selbst stimmen.
*/
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.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}`;
}
+13 -4
View File
@@ -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: {
+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 };
}
+15 -5
View File
@@ -1,21 +1,31 @@
import type { EcLevel } 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;
+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(),
};
}
+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.
}