7 Commits
Author SHA1 Message Date
Florian Wilde c06f87e6d9 Logo in der Mitte des QR-Codes
Zur Wahl stehen zwei Quellen: das Logo der Installation aus dem
Erscheinungsbild oder ein eigenes je Code. Die Groesse ist zwischen 10 und
25 Prozent der Kantenlaenge einstellbar, hinter dem Bild liegt eine Flaeche
in der Hintergrundfarbe.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Dazu die Anleitungen: Der Reverse-Proxy-Abschnitt im README nennt jetzt die
drei Kopfzeilen, die die Anwendung vom Proxy erwartet, und was ohne sie
passiert; dazu client_max_body_size fuer Uploads, ein Weg zum Nachpruefen und
die Frage, fuer wen die Codes erreichbar sein sollen. In authentik.md steht,
warum BASE_URL und die Adresse im Browser dieselbe sein muessen.
2026-08-17 00:28:31 +02:00
47 changed files with 2464 additions and 257 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.
+100 -9
View File
@@ -18,8 +18,11 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, 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
@@ -31,6 +34,10 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
Admin-Bereich einstellbar.
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
eigene Wahl.
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
nachträglich änderbar — die vorherige Adresse bleibt gültig.
- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt
zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
von selbst; alles Nötige steht in einer `.env`.
@@ -45,7 +52,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
@@ -124,9 +132,25 @@ BASE_URL=http://192.168.1.50:3000
> **Warum `BASE_URL` so wichtig ist:** Ein dynamischer QR-Code enthält nicht
> das eigentliche Ziel, sondern die Kurz-URL `{BASE_URL}/r/{slug}`. Steht dort
> `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy
> läuft ins Leere. Trage deshalb die LAN-Adresse des Servers oder die
> öffentliche Domain ein. Bereits gedruckte Codes lassen sich später nicht
> mehr umbiegen, also am besten vor dem ersten produktiven Code festlegen.
> läuft ins Leere. Bereits gedruckte Codes lassen sich später nicht mehr
> umbiegen, also am besten vor dem ersten produktiven Code festlegen.
>
> Die Frage dahinter lautet: **Wer soll die Codes scannen können?**
>
> | Wer scannt | Was in `BASE_URL` gehört |
> | --- | --- |
> | nur Leute im eigenen Netz oder per VPN | die interne Adresse, z. B. `http://192.168.1.50:3000` |
> | auch Kundschaft, Besucher, Aushänge im Laden | eine **öffentlich auflösbare** Domain, z. B. `https://qr.example.com` |
>
> Eine Domain, die nur im internen DNS steht, verhält sich dabei wie eine
> LAN-Adresse: Ohne VPN kommt niemand darauf. Ein gedruckter Code führt dann
> für alle anderen ins Leere — mit „Server nicht gefunden“, ohne jeden
> Hinweis darauf, woran es liegt.
>
> Und: `BASE_URL` muss **dieselbe** Adresse sein, über die ihr die Oberfläche
> aufruft — nicht bloß irgendeine erreichbare. Sonst scheitert die Anmeldung
> über einen externen Anbieter, siehe
> [docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der
Servicename aus der `docker-compose.yml`, nicht dein Server.
@@ -234,7 +258,7 @@ BASE_URL=https://qr.deine-domain.de
| Scheme | `http` |
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
| Forward Port | `3000` |
| Websockets Support | an |
| Websockets Support | nicht nötig — die Anwendung benutzt keine; angeschaltet schadet es aber nicht |
| Block Common Exploits | an |
| SSL | Zertifikat anfordern, *Force SSL* aktivieren |
@@ -249,9 +273,76 @@ Port. Danach:
docker compose up -d
```
> `BASE_URL` muss zur Adresse passen, unter der Besucher die Anwendung
> aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue
> dynamische Codes ins interne Netz statt auf die Domain.
> `BASE_URL` muss **dieselbe** Adresse sein, unter der ihr die Anwendung
> aufruft — nicht bloß irgendeine erreichbare. Steht dort weiterhin
> `http://192.168.1.50:3000`, zeigen neue dynamische Codes ins interne Netz
> statt auf die Domain, und die Anmeldung über einen externen Anbieter
> scheitert (siehe unten).
### Was der Proxy weiterreichen muss
Die Anwendung wertet drei Kopfzeilen aus, die erst der Proxy setzt. Der Nginx
Proxy Manager schickt alle drei von sich aus mit — bei anderen Proxys muss man
sie eintragen.
| Kopfzeile | Wofür sie gebraucht wird | Fehlt sie, passiert das |
| --- | --- | --- |
| `X-Forwarded-Proto` | erkennt, dass die Verbindung über HTTPS läuft | Der Sitzungs-Cookie wird ohne `Secure` gesetzt und ginge auch über eine unverschlüsselte Verbindung mit |
| `X-Forwarded-Host` | die aufgerufene Domain — für mehrere Domains und für die Anmeldung | Kurz-URLs unter Zweitdomains werden nicht zugeordnet |
| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Alle Aufrufe zählen als derselbe Absender |
Für ein reines nginx (ohne NPM) heißt das:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
Bei **Traefik** und **Caddy** ist nichts zu tun — beide setzen die Kopfzeilen
von sich aus.
### Größe der Uploads
PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von
`client_max_body_size` mit **413** ab, und zwar bevor die Anwendung sie zu
sehen bekommt — die Fehlermeldung kommt dann vom Proxy und nicht aus der
Oberfläche.
Der Wert muss also mindestens so groß sein wie `MAX_UPLOAD_MB` (Voreinstellung
10 MB), besser etwas darüber. Im Nginx Proxy Manager steht das unter
**Advanced → Custom Nginx Configuration**:
```nginx
client_max_body_size 20m;
```
### Nachprüfen, ob es stimmt
```bash
# Kommt die Anwendung überhaupt an?
curl -I https://qr.deine-domain.de/
# Kennt der Container dieselbe Adresse wie der Browser?
docker compose exec app env | grep BASE_URL
```
Beides muss zusammenpassen. Ein häufiger Fall: Der Proxy läuft, die Domain
öffnet sich, aber `BASE_URL` steht noch auf der internen IP aus der
Einrichtung. Dann ist die Oberfläche zwar erreichbar, aber
- jeder neue dynamische Code zeigt auf die interne Adresse, und
- die Anmeldung über einen externen Anbieter endet mit einer Fehlermeldung,
weil der Anbieter den Browser auf den anderen Host zurückschickt — siehe
[docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
### Erreichbar für wen?
Der Proxy entscheidet mit, wer die Codes scannen kann. Steht die Domain nur im
internen DNS oder ist sie nur über VPN erreichbar, funktionieren gedruckte
Codes **auch nur dort**. Für Aushänge, Flyer oder Verpackungen muss die Domain
öffentlich auflösbar und der Proxy von außen erreichbar sein.
### Mehrere Domains
+30 -1
View File
@@ -94,6 +94,34 @@ Zu beachten:
> falsch eingestellt, sondern die `BASE_URL` — und die gehört korrigiert,
> bevor der erste dynamische Code entsteht: Sie steckt in jedem davon.
### `BASE_URL` und die Adresse im Browser müssen dieselbe sein
Nicht nur „irgendeine erreichbare Adresse“ — **dieselbe**, über die ihr die
Anwendung tatsächlich aufruft.
Der Grund liegt im Ablauf: Beim Klick auf den Anmeldeknopf legt die Anwendung
einen Cookie mit dem laufenden Vorgang ab — auf dem Host, über den der Browser
gerade da ist. Die Rückkehradresse für Authentik kommt dagegen aus `BASE_URL`.
Zeigen die beiden auf verschiedene Hosts, schickt Authentik den Browser auf den
anderen Host, der Cookie bleibt beim ersten zurück, und die Anmeldung endet mit
**„Der Anmeldevorgang ist abgelaufen“**. Erkennbar ist es daran, dass in der
Adresszeile plötzlich eine andere Adresse steht als die, mit der man begonnen
hat.
Seit Version vom 17.08.2026 fängt die Anwendung das ab und nennt beide
Adressen, statt den Vorgang bis zum Ende laufen zu lassen.
Ein häufiges Muster: Die Anwendung läuft hinter einem Reverse Proxy unter
`https://qr.example.com`, in der `.env` steht aber noch die interne Adresse
`http://10.0.0.5:3005` aus der Einrichtung. Dann gehören **drei** Werte in
Übereinstimmung gebracht:
| | |
| --- | --- |
| Aufruf im Browser | `https://qr.example.com` |
| `BASE_URL` in der `.env` | `https://qr.example.com` |
| Redirect URI in Authentik | `https://qr.example.com/api/auth/oidc/callback` |
---
## Schritt 2 — Eine Application anlegen
@@ -259,7 +287,8 @@ Danach verschwindet das Passwortfeld von der Anmeldeseite.
| Authentik zeigt „Redirect URI Error“ | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein — siehe [Die Redirect URI](#die-redirect-uri). Auf Schema, Port, Schrägstrich am Ende und die Auswahl Strict/Regex achten. |
| „Der Anbieter hat keine E-Mail-Adresse geliefert“ | Der Scope `email` fehlt im Provider, oder der Benutzer hat in Authentik keine Adresse hinterlegt. |
| „Der Anbieter hat die Anmeldung abgelehnt“ | Der Benutzer ist der Application in Authentik nicht zugeordnet — Policy/Group Binding prüfen. |
| „Der Anmeldevorgang ist abgelaufen“ | Zwischen Klick und Rückkehr sind mehr als 10 Minuten vergangen, oder der Cookie ging verloren. Häufigste echte Ursache: `BASE_URL` zeigt auf eine andere Adresse als die, über die der Browser kommt. |
| „Der Anmeldevorgang ist abgelaufen“ | Der Cookie mit dem laufenden Vorgang kam nicht zurück. Entweder sind mehr als 10 Minuten vergangen — oder, weit häufiger, `BASE_URL` zeigt auf einen anderen Host als den, über den der Browser kommt. Erkennbar daran, dass in der Adresszeile eine andere Adresse steht als beim Start. Siehe [BASE_URL und die Adresse im Browser](#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein). |
| „Du rufst die Anwendung über … auf, BASE_URL zeigt aber auf …“ | Genau der Fall von oben, nur früh erkannt. Die Meldung nennt beide Adressen; `BASE_URL` in der `.env` auf die erste setzen und `docker compose up -d` ausführen. |
| „Das Anmelde-Token des Anbieters ist ungültig“ | Uhrzeit-Abweichung zwischen den Servern oder ein gewechselter Signaturschlüssel. Beides zeigt `docker compose logs app` genauer. |
| Nach der Anmeldung fehlt die Verwaltung | `OIDC_ADMIN_GROUP` ist gesetzt, der Benutzer aber nicht in dieser Gruppe. Entweder die Gruppe in Authentik ergänzen oder die Variable leeren und das Recht hier vergeben. |
+94 -6
View File
@@ -102,12 +102,84 @@ 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 |
Der **Slug** ist acht Zeichen lang aus einem Alphabet ohne verwechselbare
Zeichen (kein `0`, `O`, `1`, `l`, `I`). Er ist **je Domain** eindeutig, nicht
global — derselbe Slug darf auf zwei Domains liegen und dort auf
Verschiedenes zeigen.
### 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.
### Ein Logo in der Mitte
Ein Bild in die Mitte des Codes setzen, wie man es von Firmen-Codes kennt.
Zur Wahl stehen zwei Quellen:
- das **Logo dieser Installation** aus dem Erscheinungsbild — ein Haken
genügt, kein Upload je Code;
- ein **eigenes Logo** je Code, für Kundenprojekte mit fremdem Zeichen.
Die Größe ist zwischen 10 und 25 % der Kantenlänge einstellbar; voreingestellt
sind 20 %, das entspricht rund 4 % der Fläche. Hinter dem Bild liegt eine
Fläche in der Hintergrundfarbe, damit es sich absetzt.
**Die Fehlerkorrektur wird dabei auf H erzwungen** — was das Bild verdeckt,
muss sie ausgleichen. Die Auswahl steht dann fest auf H und sagt auch warum;
heimlich geschieht das nicht. Ab 22 % erscheint eine Warnung: Die
Fehlerkorrektur trägt das zwar, aber manche Scanner-Apps suchen in der Mitte
nach Ausrichtungsmustern.
Jeder erzeugte Code wird in der Prüfung wieder eingelesen — über mehrere
Größen, Prozentsätze und Inhaltslängen. Das ersetzt trotzdem nicht den einen
Scan mit dem eigenen Handy, bevor etwas in den Druck geht.
### Der Kurz-Link
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
verwechselbare Zeichen (kein `0`, `O`, `1`, `l`, `I`) — Kurz-URLs werden
gelegentlich abgetippt.
**Selbst bestimmen** lässt er sich beim Anlegen und später ändern:
| | |
| --- | --- |
| Erlaubt | Kleinbuchstaben, Ziffern, Bindestriche, `/` als Trenner |
| Beispiele | `sommeraktion`, `tisch-14`, `kunde-a/flyer` |
| Nicht erlaubt | Umlaute, Leerzeichen, Unterstriche, Bindestrich am Rand eines Abschnitts |
| Länge | bis 100 Zeichen, höchstens 3 Abschnitte à 48 Zeichen |
Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: `SommerAktion`
findet `sommeraktion`. Gespeichert wird kleingeschrieben, und zwei Kurz-Links
dürfen sich nicht nur in der Schreibweise unterscheiden.
**Beim Ändern bleibt die vorherige Adresse gültig.** Das ist der Sinn der
Sache: Auf gedruckten Codes steht die alte, und die soll weiter funktionieren.
Frühere Adressen stehen im Bearbeiten-Dialog und lassen sich einzeln
**freigeben** — für den Fall eines Tippfehlers, mit dem noch nichts gedruckt
ist. Eine freigegebene Adresse ist sofort tot und danach wieder vergebbar.
Solange sie gehalten wird, kann sie kein anderer Code übernehmen; sonst
ließen sich fremde gedruckte Codes umlenken.
**Wer darf das?** Standardmäßig nur Administratoren — ein Kurz-Link ist eine
öffentliche Adresse, und wer ihn ändert, kann Aushänge entwerten. Unter
Verwaltung → Einstellungen lässt sich das für Bearbeiter freigeben. Ohne das
Recht erscheint das Feld gar nicht; Codes anlegen und deren Ziel ändern geht
weiterhin.
Der Kurz-Link ist **je Domain** eindeutig, nicht global — derselbe darf auf
zwei Domains liegen und dort auf Verschiedenes zeigen.
Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben —
@@ -415,6 +487,7 @@ auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
| Bereich | Was dort geht |
| --- | --- |
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
| **QR-Typen** | einzelne Typen ausblenden |
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
@@ -450,6 +523,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:
@@ -466,7 +554,7 @@ Alles, was die Oberfläche tut, geht auch über HTTP:
| `POST /api/feedback` | Rückmeldung abgeben |
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter |
| `/api/admin/types` · `users` · `groups` · `domains` · `branding` | Verwaltung |
| `/api/admin/types` · `users` · `groups` · `domains` · `branding` · `settings` | Verwaltung |
| `GET /r/{slug}` | Weiterleitung eines dynamischen Codes |
| `GET /s/{slug}` | öffentliche Landingpage |
+9 -70
View File
@@ -7,77 +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.
### 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.
---
+69 -6
View File
@@ -1,12 +1,12 @@
{
"name": "qrcode-generator",
"version": "1.1.0",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qrcode-generator",
"version": "1.1.0",
"version": "1.6.0",
"dependencies": {
"@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1",
@@ -17,6 +17,7 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -566,7 +567,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -2065,6 +2065,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -4039,7 +4105,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -7795,7 +7860,6 @@
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
@@ -7845,7 +7909,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
+3 -1
View File
@@ -1,11 +1,12 @@
{
"name": "qrcode-generator",
"version": "1.1.0",
"version": "1.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",
@@ -23,6 +24,7 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -0,0 +1,56 @@
-- Selbst gewählte Kurz-Links, umbenennbar, mit weiterhin gültigen Vorgängern.
-- Frühere Kurz-Links eines Codes. Ohne sie würde jedes Umbenennen die bereits
-- gedruckten Codes entwerten.
CREATE TABLE "QrCodeAlias" (
"id" TEXT NOT NULL,
"domainId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"qrCodeId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "QrCodeAlias_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "QrCodeAlias_domainId_slug_key" ON "QrCodeAlias"("domainId", "slug");
CREATE INDEX "QrCodeAlias_qrCodeId_idx" ON "QrCodeAlias"("qrCodeId");
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_domainId_fkey"
FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_qrCodeId_fkey"
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Gross- und Kleinschreibung soll beim Aufrufen keine Rolle spielen. Damit
-- „Sommer" und „sommer" nicht als zwei verschiedene Kurz-Links vergeben werden
-- koennen, gilt die Eindeutigkeit ab jetzt auf der kleingeschriebenen Form.
--
-- Zuerst pruefen, ob der bestehende Bestand das ueberhaupt zulaesst. Bei
-- erzeugten Kurz-Links aus acht Zeichen ist eine solche Kollision praktisch
-- ausgeschlossen; ein stiller Datenverlust waere aber unentschuldbar, deshalb
-- bricht die Migration in dem Fall lieber ab.
DO $$
DECLARE kollisionen INT;
BEGIN
SELECT COUNT(*) INTO kollisionen FROM (
SELECT "domainId", LOWER("slug")
FROM "QrCode"
GROUP BY "domainId", LOWER("slug")
HAVING COUNT(*) > 1
) AS doppelt;
IF kollisionen > 0 THEN
RAISE EXCEPTION
'Es gibt % Kurz-Links, die sich nur in der Gross-/Kleinschreibung unterscheiden. Bitte einen davon umbenennen und die Migration erneut starten.',
kollisionen;
END IF;
END $$;
-- Der bestehende Index bleibt: Er traegt die exakten Nachschlagevorgaenge, und
-- das ist der haeufige Fall — ein gescannter Code liefert die Adresse genau so,
-- wie sie gespeichert ist. Der zweite Index kommt dazu und sorgt dafuer, dass
-- sich zwei Kurz-Links nicht nur in der Gross-/Kleinschreibung unterscheiden
-- koennen.
CREATE UNIQUE INDEX "QrCode_domainId_lower_slug_key"
ON "QrCode"("domainId", LOWER("slug"));
CREATE UNIQUE INDEX "QrCodeAlias_domainId_lower_slug_key"
ON "QrCodeAlias"("domainId", LOWER("slug"));
@@ -0,0 +1,12 @@
-- Verhalten der Installation, das sich nicht aus dem Code ergibt.
CREATE TABLE "InstanceSetting" (
"id" TEXT NOT NULL DEFAULT 'instance',
"editorsMaySetSlug" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InstanceSetting_pkey" PRIMARY KEY ("id")
);
-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur lesen muss.
INSERT INTO "InstanceSetting" ("id", "updatedAt") VALUES ('instance', CURRENT_TIMESTAMP);
@@ -0,0 +1,12 @@
-- Statische Codes lassen sich mit Namen ablegen, damit man sie wiederfindet
-- und erneut herunterladen kann.
-- Alles, was es bisher gibt, ist dynamisch.
ALTER TABLE "QrCode" ADD COLUMN "mode" TEXT NOT NULL DEFAULT 'dynamic';
CREATE INDEX "QrCode_mode_idx" ON "QrCode"("mode");
-- Ein statischer Code hat keinen Kurz-Link: Er traegt seinen Inhalt selbst und
-- fuehrt auf nichts, was dieser Server ausliefert. In PostgreSQL schliessen
-- sich mehrere NULL in einem eindeutigen Index nicht aus — die Bedingung
-- „je Domain eindeutig" bleibt fuer dynamische Codes also unveraendert.
ALTER TABLE "QrCode" ALTER COLUMN "slug" DROP NOT NULL;
@@ -0,0 +1,11 @@
-- Ein Logo in der Mitte des Codes, wie man es von Firmen-Codes kennt.
--
-- Drei Angaben gehoeren zur Gestaltung: woher das Bild kommt, welche Datei es
-- ist und wie gross es sein soll. Ohne Herkunft gibt es kein Logo — deshalb
-- sind die ersten beiden Spalten leer erlaubt und ohne Vorbelegung.
ALTER TABLE "QrCode" ADD COLUMN "logoSource" TEXT;
ALTER TABLE "QrCode" ADD COLUMN "logoUploadId" TEXT;
-- Die Groesse hat eine Vorbelegung, damit bestehende Codes einen gueltigen
-- Wert tragen. Ohne Logo bleibt er wirkungslos.
ALTER TABLE "QrCode" ADD COLUMN "logoScale" INTEGER NOT NULL DEFAULT 20;
+59 -3
View File
@@ -122,6 +122,7 @@ model Domain {
grants DomainGrant[]
codes QrCode[]
aliases QrCodeAlias[]
}
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
@@ -161,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
@@ -173,6 +182,14 @@ model QrCode {
size Int @default(512)
ecLevel String @default("M")
margin Int @default(4)
/// Logo in der Mitte: "branding" nimmt das Logo der Installation,
/// "upload" eine eigene Datei. Leer heisst: kein Logo.
logoSource String?
/// Die eigene Datei, wenn `logoSource` = "upload". Sie haengt ueber
/// `Upload.qrCodeId` am Code und verschwindet mit ihm.
logoUploadId String?
/// Kantenlaenge des Logos in Prozent der Kantenlaenge des Codes.
logoScale Int @default(20)
scans Int @default(0)
lastScanAt DateTime?
createdAt DateTime @default(now())
@@ -194,11 +211,14 @@ model QrCode {
uploads Upload[]
/// Rückmeldungen, die über die Landingpage eingegangen sind.
feedback Feedback[]
/// Frühere Kurz-Links, die weiterhin hierher führen.
aliases QrCodeAlias[]
@@unique([domainId, slug])
@@index([createdAt])
@@index([groupId])
@@index([domainId])
@@index([mode])
}
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
@@ -237,6 +257,42 @@ model Upload {
@@index([purpose])
}
/// Verhalten der Installation, das sich nicht aus dem Code ergibt.
///
/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt
/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder
/// ihren Typ behalten.
model InstanceSetting {
id String @id @default("instance")
/// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern?
///
/// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert,
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
editorsMaySetSlug Boolean @default(false)
updatedAt DateTime @updatedAt
}
/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt.
///
/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten —
/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes.
/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem
/// Tippfehler, den noch niemand gedruckt hat.
model QrCodeAlias {
id String @id @default(cuid())
/// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst.
domainId String
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
slug String
qrCodeId String
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([domainId, slug])
@@index([qrCodeId])
}
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
///
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
+84
View File
@@ -0,0 +1,84 @@
/**
* Schreibt CHANGELOG.md aus src/lib/changelog.ts.
*
* node scripts/changelog.mjs erzeugen
* node scripts/changelog.mjs --check nur prüfen, ob die Datei aktuell ist
*
* Warum erzeugt und nicht von Hand gepflegt: Die Anwendung zeigt denselben
* Verlauf unter /version an. Zwei gepflegte Listen liefen unweigerlich
* auseinander — und ein Changelog, der nicht stimmt, ist schlimmer als keiner.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const wurzel = join(dirname(fileURLToPath(import.meta.url)), "..");
const ziel = join(wurzel, "CHANGELOG.md");
/**
* Die Quelle ist TypeScript, dieses Skript aber schlichtes Node. Statt einen
* Übersetzer dazuzunehmen, wird der Datenteil gelesen und ausgewertet — er
* besteht ausschließlich aus Zeichenketten und Listen.
*/
function ladeVerlauf() {
const quelle = readFileSync(join(wurzel, "src", "lib", "changelog.ts"), "utf8");
const anfang = quelle.indexOf("export const CHANGELOG");
if (anfang === -1) throw new Error("CHANGELOG-Liste nicht gefunden.");
const start = quelle.indexOf("[", anfang);
const ende = quelle.indexOf("\n];", start);
if (start === -1 || ende === -1) throw new Error("Liste nicht abgrenzbar.");
const literal = quelle.slice(start, ende + 2);
return Function(`"use strict"; return (${literal});`)();
}
function formatiereDatum(iso) {
const [jahr, monat, tag] = iso.split("-");
return `${tag}.${monat}.${jahr}`;
}
function baue(verlauf) {
const zeilen = [
"# Änderungen",
"",
"Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der",
"Anwendung unter **Version und Änderungen**, erreichbar über die",
"Versionsnummer in der Fußzeile.",
"",
"> Diese Datei wird erzeugt: `npm run changelog`.",
"> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.",
"",
];
for (const eintrag of verlauf) {
zeilen.push(`## ${eintrag.version} — ${formatiereDatum(eintrag.date)}`, "");
zeilen.push(eintrag.summary, "");
for (const änderung of eintrag.changes) zeilen.push(`- ${änderung}`);
zeilen.push("");
}
return zeilen.join("\n");
}
const inhalt = baue(ladeVerlauf());
if (process.argv.includes("--check")) {
let vorhanden = "";
try {
vorhanden = readFileSync(ziel, "utf8");
} catch {
console.error("CHANGELOG.md fehlt. Erzeugen mit: npm run changelog");
process.exit(1);
}
if (vorhanden !== inhalt) {
console.error(
"CHANGELOG.md passt nicht mehr zu src/lib/changelog.ts. Neu erzeugen mit: npm run changelog",
);
process.exit(1);
}
console.log("CHANGELOG.md ist aktuell.");
} else {
writeFileSync(ziel, inhalt, "utf8");
console.log("CHANGELOG.md geschrieben.");
}
+10 -6
View File
@@ -94,12 +94,16 @@ export default async function SignInPage({ searchParams }: PageProps) {
Gruppen, Benutzer und die QR-Typen verwalten.
</Notice>
) : oidc?.nurOidc ? (
<p className="text-center text-sm text-slate-500">
Die Anmeldung mit Passwort ist abgeschaltet (<code>OIDC_ONLY=true</code>).
{setup
? " Wer sich zuerst anmeldet, wird Administrator dieser Installation."
: ""}
</p>
/* Bewusst kein Hinweis darauf, dass die Passwort-Anmeldung
abgeschaltet ist. Diese Seite sieht jeder, der die Adresse kennt —
wie die Anmeldung eingerichtet ist, geht Fremde nichts an. Wer
hierher gehört, sieht den einen Knopf und weiß, was zu tun ist.
Beim allerersten Aufruf ist der Hinweis dagegen nützlich. */
setup ? (
<p className="text-center text-sm text-slate-500">
Wer sich zuerst anmeldet, wird Administrator dieser Installation.
</p>
) : null
) : (
<p className="text-center text-sm text-slate-500">
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
+23 -2
View File
@@ -4,6 +4,10 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { getBranding } from "@/lib/branding";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard";
import { getQrTypeState } from "@/lib/qr/availability";
import { getQrType } from "@/lib/qr/registry";
@@ -47,7 +51,7 @@ function Hinweis({
}
export default async function CreatePage({ params }: Props) {
await requirePageActor();
const actor = await requirePageActor();
const { type } = await params;
const state = await getQrTypeState(type);
@@ -75,5 +79,22 @@ export default async function CreatePage({ params }: Props) {
);
}
return <QrGenerator typeId={state.qrType.id} />;
// Ob der Kurz-Link selbst vergeben werden darf, haengt an Rolle und
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
// noch einmal.
const { editorsMaySetSlug } = await getSettings();
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
// der eigene Upload.
const { logoUploadId } = await getBranding();
return (
<QrGenerator
typeId={state.qrType.id}
maySetSlug={maySetSlug}
hasBrandingLogo={logoUploadId !== null}
/>
);
}
+15 -3
View File
@@ -6,6 +6,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
import { 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>
+10 -2
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard";
export const metadata: Metadata = { title: "Meine Codes" };
@@ -8,10 +10,16 @@ export const metadata: Metadata = { title: "Meine Codes" };
export const dynamic = "force-dynamic";
export default async function MyCodesPage() {
await requirePageActor();
const actor = await requirePageActor();
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte
// nebeneinander zeigen können.
// Der Kurz-Link darf je Gruppe verschieden bearbeitbar sein; hier zaehlt,
// ob es irgendwo erlaubt ist. Die Route prueft es beim Speichern erneut,
// dann mit der Gruppe des betroffenen Codes.
const { editorsMaySetSlug } = await getSettings();
const maySetSlug = can(actor, "codes:slug", { editorsMaySetSlug });
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
@@ -25,7 +33,7 @@ export default async function MyCodesPage() {
</p>
</div>
<CodesList />
<CodesList maySetSlug={maySetSlug} />
</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,39 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { SettingsAdmin } from "@/components/settings-admin";
import { requirePagePermission } from "@/lib/auth/guard";
import { getSettings } from "@/lib/settings";
export const metadata: Metadata = { title: "Einstellungen" };
export const dynamic = "force-dynamic";
export default async function SettingsPage() {
await requirePagePermission("instance:manage");
const settings = await getSettings();
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/verwaltung"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Verwaltung
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Einstellungen
</h1>
<p className="mt-2 text-slate-600">
Was Bearbeiter dürfen und was Administratoren vorbehalten bleibt.
</p>
</div>
<SettingsAdmin initial={{ editorsMaySetSlug: settings.editorsMaySetSlug }} />
</div>
);
}
+14 -1
View File
@@ -6,6 +6,7 @@ import { Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Verwaltung" };
@@ -46,13 +47,14 @@ function Tile({
export default async function AdminPage() {
await requirePagePermission("instance:manage");
const [userCount, groupCount, codeCount, domainCount, branding] =
const [userCount, groupCount, codeCount, domainCount, branding, settings] =
await Promise.all([
prisma.user.count(),
prisma.group.count(),
prisma.qrCode.count(),
prisma.domain.count(),
getBranding(),
getSettings(),
]);
const eigenes = [
@@ -120,6 +122,17 @@ export default async function AdminPage() {
description="Festlegen, welche Typen auf der Startseite erscheinen."
hint="15 Typen insgesamt"
/>
<Tile
href="/verwaltung/einstellungen"
icon="lock"
title="Einstellungen"
description="Was Bearbeiter dürfen — etwa eigene Kurz-Links vergeben."
hint={
settings.editorsMaySetSlug
? "Kurz-Links: auch Bearbeiter"
: "Kurz-Links: nur Administratoren"
}
/>
<Tile
href="/verwaltung/erscheinungsbild"
icon="image"
+40
View File
@@ -0,0 +1,40 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { getSettings, saveSettings } from "@/lib/settings";
export const dynamic = "force-dynamic";
const updateSchema = z.object({
editorsMaySetSlug: z.boolean().optional(),
});
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
return Response.json({ settings: await getSettings() });
} catch (error) {
return errorResponse(error);
}
}
export async function PATCH(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
if (Object.keys(parsed.data).length === 0) {
return jsonError("Es wurden keine Änderungen übermittelt.");
}
return Response.json({ settings: await saveSettings(parsed.data) });
} catch (error) {
return errorResponse(error);
}
}
+7 -1
View File
@@ -1,8 +1,10 @@
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { isAuthEnabled } from "@/lib/auth/actor";
import {
assertHostMatchesBaseUrl,
discover,
getOidcConfig,
isOidcEnabled,
@@ -26,7 +28,7 @@ function base64url(buffer: Buffer): string {
* zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann,
* wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll.
*/
export async function GET(): Promise<Response> {
export async function GET(request: NextRequest): Promise<Response> {
try {
if (!isAuthEnabled()) {
throw new QrRequestError(
@@ -41,6 +43,10 @@ export async function GET(): Promise<Response> {
);
}
// Vor allem anderen: Kommt der Browser über die Adresse, auf die
// BASE_URL zeigt? Sonst geht der Vorgang auf dem Rückweg verloren.
assertHostMatchesBaseUrl(request.headers);
const config = getOidcConfig();
const meta = await discover(config.issuer);
+14 -3
View File
@@ -4,9 +4,11 @@ import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { shortUrl } from "@/lib/env";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { errorResponse } from "@/lib/http";
import { designSchema } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry";
import { ladeLogo } from "@/lib/qr/logo";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
@@ -47,13 +49,22 @@ export async function GET(
size: code.size,
ecLevel: code.ecLevel,
margin: code.margin,
logoSource: code.logoSource,
logoUploadId: code.logoUploadId,
logoScale: code.logoScale,
});
const logo = await ladeLogo(design);
const content = shortUrl(code.slug, code.domain.hostname);
// Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
const content =
code.mode === "static" || !code.slug
? requireImplementedQrType(code.type).encode(code.data)
: shortUrl(code.slug, code.domain.hostname);
const body =
format === "png"
? new Uint8Array(await renderPng(content, design))
: await renderSvg(content, design);
? new Uint8Array(await renderPng(content, design, logo))
: await renderSvg(content, design, logo);
const headers: Record<string, string> = {
"Content-Type": CONTENT_TYPES[format],
+133 -7
View File
@@ -10,7 +10,10 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request";
import { attachUploads } from "@/lib/uploads";
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
import { assertSlugAvailable } from "@/lib/slug-resolve";
import { getSettings } from "@/lib/settings";
import { attachUpload, attachUploads, detachUpload } from "@/lib/uploads";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
@@ -21,6 +24,7 @@ const WITH_RELATIONS = {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
} as const;
const updateSchema = z.object({
@@ -32,6 +36,17 @@ const updateSchema = z.object({
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.nullable()
.optional(),
/** Neuer Kurz-Link. Der bisherige bleibt als frühere Adresse gültig. */
slug: z.string().trim().max(120).optional(),
/**
* Welche früheren Adressen weiterhin gelten sollen.
*
* Bewusst als vollständige Liste und nicht als „lösche diese eine": So
* beschreibt die Anfrage den gewünschten Zustand, und die Oberfläche muss
* sich nicht merken, was sie schon geschickt hat. Was fehlt, wird
* freigegeben — und ist damit sofort tot.
*/
aliases: z.array(z.string().trim().max(120)).max(50).optional(),
});
async function loadCode(id: string) {
@@ -81,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
@@ -109,20 +133,122 @@ export async function PATCH(
Object.assign(update, parsed.data.design);
}
if (Object.keys(update).length === 0) {
// Ein eigenes Logo gehört zur Gestaltung, nicht zum Inhalt. Es muss
// trotzdem an den Code gehängt werden — sonst liegt die Datei herrenlos
// herum und wird nach einem Tag weggeräumt, mitsamt dem Logo im Code.
const logoVorher =
existing.logoSource === "upload" ? existing.logoUploadId : null;
const logoNachher =
parsed.data.design === undefined
? logoVorher
: parsed.data.design.logoSource === "upload"
? parsed.data.design.logoUploadId
: null;
/* ------------------------------------------------------- Kurz-Link */
const neuerSlug = parsed.data.slug?.trim();
// Ein statischer Code hat keinen Kurz-Link — sein Inhalt steckt in ihm
// selbst. Ein Umbenennen waere gegenstandslos.
if (existing.mode === "static" && neuerSlug) {
return jsonError(
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
);
}
const umbenannt =
neuerSlug !== undefined &&
existing.slug !== null &&
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
/**
* Welche früheren Adressen gelten sollen. Fehlt das Feld, bleibt alles wie
* es ist; steht es da, beschreibt es den gewünschten Zustand.
*/
const behalten =
parsed.data.aliases === undefined
? null
: new Set(parsed.data.aliases.map(normalizeSlug));
const vorhandeneAliase = await prisma.qrCodeAlias.findMany({
where: { qrCodeId: existing.id },
select: { id: true, slug: true },
});
const freigeben = behalten
? vorhandeneAliase.filter((a) => !behalten.has(normalizeSlug(a.slug)))
: [];
// Erst prüfen, wenn wirklich etwas am Kurz-Link geändert wird. Die
// Oberfläche schickt die Liste der früheren Adressen bei jeder Änderung
// mit — unverändert soll das kein zusätzliches Recht verlangen.
if (umbenannt || freigeben.length > 0) {
const { editorsMaySetSlug } = await getSettings();
requirePermission(actor, "codes:slug", {
groupId: existing.groupId,
editorsMaySetSlug,
});
}
if (umbenannt) {
const problem = describeSlugProblem(neuerSlug);
if (problem) return jsonError(problem);
await assertSlugAvailable(existing.domainId, neuerSlug, existing.id);
update.slug = normalizeSlug(neuerSlug);
}
if (
Object.keys(update).length === 0 &&
!umbenannt &&
freigeben.length === 0
) {
return jsonError("Es wurden keine Änderungen übermittelt.");
}
const updated = await prisma.qrCode.update({
where: { id },
data: update,
include: WITH_RELATIONS,
// Umbenennen, alte Adresse sichern und Freigaben in einem Zug — sonst
// gäbe es einen Moment, in dem der Code unter keiner Adresse erreichbar
// ist, oder eine freigegebene Adresse bliebe hängen.
const updated = await prisma.$transaction(async (tx) => {
if (freigeben.length > 0) {
await tx.qrCodeAlias.deleteMany({
where: { id: { in: freigeben.map((a) => a.id) } },
});
}
if (umbenannt) {
// Der bisherige Kurz-Link bleibt gültig — daran hängen gedruckte
// Codes. Freigeben lässt er sich danach einzeln.
await tx.qrCodeAlias.create({
data: {
domainId: existing.domainId,
// `umbenannt` gilt nur bei vorhandenem Kurz-Link.
slug: existing.slug as string,
qrCodeId: existing.id,
},
});
}
return tx.qrCode.update({
where: { id },
data: update,
include: WITH_RELATIONS,
});
});
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
// später als verwaist weggeräumt.
if (neueDateien !== null) {
await attachUploads(updated.id, neueDateien);
await attachUploads(
updated.id,
logoNachher ? [...neueDateien, logoNachher] : neueDateien,
);
} else if (logoNachher && logoNachher !== logoVorher) {
await attachUpload(updated.id, logoNachher);
}
// Ein abgelegtes oder ausgetauschtes Logo verliert seinen Bezug und wird
// später weggeräumt.
if (logoVorher && logoVorher !== logoNachher) {
await detachUpload(updated.id, logoVorher);
}
return Response.json({ code: toQrCodeDto(updated) });
+100 -5
View File
@@ -12,7 +12,9 @@ import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request";
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
import { generateSlug } from "@/lib/slug";
import { describeSlugProblem, generateSlug, normalizeSlug } from "@/lib/slug";
import { assertSlugAvailable } from "@/lib/slug-resolve";
import { getSettings } from "@/lib/settings";
import { attachUploads } from "@/lib/uploads";
export const dynamic = "force-dynamic";
@@ -26,12 +28,41 @@ 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.
*/
slug: z.string().trim().max(120).optional(),
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
groupId: z.string().trim().min(1).optional(),
/** Unter welcher Domain die Kurz-URL entsteht. */
domainId: z.string().trim().min(1).optional(),
});
/**
* Alle Dateien, die zu einem Code gehören.
*
* Das sind die des Typ-Moduls — ein PDF, ein Bild — **und** ein eigenes Logo
* aus der Gestaltung. Wird das vergessen, löst `attachUploads` das Logo beim
* nächsten Speichern wieder vom Code und räumt es später als herrenlos weg.
*/
function dateienDesCodes(
qrType: { uploadIds: (data: never) => string[] },
data: unknown,
design: { logoSource: string | null; logoUploadId: string | null },
): string[] {
const ids = qrType.uploadIds(data as never);
return design.logoSource === "upload" && design.logoUploadId
? [...ids, design.logoUploadId]
: ids;
}
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === "object" &&
@@ -57,6 +88,7 @@ export async function GET(): Promise<Response> {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
},
});
@@ -93,15 +125,69 @@ 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.
const gewuenscht = parsed.data.slug?.trim();
if (gewuenscht) {
// Einen Kurz-Link selbst zu vergeben wiegt mehr als einen Code anzulegen.
const { editorsMaySetSlug } = await getSettings();
requirePermission(actor, "codes:slug", { groupId, editorsMaySetSlug });
const problem = describeSlugProblem(gewuenscht);
if (problem) return jsonError(problem);
await assertSlugAvailable(domainId, gewuenscht);
}
if (istStatisch) {
const created = await prisma.qrCode.create({
data: {
mode: "static",
slug: null,
type: qrType.id,
label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data),
groupId,
domainId,
createdById: actor.kind === "user" ? actor.user.id : null,
...design,
},
include: {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
},
});
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
}
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const created = await prisma.qrCode.create({
data: {
slug: generateSlug(),
slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
type: qrType.id,
label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data),
@@ -114,14 +200,23 @@ export async function POST(request: NextRequest): Promise<Response> {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
},
});
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
// vorher lagen sie ohne Bezug herum.
await attachUploads(created.id, qrType.uploadIds(validated.data));
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) {
// Bei einem selbst gewählten Kurz-Link hilft kein zweiter Versuch: Er
// käme immer wieder auf dieselbe Adresse.
if (gewuenscht && isUniqueViolation(error)) {
return jsonError(
`Der Kurz-Link „${normalizeSlug(gewuenscht)}" ist auf dieser Domain schon vergeben.`,
409,
);
}
if (!isUniqueViolation(error)) throw error;
}
}
+5 -3
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { ladeLogo } from "@/lib/qr/logo";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import {
downloadFilename,
@@ -38,15 +39,16 @@ export async function POST(request: NextRequest): Promise<Response> {
const { qrType, data, content } = await resolveQrRequest(parsed.data);
const logo = await ladeLogo(design);
const body =
format === "png"
? new Uint8Array(await renderPng(content, design))
: await renderSvg(content, design);
? new Uint8Array(await renderPng(content, design, logo))
: await renderSvg(content, design, logo);
return new Response(body, {
headers: {
"Content-Type": CONTENT_TYPES[format],
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format)}"`,
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format, parsed.data.label)}"`,
"Cache-Control": "no-store",
},
});
+3 -1
View File
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { ladeLogo } from "@/lib/qr/logo";
import { renderSvg } from "@/lib/qr/render";
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
@@ -21,7 +22,8 @@ export async function POST(request: NextRequest): Promise<Response> {
}
const { content } = await resolveQrRequest(parsed.data);
const svg = await renderSvg(content, parsed.data.design);
const logo = await ladeLogo(parsed.data.design);
const svg = await renderSvg(content, parsed.data.design, logo);
return Response.json({
content,
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ slug: string }> };
// Ein Kurz-Link darf „/" enthalten (z. B. kunde-a/sommer), deshalb eine
// Sammelroute: Next liefert die Abschnitte dann als Liste.
type Context = { params: Promise<{ slug: string[] }> };
function notFound(message: string): Response {
return new Response(
@@ -44,7 +47,8 @@ export async function GET(
request: NextRequest,
context: Context,
): Promise<Response> {
const { slug } = await context.params;
const abschnitte = (await context.params).slug;
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) {
return notFound("Diese Kurz-Adresse ist ungültig.");
@@ -65,9 +69,8 @@ export async function GET(
);
}
const code = await prisma.qrCode.findUnique({
where: { domainId_slug: { domainId: domain.id, slug } },
});
const gefunden = await findCodeBySlug(domain.id, slug);
const code = gefunden?.code;
if (!code) {
return notFound(
@@ -85,7 +88,7 @@ export async function GET(
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
// wenn das eine nicht registrierte ist, etwa eine IP im LAN).
target = `/s/${slug}`;
target = `/s/${code.slug}`;
} else {
try {
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
@@ -3,14 +3,15 @@ import { headers } from "next/headers";
import { qrLandings } from "@/components/landing";
import { LandingNotice } from "@/components/landing/shell";
import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ slug: string }> };
// Sammelroute, weil ein Kurz-Link „/" enthalten darf.
type PageProps = { params: Promise<{ slug: string[] }> };
/**
* Öffentliche Landingpage eines dynamischen Codes.
@@ -21,16 +22,15 @@ type PageProps = { params: Promise<{ slug: string }> };
* Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan.
*/
async function loadCode(slug: string) {
async function loadCode(abschnitte: string[] | undefined) {
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) return null;
const matched = await resolveRequestDomain(await headers());
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
if (!domain.isActive) return null;
return prisma.qrCode.findUnique({
where: { domainId_slug: { domainId: domain.id, slug } },
});
return (await findCodeBySlug(domain.id, slug))?.code ?? null;
}
export async function generateMetadata({
@@ -91,6 +91,17 @@ export default async function LandingPage({ params }: PageProps) {
);
}
// Gefunden wird ein Code nur ueber seinen Kurz-Link; statische haben keinen
// und koennen hier deshalb gar nicht ankommen. Der Zweig ist reine Vorsorge.
if (!code.slug) {
return (
<LandingNotice title="Diese Seite gibt es nicht">
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
der ihn erstellt hat.
</LandingNotice>
);
}
return (
<Landing
data={code.data}
+227 -64
View File
@@ -19,6 +19,7 @@ import {
import { ApiError, apiJson } from "@/lib/api-client";
import type { QrCodeDto } from "@/lib/dto";
import { qrTypes } from "@/lib/qr/registry";
import { describeSlugProblem } from "@/lib/slug";
import type { QrFormData } from "@/lib/qr/type-module";
const dateFormat = new Intl.DateTimeFormat("de-DE", {
@@ -65,11 +66,19 @@ function displayName(code: QrCodeDto): string {
return code.label?.trim() || code.summary;
}
export function CodesList() {
export function CodesList({
/** Darf der Kurz-Link geändert werden? Sonst erscheinen die Felder nicht. */
maySetSlug = true,
}: {
maySetSlug?: boolean;
}) {
const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [editing, setEditing] = useState<QrCodeDto | null>(null);
const [editSlug, setEditSlug] = useState("");
/** Welche früheren Adressen gelten sollen — was hier fehlt, wird freigegeben. */
const [editAliases, setEditAliases] = useState<string[]>([]);
const [editData, setEditData] = useState<QrFormData>({});
const [editLabel, setEditLabel] = useState("");
const [editError, setEditError] = useState<string | null>(null);
@@ -124,9 +133,21 @@ export function CodesList() {
setEditing(code);
setEditData(code.data);
setEditLabel(code.label ?? "");
setEditSlug(code.slug ?? "");
setEditAliases(code.aliases);
setEditError(null);
};
const slugProblem =
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
const slugGeaendert =
editing !== null &&
editing.slug !== null &&
editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
const freigegeben = editing
? editing.aliases.filter((alias) => !editAliases.includes(alias))
: [];
const EditForm = editing ? qrForms[editing.type] : undefined;
const editValidation = useMemo(
() => (editing ? qrTypes[editing.type].validate(editData) : null),
@@ -139,12 +160,29 @@ export function CodesList() {
setEditError(editValidation.message);
return;
}
if (slugProblem) {
setEditError(slugProblem);
return;
}
setSaving(true);
setEditError(null);
try {
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
method: "PATCH",
json: { data: editValidation.data, label: editLabel.trim() || null },
json: {
label: editLabel.trim() || null,
// Ein statischer Code ist ein Schnappschuss — Inhalt und Kurz-Link
// gehören nicht in die Anfrage.
...(editing.mode === "static"
? {}
: {
data: editValidation.data,
// Nur mitschicken, wenn es bearbeitet werden durfte.
...(maySetSlug
? { slug: editSlug.trim(), aliases: editAliases }
: {}),
}),
},
});
setEditing(null);
await load();
@@ -179,6 +217,7 @@ export function CodesList() {
};
const copy = async (code: QrCodeDto) => {
if (!code.shortUrl) return;
try {
await navigator.clipboard.writeText(code.shortUrl);
setCopiedId(code.id);
@@ -449,9 +488,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>
@@ -461,28 +509,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">
@@ -497,7 +551,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>
)}
@@ -512,7 +572,13 @@ export function CodesList() {
) : null}
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
{code.scans}
{code.mode === "static" ? (
<span className="font-normal text-slate-400" title="Statische Codes werden nicht gezählt">
—
</span>
) : (
code.scans
)}
</td>
<td
@@ -539,8 +605,8 @@ export function CodesList() {
<button
type="button"
onClick={() => openEdit(code)}
title="Ziel bearbeiten"
aria-label={`Ziel von ${displayName(code)} bearbeiten`}
title="Bearbeiten"
aria-label={`${displayName(code)} bearbeiten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
<Icon name="pencil" className="size-4" />
@@ -610,30 +676,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>
@@ -683,7 +760,7 @@ export function CodesList() {
onClick={() => openEdit(code)}
>
<Icon name="pencil" className="size-3.5" />
Ziel bearbeiten
Bearbeiten
</Button>
<a
href={`/api/codes/${code.id}/qr?format=png&download=1`}
@@ -721,7 +798,7 @@ export function CodesList() {
{/* ------------------------------------------------------ Bearbeiten */}
<Modal
open={editing !== null}
title="Ziel bearbeiten"
title="Code bearbeiten"
onClose={() => setEditing(null)}
footer={
<>
@@ -742,15 +819,12 @@ export function CodesList() {
{editing ? (
<div className="space-y-4">
<p className="text-sm text-slate-600">
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin
auf{" "}
<code className="font-mono text-xs break-all">
{editing.shortUrl}
</code>
.
{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) => {
@@ -766,6 +840,95 @@ export function CodesList() {
</Notice>
)}
{maySetSlug && editing.mode !== "static" ? (
<>
<Field
label="Kurz-Link"
htmlFor="edit-slug"
hint="Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
>
<TextInput
id="edit-slug"
value={editSlug}
maxLength={100}
spellCheck={false}
invalid={Boolean(slugProblem)}
disabled={saving}
onChange={(event) => {
setEditSlug(event.target.value);
setEditError(null);
}}
/>
</Field>
{slugProblem ? (
<Notice tone="error">{slugProblem}</Notice>
) : slugGeaendert ? (
<Notice tone="info">
Der bisherige Kurz-Link{" "}
<code className="font-mono text-xs break-all">{editing.slug}</code>{" "}
bleibt gültig — schon gedruckte Codes funktionieren weiter. Unten
lässt er sich später freigeben.
</Notice>
) : null}
{editing.aliases.length > 0 ? (
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
Frühere Kurz-Links
</span>
<p className="mb-2 text-sm text-slate-500">
Führen weiterhin auf diesen Code. Freigeben nur, wenn damit
nichts gedruckt ist — die Adresse ist danach sofort tot.
</p>
<ul className="space-y-1.5">
{editing.aliases.map((alias) => {
const behalten = editAliases.includes(alias);
return (
<li
key={alias}
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2"
>
<code
className={
behalten
? "min-w-0 font-mono text-xs break-all text-slate-700"
: "min-w-0 font-mono text-xs break-all text-slate-400 line-through"
}
>
/r/{alias}
</code>
<Button
type="button"
variant={behalten ? "ghost" : "secondary"}
size="sm"
disabled={saving}
onClick={() =>
setEditAliases((alt) =>
behalten
? alt.filter((eintrag) => eintrag !== alias)
: [...alt, alias],
)
}
>
{behalten ? "Freigeben" : "Doch behalten"}
</Button>
</li>
);
})}
</ul>
{freigegeben.length > 0 ? (
<Notice tone="warning">
{freigegeben.length === 1
? "Eine frühere Adresse wird beim Speichern freigegeben und funktioniert danach nicht mehr."
: `${freigegeben.length} frühere Adressen werden beim Speichern freigegeben und funktionieren danach nicht mehr.`}
</Notice>
) : null}
</div>
) : null}
</>
) : null}
<Field label="Name (optional)" htmlFor="edit-label">
<TextInput
id="edit-label"
+160 -4
View File
@@ -1,15 +1,20 @@
"use client";
import { Icon } from "@/components/icons";
import { FileField } from "@/components/qr-forms/file-field";
import { Field, Notice, TextInput, cx } from "@/components/ui";
import { evaluateContrast } from "@/lib/qr/contrast";
import {
COLOR_PRESETS,
EC_LEVELS,
EC_LEVEL_LABELS,
LOGO_SCALE_WARN,
MAX_LOGO_SCALE,
MAX_SIZE,
MIN_LOGO_SCALE,
MIN_SIZE,
type EcLevel,
type LogoSource,
type QrDesign,
} from "@/lib/qr/design";
@@ -62,9 +67,12 @@ function ColorField({
export function DesignPanel({
design,
onChange,
/** Ist unter „Erscheinungsbild“ ein Logo hinterlegt? Sonst entfällt die Wahl. */
hasBrandingLogo = false,
}: {
design: QrDesign;
onChange: (design: QrDesign) => void;
hasBrandingLogo?: boolean;
}) {
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
const contrast = evaluateContrast(design.fgColor, design.bgColor);
@@ -165,7 +173,11 @@ export function DesignPanel({
<Field
label="Fehlerkorrektur"
hint={EC_LEVEL_LABELS[design.ecLevel]}
hint={
design.logoSource === null
? EC_LEVEL_LABELS[design.ecLevel]
: "Mit Logo gilt H — was das Bild verdeckt, muss die Fehlerkorrektur ausgleichen."
}
>
<div
role="radiogroup"
@@ -177,11 +189,14 @@ export function DesignPanel({
key={level}
type="button"
role="radio"
aria-checked={design.ecLevel === level}
aria-checked={
design.logoSource === null ? design.ecLevel === level : level === "H"
}
disabled={design.logoSource !== null}
onClick={() => patch({ ecLevel: level })}
className={cx(
"rounded-lg border py-2 text-sm font-medium transition-colors",
design.ecLevel === level
"rounded-lg border py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
(design.logoSource === null ? design.ecLevel : "H") === level
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
)}
@@ -208,6 +223,147 @@ export function DesignPanel({
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
</Field>
<LogoAuswahl
design={design}
patch={patch}
hasBrandingLogo={hasBrandingLogo}
/>
</div>
);
}
/* -------------------------------------------------------------------- Logo */
/**
* Das Bild in der Mitte des Codes.
*
* Bewusst ein eigener Abschnitt und nicht bloß ein Feld: Ein Logo zieht drei
* Entscheidungen nach sich — woher es kommt, wie groß es wird, und dass die
* Fehlerkorrektur dafür hochgesetzt wird. Das gehört sichtbar beieinander.
*/
function LogoAuswahl({
design,
patch,
hasBrandingLogo,
}: {
design: QrDesign;
patch: (changes: Partial<QrDesign>) => void;
hasBrandingLogo: boolean;
}) {
const an = design.logoSource !== null;
const waehle = (quelle: LogoSource | null) => {
if (quelle === null) {
patch({ logoSource: null, logoUploadId: null });
} else if (quelle === "branding") {
patch({ logoSource: "branding", logoUploadId: null });
} else {
patch({ logoSource: "upload" });
}
};
return (
<fieldset className="border-t border-slate-200 pt-4">
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
Logo in der Mitte
</legend>
<label className="mt-3 flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={an}
onChange={(event) =>
waehle(
event.target.checked ? (hasBrandingLogo ? "branding" : "upload") : null,
)
}
className="mt-0.5 size-4 shrink-0 rounded border-slate-300 accent-indigo-600"
/>
<span className="text-sm text-slate-700">
Ein Logo in die Mitte des Codes setzen
</span>
</label>
{an ? (
<div className="mt-4 space-y-4">
{hasBrandingLogo ? (
<div
role="radiogroup"
aria-label="Herkunft des Logos"
className="space-y-2"
>
{(
[
["branding", "Logo dieser Installation"],
["upload", "Eigenes Logo hochladen"],
] as [LogoSource, string][]
).map(([quelle, text]) => (
<label
key={quelle}
className="flex cursor-pointer items-center gap-2.5"
>
<input
type="radio"
name="logo-quelle"
checked={design.logoSource === quelle}
onChange={() => waehle(quelle)}
className="size-4 shrink-0 accent-indigo-600"
/>
<span className="text-sm text-slate-700">{text}</span>
</label>
))}
</div>
) : null}
{design.logoSource === "upload" ? (
<div className="space-y-2">
<FileField
kind="qrlogo"
label={design.logoUploadId ? "Anderes Logo wählen" : "Logo wählen"}
onUploaded={(datei) => patch({ logoUploadId: datei.fileId })}
/>
{design.logoUploadId ? null : (
<p className="text-sm text-amber-700">
Noch keine Datei gewählt — bis dahin bleibt die Mitte frei.
</p>
)}
</div>
) : null}
<Field
label={`Größe: ${design.logoScale} % der Kantenlänge`}
htmlFor="qr-logo-scale"
hint="Bezogen auf die Breite des Codes. Bei 20 % sind rund 4 % der Fläche verdeckt."
>
<input
id="qr-logo-scale"
type="range"
min={MIN_LOGO_SCALE}
max={MAX_LOGO_SCALE}
step={1}
value={design.logoScale}
onChange={(event) => patch({ logoScale: Number(event.target.value) })}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
</Field>
{design.logoScale > LOGO_SCALE_WARN ? (
<Notice tone="warning">
Ab {LOGO_SCALE_WARN} % wird es knapp. Die Fehlerkorrektur trägt das
zwar, aber manche Scanner-Apps suchen in der Mitte nach
Ausrichtungsmustern. Prüfe den fertigen Code mit mehr als einer App,
bevor er in den Druck geht.
</Notice>
) : null}
<p className="text-sm text-slate-500">
Hinter dem Logo liegt eine Fläche in der Hintergrundfarbe, damit es
sich absetzt. Ein Code mit Logo gehört vor dem Druck einmal
eingescannt — das ist die einzige Prüfung, die wirklich zählt.
</p>
</div>
) : null}
</fieldset>
);
}
+108 -17
View File
@@ -22,6 +22,7 @@ import type { QrCodeDto } from "@/lib/dto";
import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design";
import { qrTypes } from "@/lib/qr/registry";
import type { QrFormData, QrTypeId } from "@/lib/qr/type-module";
import { describeSlugProblem } from "@/lib/slug";
type PreviewState = { dataUrl: string; content: string };
@@ -42,7 +43,17 @@ function sameData(a: unknown, b: unknown): boolean {
return stableStringify(a) === stableStringify(b);
}
export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
export function QrGenerator({
typeId,
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
maySetSlug = true,
/** Ist ein Logo der Installation hinterlegt? Sonst nur eigener Upload. */
hasBrandingLogo = false,
}: {
typeId: QrTypeId;
maySetSlug?: boolean;
hasBrandingLogo?: boolean;
}) {
const qrType = qrTypes[typeId];
const Form = qrForms[typeId];
@@ -51,6 +62,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
// Typen ohne statische Variante — etwa der App-Link — starten dynamisch.
const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic);
const [label, setLabel] = useState("");
/** Selbst gewählter Kurz-Link. Leer bedeutet: einen erzeugen lassen. */
const [wunschSlug, setWunschSlug] = useState("");
const [saved, setSaved] = useState<QrCodeDto | null>(null);
const [saving, setSaving] = useState(false);
@@ -74,11 +87,13 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
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;
@@ -164,6 +179,11 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
/* ----------------------------------------------- Dynamischen Code speichern */
// Sofortige Rückmeldung, damit man nicht erst beim Speichern erfährt, dass
// ein Zeichen nicht erlaubt ist.
const slugProblem =
wunschSlug.trim().length > 0 ? describeSlugProblem(wunschSlug) : null;
const hasUnsavedChanges =
saved !== null &&
(!sameData(saved.data, validation.ok ? validation.data : data) ||
@@ -171,7 +191,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
(saved.label ?? "") !== label.trim());
const handleSave = useCallback(async () => {
if (!validation.ok || !designValid) return;
if (!validation.ok || !designValid || slugProblem) return;
setSaving(true);
setSaveError(null);
try {
@@ -179,7 +199,10 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
type: typeId,
data: validation.data,
design,
mode,
label: label.trim() || undefined,
// Ein statischer Code hat keinen Kurz-Link.
slug: isDynamic ? wunschSlug.trim() || undefined : undefined,
};
const result = saved
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
@@ -200,10 +223,22 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
} finally {
setSaving(false);
}
}, [validation, designValid, typeId, design, label, saved]);
}, [validation, designValid, slugProblem, typeId, design, mode, isDynamic, label, wunschSlug, saved]);
/**
* Wechselt die Betriebsart und vergisst dabei einen zuvor gespeicherten
* Code. Ein abgelegter statischer Eintrag ist etwas anderes als ein
* gespeicherter dynamischer — beides gleichzeitig festzuhalten führte
* dazu, dass die Oberfläche den falschen Zustand zeigt.
*/
const wechsleModus = useCallback((dynamisch: boolean) => {
setIsDynamic(dynamisch);
setSaved(null);
setSaveError(null);
}, []);
const handleCopy = useCallback(async () => {
if (!saved) return;
if (!saved?.shortUrl) return;
try {
await navigator.clipboard.writeText(saved.shortUrl);
setCopied(true);
@@ -286,7 +321,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
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
@@ -310,7 +345,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
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
@@ -331,12 +366,15 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
</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"
@@ -347,7 +385,57 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
/>
</Field>
{saved ? (
{!isDynamic || saved || !maySetSlug ? null : (
<Field
label="Kurz-Link (optional)"
htmlFor="code-slug"
error={slugProblem}
hint="Leer lassen, dann wird einer erzeugt. Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
>
<TextInput
id="code-slug"
value={wunschSlug}
maxLength={100}
spellCheck={false}
invalid={Boolean(slugProblem)}
placeholder="z. B. sommeraktion oder kunde-a/flyer"
onChange={(event) => setWunschSlug(event.target.value)}
/>
</Field>
)}
{!isDynamic ? (
/* Statisch: Der Code ist ohne Speichern fertig. Ablegen ist
ein Angebot, kein Zwang — es geht nur ums Wiederfinden.
Nach einer Inhaltsänderung ist die Ablage ein Schnappschuss
von vorher; dann wird sie wieder angeboten. */
saved && !hasUnsavedChanges ? (
<div className="space-y-3">
<Notice tone="success">
<span className="font-medium">Abgelegt.</span> Du findest
den Code jetzt unter „Meine Codes“ und kannst ihn dort
erneut herunterladen. Der Code selbst ändert sich dadurch
nicht — sein Inhalt steckt in ihm.
</Notice>
<Link
href="/meine-codes"
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-accent-text hover:text-accent-soft-fg"
>
Zu „Meine Codes“
<Icon name="external" className="size-3.5" />
</Link>
</div>
) : (
<Button
type="button"
variant="secondary"
onClick={handleSave}
disabled={saving || !ready}
>
{saving ? "Legt ab …" : "In „Meine Codes“ ablegen"}
</Button>
)
) : saved ? (
<div className="space-y-3">
<Notice tone="success">
<span className="font-medium">Code gespeichert.</span> Der
@@ -408,7 +496,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<Button
type="button"
onClick={handleSave}
disabled={saving || !ready}
disabled={saving || !ready || Boolean(slugProblem)}
>
{saving ? "Speichert …" : "Dynamischen Code speichern"}
</Button>
@@ -416,8 +504,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
)}
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
</div>
) : null}
</div>
</Card>
</div>
@@ -426,7 +513,11 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
Gestaltung
</CardTitle>
<DesignPanel design={design} onChange={setDesign} />
<DesignPanel
design={design}
onChange={setDesign}
hasBrandingLogo={hasBrandingLogo}
/>
</Card>
</div>
</div>
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { Card, CardTitle, Checkbox, Notice } from "@/components/ui";
export type SettingsDto = {
editorsMaySetSlug: boolean;
};
export function SettingsAdmin({ initial }: { initial: SettingsDto }) {
const [wert, setWert] = useState(initial);
const [fehler, setFehler] = useState<string | null>(null);
const [hinweis, setHinweis] = useState<string | null>(null);
const [laeuft, setLaeuft] = useState(false);
// Bewusst ohne Speichern-Knopf: Ein einzelner Schalter, der erst nach einem
// zweiten Klick wirkt, ist eine Stolperfalle.
const umschalten = async (aktiv: boolean) => {
setLaeuft(true);
setFehler(null);
setHinweis(null);
const vorher = wert;
setWert({ ...wert, editorsMaySetSlug: aktiv });
try {
const antwort = await fetch("/api/admin/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ editorsMaySetSlug: aktiv }),
});
const rumpf = await antwort.json().catch(() => null);
if (!antwort.ok) {
throw new Error(rumpf?.error ?? "Speichern nicht möglich.");
}
setWert(rumpf.settings);
setHinweis(
aktiv
? "Bearbeiter dürfen Kurz-Links jetzt selbst vergeben und ändern."
: "Kurz-Links vergeben und ändern dürfen jetzt nur noch Administratoren.",
);
} catch (error) {
setWert(vorher);
setFehler(error instanceof Error ? error.message : String(error));
} finally {
setLaeuft(false);
}
};
return (
<div className="space-y-5">
{fehler ? <Notice tone="error">{fehler}</Notice> : null}
{hinweis ? <Notice tone="success">{hinweis}</Notice> : null}
<Card>
<CardTitle hint="Gilt für alle Gruppen dieser Installation.">
Kurz-Links
</CardTitle>
<Checkbox
id="editors-may-set-slug"
label="Bearbeiter dürfen den Kurz-Link selbst vergeben und ändern"
hint="Aus: Nur Administratoren. Bearbeiter legen Codes an, der Kurz-Link wird dabei erzeugt."
checked={wert.editorsMaySetSlug}
disabled={laeuft}
onChange={(event) => void umschalten(event.target.checked)}
/>
<div className="mt-4 space-y-2 text-sm text-slate-600">
<p>
Ein Kurz-Link ist eine öffentliche Adresse und steht auf gedruckten
Codes. Wer ihn ändert, kann damit Aushänge und Flyer entwerten —
die vorherige Adresse bleibt zwar gültig, lässt sich aber auch
wieder freigeben.
</p>
<p>
Deshalb ist das standardmäßig Administratoren vorbehalten. In einem
kleinen Team, in dem alle wissen, was ein Kurz-Link bedeutet, ist
die Freigabe unbedenklich.
</p>
</div>
</Card>
</div>
);
}
+52
View File
@@ -96,6 +96,58 @@ export function redirectUri(): string {
return `${getBaseUrl()}/api/auth/oidc/callback`;
}
/**
* Prüft, ob der Browser über dieselbe Adresse kommt, auf die `BASE_URL` zeigt.
*
* Warum das zwingend ist: Der Aufruf setzt einen Cookie mit dem laufenden
* Vorgang — und zwar auf dem Host, über den der Browser gerade da ist. Die
* Rückkehradresse für den Anbieter kommt dagegen aus `BASE_URL`. Weichen die
* beiden voneinander ab, schickt der Anbieter den Browser auf einen anderen
* Host, der Cookie geht dabei verloren, und die Anmeldung endet mit
* „Der Anmeldevorgang ist abgelaufen“ — einer Meldung, die die eigentliche
* Ursache gut versteckt.
*
* Deshalb hier eine frühe Prüfung mit einer Meldung, die beide Adressen nennt.
*/
export function assertHostMatchesBaseUrl(headers: Headers): void {
const angekommen = (
headers.get("x-forwarded-host") ??
headers.get("host") ??
""
)
.split(",")[0]
.trim()
.toLowerCase();
if (!angekommen) return;
let erwartet = "";
try {
erwartet = new URL(getBaseUrl()).host.toLowerCase();
} catch {
return; // Unbrauchbare BASE_URL — dafür gibt es eigene Meldungen.
}
if (angekommen === erwartet) return;
// Ins Protokoll gehören beide Adressen — dort liest nur mit, wer den Server
// bedient. Auf dem Bildschirm steht bewusst nur die, über die der Aufruf
// ohnehin kam: Die Anmeldeseite sieht jeder, der die Adresse kennt, und die
// interne Adresse der Installation geht Fremde nichts an.
console.error(
`OIDC: Host-Abweichung — Aufruf über "${angekommen}", BASE_URL zeigt auf "${erwartet}".`,
);
throw new QrRequestError(
`Die Anmeldung über den Anbieter ist für „${angekommen}" nicht eingerichtet. ` +
`In der Konfiguration steht eine andere Adresse, deshalb ginge der ` +
`Anmeldevorgang auf dem Rückweg verloren. Wer die Anwendung betreibt, ` +
`findet den erwarteten Wert unter BASE_URL — und die Einzelheiten im ` +
`Protokoll des Containers.`,
400,
);
}
type Discovery = {
issuer: string;
authorization_endpoint: string;
+27 -1
View File
@@ -21,12 +21,30 @@ export type Action =
| "codes:update"
/** Codes löschen. */
| "codes:delete"
/**
* Den Kurz-Link selbst vergeben oder ändern.
*
* Eigene Aktion und nicht Teil von `codes:update`, weil sie mehr wiegt: Ein
* Kurz-Link ist eine öffentliche Adresse. Wer ihn ändert, entwertet im
* Zweifel gedruckte Codes; wer ihn vergibt, besetzt eine Adresse, die
* jemand anderes gebraucht hätte. Deshalb standardmäßig nur für
* Administratoren — freigeben lässt es sich in der Verwaltung.
*/
| "codes:slug"
/** Mitglieder und Rollen einer Gruppe verwalten. */
| "group:manage"
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
| "instance:manage";
export type Scope = { groupId?: string };
export type Scope = {
groupId?: string;
/**
* Die Einstellung aus der Verwaltung, ob Bearbeiter Kurz-Links vergeben
* dürfen. Wird von der aufrufenden Stelle mitgegeben, damit die Regeln hier
* ohne Datenbankzugriff auskommen und prüfbar bleiben.
*/
editorsMaySetSlug?: boolean;
};
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
@@ -89,6 +107,12 @@ export function can(actor: Actor, action: Action, scope: Scope = {}): boolean {
case "codes:update":
case "codes:delete":
return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
case "codes:slug":
// Administratoren der Gruppe immer, Bearbeiter nur nach Freigabe.
if (role === "ADMIN") return true;
return (
ROLE_RANK[role] >= ROLE_RANK.EDITOR && scope.editorsMaySetSlug === true
);
case "group:manage":
return role === "ADMIN";
default:
@@ -104,6 +128,8 @@ const DENIED_MESSAGES: Record<Action, string> = {
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"codes:delete":
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"codes:slug":
"Den Kurz-Link darf hier nur ein Administrator vergeben oder ändern. Ein Administrator der Installation kann das unter Verwaltung → Einstellungen für Bearbeiter freigeben.",
"group:manage":
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
"instance:manage":
+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}`;
}
+19 -5
View File
@@ -3,7 +3,7 @@ import "server-only";
import type { Prisma, QrCode } from "@/generated/prisma/client";
import type { QrCodeDto } from "@/lib/dto";
import { landingUrl, shortUrl } from "@/lib/env";
import type { EcLevel } from "@/lib/qr/design";
import type { EcLevel, LogoSource } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry";
import type { QrTypeId } from "@/lib/qr/type-module";
@@ -31,22 +31,31 @@ export type QrCodeWithRelations = QrCode & {
group?: { name: string } | null;
domain?: { hostname: string } | null;
createdBy?: { name: string | null; email: string } | null;
aliases?: { slug: string }[];
};
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
@@ -59,11 +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: {
@@ -72,6 +83,9 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
size: code.size,
ecLevel: code.ecLevel as EcLevel,
margin: code.margin,
logoSource: code.logoSource as LogoSource | null,
logoUploadId: code.logoUploadId,
logoScale: code.logoScale,
},
scans: code.scans,
lastScanAt: code.lastScanAt?.toISOString() ?? null,
+21 -6
View File
@@ -1,19 +1,31 @@
import type { EcLevel } from "@/lib/qr/design";
import type { EcLevel, LogoSource } from "@/lib/qr/design";
import type { QrTypeId } from "@/lib/qr/type-module";
/** 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;
@@ -23,6 +35,9 @@ export type QrCodeDto = {
size: number;
ecLevel: EcLevel;
margin: number;
logoSource: LogoSource | null;
logoUploadId: string | null;
logoScale: number;
};
scans: number;
lastScanAt: string | null;
+55 -1
View File
@@ -13,6 +13,29 @@ export const EC_LEVEL_LABELS: Record<EcLevel, string> = {
export const MIN_SIZE = 256;
export const MAX_SIZE = 2048;
/**
* Logo in der Mitte des Codes.
*
* `branding` nimmt das Logo der Installation aus dem Erscheinungsbild,
* `upload` ein eigenes, das zu diesem Code gehört. Ohne Angabe kein Logo.
*/
export const LOGO_SOURCES = ["branding", "upload"] as const;
export type LogoSource = (typeof LOGO_SOURCES)[number];
/**
* Kantenlänge des Logos in Prozent der Kantenlänge des Codes.
*
* Bei 20 % sind knapp 4 % der Fläche verdeckt — das steckt die
* Fehlerkorrektur H (30 %) mühelos weg. Die Obergrenze liegt bei 25 %, weil
* darüber weniger die Fehlerkorrektur das Problem ist als die Scanner-Apps:
* Manche suchen in der Mitte nach Ausrichtungsmustern.
*/
export const MIN_LOGO_SCALE = 10;
export const MAX_LOGO_SCALE = 25;
/** Ab hier ein Hinweis, dass es knapp wird. */
export const LOGO_SCALE_WARN = 22;
export const DEFAULT_LOGO_SCALE = 20;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const hexColor = z
@@ -39,7 +62,25 @@ export const designSchema = z.object({
.int({ error: "Der Rand muss eine ganze Zahl sein." })
.min(0, { error: "Der Rand darf nicht negativ sein." })
.max(16, { error: "Der Rand darf höchstens 16 Module breit sein." }),
});
logoSource: z
.enum(LOGO_SOURCES, { error: "Unbekannte Herkunft des Logos." })
.nullable()
.default(null),
logoUploadId: z.string().trim().min(1).nullable().default(null),
logoScale: z.coerce
.number({ error: "Die Logogröße muss eine Zahl sein." })
.int({ error: "Die Logogröße muss eine ganze Zahl sein." })
.min(MIN_LOGO_SCALE, {
error: `Das Logo muss mindestens ${MIN_LOGO_SCALE} % breit sein.`,
})
.max(MAX_LOGO_SCALE, {
error: `Das Logo darf höchstens ${MAX_LOGO_SCALE} % breit sein — darüber wird der Code unzuverlässig.`,
})
.default(DEFAULT_LOGO_SCALE),
}).refine(
(design) => design.logoSource !== "upload" || design.logoUploadId !== null,
{ error: "Für ein eigenes Logo fehlt die hochgeladene Datei." },
);
export type QrDesign = z.infer<typeof designSchema>;
@@ -49,8 +90,21 @@ export const defaultDesign: QrDesign = {
size: 512,
ecLevel: "M",
margin: 4,
logoSource: null,
logoUploadId: null,
logoScale: DEFAULT_LOGO_SCALE,
};
/**
* Ein Logo verdeckt einen Teil des Codes. Was fehlt, muss die Fehlerkorrektur
* ausgleichen — deshalb gilt mit Logo immer **H**, unabhängig davon, was in
* der Auswahl steht. Die Oberfläche sagt das dazu; heimlich geschieht hier
* nichts.
*/
export function effectiveEcLevel(design: QrDesign): EcLevel {
return design.logoSource === null ? design.ecLevel : "H";
}
/** Farbvorschläge für die schnelle Auswahl in der Oberfläche. */
export const COLOR_PRESETS: ReadonlyArray<{
label: string;
+52
View File
@@ -0,0 +1,52 @@
import "server-only";
import { getBranding } from "@/lib/branding";
import { prisma } from "@/lib/db";
import type { QrDesign } from "@/lib/qr/design";
import { QrRequestError } from "@/lib/qr/type-module";
/** Das Bild, das in die Mitte des Codes kommt. */
export type QrLogo = {
bytes: Buffer;
mimeType: string;
};
/**
* Holt das Logo, das die Gestaltung verlangt — oder `null`.
*
* Zwei Quellen: das Logo der Installation aus dem Erscheinungsbild, oder eine
* Datei, die zu diesem Code hochgeladen wurde. Beides liegt als Upload in der
* Datenbank.
*
* Fehlt die Datei, ist das kein stiller Ausfall: Ein Code, der ohne Logo
* herauskommt, obwohl eines eingestellt war, fiele erst nach dem Druck auf.
*/
export async function ladeLogo(design: QrDesign): Promise<QrLogo | null> {
if (design.logoSource === null) return null;
const id =
design.logoSource === "branding"
? (await getBranding()).logoUploadId
: design.logoUploadId;
if (!id) {
throw new QrRequestError(
design.logoSource === "branding"
? "Für diese Installation ist kein Logo hinterlegt — unter Verwaltung → Erscheinungsbild lässt sich eines hochladen."
: "Das hochgeladene Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
);
}
const datei = await prisma.upload.findUnique({
where: { id },
select: { data: true, mimeType: true },
});
if (!datei) {
throw new QrRequestError(
"Das Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
);
}
return { bytes: Buffer.from(datei.data), mimeType: datei.mimeType };
}
+103 -4
View File
@@ -1,8 +1,10 @@
import "server-only";
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
import sharp from "sharp";
import type { QrDesign } from "@/lib/qr/design";
import { effectiveEcLevel, type QrDesign } from "@/lib/qr/design";
import type { QrLogo } from "@/lib/qr/logo";
import { UserFacingError } from "@/lib/qr/type-module";
/** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */
@@ -10,7 +12,8 @@ export class QrRenderError extends UserFacingError {}
function toOptions(design: QrDesign): QRCodeRenderersOptions {
return {
errorCorrectionLevel: design.ecLevel,
// Mit Logo gilt immer H — siehe `effectiveEcLevel`.
errorCorrectionLevel: effectiveEcLevel(design),
margin: design.margin,
width: design.size,
color: {
@@ -30,12 +33,103 @@ function wrap(error: unknown): never {
throw new QrRenderError(`Der QR-Code konnte nicht erzeugt werden: ${detail}`);
}
/* ------------------------------------------------------------------- Logo */
/**
* Wie das Logo in den Code kommt.
*
* Hinter dem Bild liegt eine Fläche in der Hintergrundfarbe — ohne sie stünde
* das Logo auf den Modulen und ginge optisch unter. Der Rand dieser Fläche
* ist ein Zehntel der Logobreite; das reicht, um es abzusetzen, ohne mehr
* Code zu verdecken als nötig.
*
* Das Bild selbst wird eingepasst, nicht beschnitten (`contain`): Ein
* Firmenlogo im Querformat soll als Ganzes zu sehen sein.
*/
const PAD_ANTEIL = 0.1;
/** Kantenlänge von Bild und Fläche, in Bildpunkten. */
function logoMasse(gesamt: number, prozent: number) {
const bild = Math.round((gesamt * prozent) / 100);
const flaeche = Math.round(bild * (1 + 2 * PAD_ANTEIL));
return { bild, flaeche, versatz: Math.round((gesamt - flaeche) / 2) };
}
async function legeLogoAuf(
qrPng: Buffer,
design: QrDesign,
logo: QrLogo,
): Promise<Buffer> {
const { bild, flaeche, versatz } = logoMasse(design.size, design.logoScale);
// Erst das Bild auf die Fläche einpassen, dann die Fläche auf den Code.
// Zwei Schritte, weil `extend` den Rand in der Hintergrundfarbe zieht — das
// ergibt die Fläche, ohne eine zweite Zeichnung zu brauchen.
const rand = Math.round((flaeche - bild) / 2);
const kachel = await sharp(logo.bytes)
.resize(bild, bild, { fit: "contain", background: design.bgColor })
.extend({
top: rand,
bottom: flaeche - bild - rand,
left: rand,
right: flaeche - bild - rand,
background: design.bgColor,
})
.png()
.toBuffer();
return sharp(qrPng)
.composite([{ input: kachel, top: versatz, left: versatz }])
.png()
.toBuffer();
}
/**
* Setzt das Logo in ein fertiges QR-SVG.
*
* Das SVG von `qrcode` hat ein `viewBox` in Modulen — gerechnet wird deshalb
* in Modulen und nicht in Bildpunkten. Eingefügt wird direkt vor `</svg>`,
* damit Fläche und Bild über den Modulen liegen.
*/
function legeLogoInSvg(svg: string, design: QrDesign, logo: QrLogo): string {
const treffer = /viewBox="0 0 (\d+(?:\.\d+)?) /.exec(svg);
if (!treffer) {
// Ohne viewBox ließe sich nichts sauber platzieren. Lieber ohne Logo
// ausliefern als an einer falschen Stelle.
console.error("QR-SVG ohne viewBox — Logo wird ausgelassen.");
return svg;
}
const kante = Number(treffer[1]);
const bild = (kante * design.logoScale) / 100;
const flaeche = bild * (1 + 2 * PAD_ANTEIL);
const versatz = (kante - flaeche) / 2;
const bildVersatz = (kante - bild) / 2;
const daten = `data:${logo.mimeType};base64,${logo.bytes.toString("base64")}`;
const rund = (wert: number) => Number(wert.toFixed(3));
const einsatz =
`<rect x="${rund(versatz)}" y="${rund(versatz)}" width="${rund(flaeche)}" height="${rund(flaeche)}" fill="${design.bgColor}"/>` +
`<image x="${rund(bildVersatz)}" y="${rund(bildVersatz)}" width="${rund(bild)}" height="${rund(bild)}" ` +
`preserveAspectRatio="xMidYMid meet" href="${daten}"/>`;
return svg.replace("</svg>", `${einsatz}</svg>`);
}
/* ---------------------------------------------------------------- Ausgabe */
export async function renderSvg(
content: string,
design: QrDesign,
logo: QrLogo | null = null,
): Promise<string> {
try {
return await QRCode.toString(content, { ...toOptions(design), type: "svg" });
const svg = await QRCode.toString(content, {
...toOptions(design),
type: "svg",
});
return logo === null ? svg : legeLogoInSvg(svg, design, logo);
} catch (error) {
wrap(error);
}
@@ -44,9 +138,14 @@ export async function renderSvg(
export async function renderPng(
content: string,
design: QrDesign,
logo: QrLogo | null = null,
): Promise<Buffer> {
try {
return await QRCode.toBuffer(content, { ...toOptions(design), type: "png" });
const png = await QRCode.toBuffer(content, {
...toOptions(design),
type: "png",
});
return logo === null ? png : await legeLogoAuf(png, design, logo);
} catch (error) {
wrap(error);
}
+28 -10
View File
@@ -36,6 +36,8 @@ export const qrRequestSchema = z.object({
slug: z.string().trim().optional(),
/** Domain, unter der die Kurz-URL entsteht. */
domainId: z.string().trim().optional(),
/** Name des Codes — bestimmt den Dateinamen beim Herunterladen. */
label: z.string().trim().max(120).optional(),
});
export type QrRequest = z.infer<typeof qrRequestSchema>;
@@ -137,25 +139,41 @@ export async function resolveQrRequest(
};
}
/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */
/** Macht aus beliebigem Text einen brauchbaren Dateinamen-Bestandteil. */
function alsDateiname(text: string): string {
return text
.toLowerCase()
.replaceAll("ä", "ae")
.replaceAll("ö", "oe")
.replaceAll("ü", "ue")
.replaceAll("ß", "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
/**
* Dateiname für den Download.
*
* Wenn ein Name vergeben wurde, gewinnt er — genau dafür ist er da: damit man
* die Datei später wiederfindet. Sonst wie bisher aus dem Inhalt abgeleitet.
*/
export function downloadFilename(
qrType: QrTypeModule,
data: unknown,
extension: string,
label?: string | null,
): string {
const ausName = label ? alsDateiname(label) : "";
if (ausName) return `qr-${ausName}.${extension}`;
let base = "qr-code";
try {
const summary = qrType
.summary(data)
.toLowerCase()
.replaceAll("ä", "ae")
.replaceAll("ö", "oe")
.replaceAll("ü", "ue")
.replaceAll("ß", "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
if (summary) base = `qr-${summary}`;
.toLowerCase();
const gesaeubert = alsDateiname(summary);
if (gesaeubert) base = `qr-${gesaeubert}`;
} catch {
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
}
+45
View File
@@ -0,0 +1,45 @@
import "server-only";
import { prisma } from "@/lib/db";
export type InstanceSettings = {
/** Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern? */
editorsMaySetSlug: boolean;
};
export const DEFAULT_SETTINGS: InstanceSettings = {
editorsMaySetSlug: false,
};
/**
* Bewusst ohne Zwischenspeicher — dieselbe Erfahrung wie beim
* Erscheinungsbild: API-Route und Seite laufen in getrennten Modulinstanzen,
* ein Puffer wäre nach dem Speichern veraltet. Es ist eine Abfrage auf den
* Primärschlüssel einer Tabelle mit einer einzigen Zeile.
*/
export async function getSettings(): Promise<InstanceSettings> {
try {
const zeile = await prisma.instanceSetting.findUnique({
where: { id: "instance" },
});
return zeile
? { editorsMaySetSlug: zeile.editorsMaySetSlug }
: DEFAULT_SETTINGS;
} catch (error) {
// Ein Datenbankproblem darf nicht dazu führen, dass keine Seite erscheint.
// Im Zweifel gilt die zurückhaltendere Einstellung.
console.error("Einstellungen konnten nicht geladen werden:", error);
return DEFAULT_SETTINGS;
}
}
export async function saveSettings(
update: Partial<InstanceSettings>,
): Promise<InstanceSettings> {
await prisma.instanceSetting.upsert({
where: { id: "instance" },
update,
create: { id: "instance", ...update },
});
return getSettings();
}
+91
View File
@@ -0,0 +1,91 @@
import "server-only";
import type { QrCode } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { QrRequestError } from "@/lib/qr/type-module";
import { isValidSlug, normalizeSlug } from "@/lib/slug";
/**
* Sucht den Code zu einem Kurz-Link — einschließlich der früheren Adressen.
*
* Zwei Stufen, aus einem Grund: Der häufige Fall ist ein gescannter Code, und
* der liefert die Adresse genau so, wie sie gespeichert ist. Dieser Zugriff
* geht über den exakten Index. Erst wenn er nichts findet, wird
* großschreibungsunabhängig gesucht und danach unter den früheren Adressen —
* beides seltene Fälle, etwa wenn jemand die Adresse abtippt.
*/
export async function findCodeBySlug(
domainId: string,
slug: string,
): Promise<{ code: QrCode; ueberFrueheAdresse: boolean } | null> {
if (!isValidSlug(slug)) return null;
const exakt = await prisma.qrCode.findUnique({
where: { domainId_slug: { domainId, slug } },
});
if (exakt) return { code: exakt, ueberFrueheAdresse: false };
const normalisiert = normalizeSlug(slug);
const egalGeschrieben = await prisma.qrCode.findFirst({
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
});
if (egalGeschrieben) {
return { code: egalGeschrieben, ueberFrueheAdresse: false };
}
const alias = await prisma.qrCodeAlias.findFirst({
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
include: { qrCode: true },
});
if (alias) return { code: alias.qrCode, ueberFrueheAdresse: true };
return null;
}
/**
* Ist dieser Kurz-Link auf dieser Domain noch frei?
*
* Belegt ist er auch dann, wenn ihn ein anderer Code als **frühere** Adresse
* hält — sonst könnte man einem fremden Code seine gedruckten Adressen
* wegnehmen.
*/
export async function assertSlugAvailable(
domainId: string,
slug: string,
/** Der eigene Code darf seine eigenen Adressen natürlich behalten. */
eigenerCodeId?: string,
): Promise<void> {
const normalisiert = normalizeSlug(slug);
const belegtVomCode = await prisma.qrCode.findFirst({
where: {
domainId,
slug: { equals: normalisiert, mode: "insensitive" },
...(eigenerCodeId ? { id: { not: eigenerCodeId } } : {}),
},
select: { id: true },
});
if (belegtVomCode) {
throw new QrRequestError(
`Der Kurz-Link „${normalisiert}" ist auf dieser Domain schon vergeben.`,
409,
);
}
const belegtAlsAlias = await prisma.qrCodeAlias.findFirst({
where: {
domainId,
slug: { equals: normalisiert, mode: "insensitive" },
...(eigenerCodeId ? { qrCodeId: { not: eigenerCodeId } } : {}),
},
select: { id: true },
});
if (belegtAlsAlias) {
throw new QrRequestError(
`Der Kurz-Link „${normalisiert}" gehört als frühere Adresse noch zu einem ` +
`anderen Code. Wer ihn freigibt, kann ihn danach hier verwenden.`,
409,
);
}
}
+91 -3
View File
@@ -8,8 +8,12 @@ const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
export const SLUG_LENGTH = 8;
/** Muster, gegen das eingehende Slugs geprüft werden. */
export const SLUG_PATTERN = /^[0-9A-Za-z]{4,32}$/;
/** Höchstens so viele durch `/` getrennte Abschnitte. */
export const MAX_SLUG_SEGMENTS = 3;
/** Länge eines Abschnitts. */
export const MAX_SEGMENT_LENGTH = 48;
/** Länge des ganzen Kurz-Links. */
export const MAX_SLUG_LENGTH = 100;
export function generateSlug(length: number = SLUG_LENGTH): string {
let slug = "";
@@ -19,6 +23,90 @@ export function generateSlug(length: number = SLUG_LENGTH): string {
return slug;
}
/**
* Vergleichsform eines Kurz-Links.
*
* Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: Wer
* `qr.firma.de/r/Sommeraktion` abtippt, soll dort landen, wo
* `sommeraktion` liegt. Deshalb wird überall gegen diese Form verglichen —
* beim Auflösen wie bei der Prüfung auf Eindeutigkeit.
*/
export function normalizeSlug(value: string): string {
return value.trim().toLowerCase();
}
/** Ein einzelner Abschnitt: Kleinbuchstaben, Ziffern, Bindestriche. */
const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* Prüft einen Kurz-Link, wie er in der Adresse stehen darf.
*
* Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und `/` als Trenner —
* damit sind Adressen wie `kunde-a/sommeraktion` möglich. Bewusst **nicht**
* erlaubt: Umlaute und Akzente (sie werden in Adressen unleserlich kodiert),
* Leerzeichen, Unterstriche sowie Bindestriche am Rand eines Abschnitts.
*
* Selbst erzeugte Kurz-Links aus dem gemischten Alphabet oben bestehen diese
* Prüfung ebenfalls, weil verglichen wird, nachdem kleingeschrieben wurde.
*
* Führende, doppelte und abschließende Schrägstriche gelten als ungültig — und
* zwar auch beim Auflösen. Sonst führten `/r/sommer` und `/r//sommer` auf
* denselben Code, und es gäbe zwei Adressen für dieselbe Sache.
*/
export function isValidSlug(value: string): boolean {
return SLUG_PATTERN.test(value);
const slug = normalizeSlug(value);
if (slug.length === 0 || slug.length > MAX_SLUG_LENGTH) return false;
if (slug.startsWith("/") || slug.endsWith("/") || slug.includes("//")) {
return false;
}
const abschnitte = slug.split("/");
if (abschnitte.length > MAX_SLUG_SEGMENTS) return false;
return abschnitte.every(
(abschnitt) =>
abschnitt.length > 0 &&
abschnitt.length <= MAX_SEGMENT_LENGTH &&
SEGMENT.test(abschnitt),
);
}
/**
* Prüft einen selbst gewählten Kurz-Link und liefert eine deutsche Begründung,
* wenn er nicht taugt. Ausführlicher als `isValidSlug`, weil hier jemand
* zusieht und wissen will, was falsch war.
*/
export function describeSlugProblem(eingabe: string): string | null {
const roh = eingabe.trim();
if (roh.length === 0) return "Bitte gib einen Kurz-Link ein.";
if (/^\/|\/$/.test(roh)) {
return "Der Kurz-Link darf nicht mit „/“ beginnen oder enden.";
}
if (roh.includes("//")) {
return "Zwei Schrägstriche hintereinander sind nicht möglich.";
}
if (roh.length > MAX_SLUG_LENGTH) {
return `Der Kurz-Link ist zu lang (höchstens ${MAX_SLUG_LENGTH} Zeichen).`;
}
const slug = normalizeSlug(roh);
const abschnitte = slug.split("/");
if (abschnitte.length > MAX_SLUG_SEGMENTS) {
return `Höchstens ${MAX_SLUG_SEGMENTS} durch „/“ getrennte Abschnitte.`;
}
for (const abschnitt of abschnitte) {
if (abschnitt.length > MAX_SEGMENT_LENGTH) {
return `Jeder Abschnitt darf höchstens ${MAX_SEGMENT_LENGTH} Zeichen lang sein.`;
}
if (/^-|-$/.test(abschnitt)) {
return "Ein Bindestrich darf nicht am Anfang oder Ende eines Abschnitts stehen.";
}
if (!SEGMENT.test(abschnitt)) {
return "Erlaubt sind Buchstaben ohne Umlaute, Ziffern, Bindestriche und „/“ als Trenner.";
}
}
return null;
}
+10
View File
@@ -35,6 +35,16 @@ export const UPLOAD_KINDS = {
// das dann bei jedem Seitenaufruf mitgeht.
maxBytes: 2 * 1024 * 1024,
},
qrlogo: {
label: "Logo im Code",
// Ohne GIF: Ein bewegtes Bild wird beim Einsetzen ohnehin zum Standbild,
// und die Auswahl soll nicht mehr versprechen, als hinten herauskommt.
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/avif"],
accept: "image/png,image/jpeg,image/webp,image/avif",
// Das Logo landet auf wenigen hundert Bildpunkten in der Mitte des Codes.
// Mehr als zwei Megabyte bringen dort nichts.
maxBytes: 2 * 1024 * 1024,
},
favicon: {
label: "Favicon",
// Zusätzlich ICO — viele haben genau das noch herumliegen.
+30
View File
@@ -189,6 +189,36 @@ export async function attachUploads(
});
}
/**
* Hängt **eine** Datei an einen Code, ohne die übrigen anzurühren.
*
* Gebraucht für das Logo aus der Gestaltung: Ändert sich nur das Aussehen
* eines Codes, sollen die Dateien seines Inhalts unberührt bleiben.
*/
export async function attachUpload(
qrCodeId: string,
uploadId: string,
): Promise<void> {
await prisma.upload.updateMany({
// Dieselbe Bedingung wie oben: keine fremde Datei an sich ziehen.
where: { id: uploadId, OR: [{ qrCodeId: null }, { qrCodeId }] },
data: { qrCodeId },
});
}
/**
* Löst eine Datei wieder vom Code — sie wird danach als herrenlos weggeräumt.
*/
export async function detachUpload(
qrCodeId: string,
uploadId: string,
): Promise<void> {
await prisma.upload.updateMany({
where: { id: uploadId, qrCodeId },
data: { qrCodeId: null },
});
}
/** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */
const ORPHAN_HOURS = 24;