Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f67a35218 | ||
|
|
eeaf3820b7 | ||
|
|
46e7eb0b59 | ||
|
|
c38b27ca85 |
@@ -19,7 +19,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
||||
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
|
||||
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
||||
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
|
||||
Ziel ändern, löschen.
|
||||
Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen
|
||||
ablegen, um sie wiederzufinden.
|
||||
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
||||
erhoben, ohne Name und IP-Adresse.
|
||||
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
||||
@@ -31,6 +32,8 @@ 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.
|
||||
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
|
||||
von selbst; alles Nötige steht in einer `.env`.
|
||||
|
||||
@@ -124,9 +127,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 +253,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 +268,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
@@ -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. |
|
||||
|
||||
|
||||
+56
-6
@@ -102,12 +102,61 @@ 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.
|
||||
|
||||
### 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 +464,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 |
|
||||
@@ -466,7 +516,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 |
|
||||
|
||||
|
||||
@@ -74,6 +74,23 @@ Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System.
|
||||
Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne
|
||||
Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl.
|
||||
|
||||
### Sprechende Kurz-Links (17.08.2026)
|
||||
|
||||
Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die
|
||||
vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde
|
||||
jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren
|
||||
vorbehalten, per Einstellung für Bearbeiter freigebbar.
|
||||
|
||||
Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1).
|
||||
|
||||
### Statische Codes benennen und ablegen (20.08.2026)
|
||||
|
||||
Das Namensfeld gilt in beiden Betriebsarten. Es bestimmt den Dateinamen beim
|
||||
Herunterladen und erlaubt, einen statischen Code in „Meine Codes" abzulegen —
|
||||
als Schnappschuss, dessen Inhalt sich nicht mehr ändert.
|
||||
|
||||
Schließt [Issue #2](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/2).
|
||||
|
||||
### Dokumentation (15.08.2026)
|
||||
|
||||
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
|
||||
|
||||
@@ -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;
|
||||
+51
-3
@@ -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
|
||||
@@ -194,11 +203,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 +249,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
|
||||
|
||||
@@ -94,12 +94,16 @@ export default async function SignInPage({ searchParams }: PageProps) {
|
||||
Gruppen, Benutzer und die QR-Typen verwalten.
|
||||
</Notice>
|
||||
) : oidc?.nurOidc ? (
|
||||
/* 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">
|
||||
Die Anmeldung mit Passwort ist abgeschaltet (<code>OIDC_ONLY=true</code>).
|
||||
{setup
|
||||
? " Wer sich zuerst anmeldet, wird Administrator dieser Installation."
|
||||
: ""}
|
||||
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 →
|
||||
|
||||
@@ -4,6 +4,9 @@ import { notFound } from "next/navigation";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { QrGenerator } from "@/components/qr-generator";
|
||||
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||
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 +50,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 +78,12 @@ 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 });
|
||||
|
||||
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { shortUrl } from "@/lib/env";
|
||||
import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||
import { errorResponse } from "@/lib/http";
|
||||
import { designSchema } from "@/lib/qr/design";
|
||||
import { getQrType } from "@/lib/qr/registry";
|
||||
@@ -49,7 +50,12 @@ export async function GET(
|
||||
margin: code.margin,
|
||||
});
|
||||
|
||||
const content = shortUrl(code.slug, code.domain.hostname);
|
||||
// Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
|
||||
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
|
||||
const content =
|
||||
code.mode === "static" || !code.slug
|
||||
? requireImplementedQrType(code.type).encode(code.data)
|
||||
: shortUrl(code.slug, code.domain.hostname);
|
||||
const body =
|
||||
format === "png"
|
||||
? new Uint8Array(await renderPng(content, design))
|
||||
|
||||
@@ -10,6 +10,9 @@ 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 { describeSlugProblem, normalizeSlug } from "@/lib/slug";
|
||||
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { attachUploads } from "@/lib/uploads";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
@@ -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,15 +133,94 @@ export async function PATCH(
|
||||
Object.assign(update, parsed.data.design);
|
||||
}
|
||||
|
||||
if (Object.keys(update).length === 0) {
|
||||
/* ------------------------------------------------------- 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({
|
||||
// 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.
|
||||
|
||||
@@ -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,6 +28,17 @@ 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. */
|
||||
@@ -57,6 +70,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 +107,44 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
return jsonError(validated.message);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
// 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: {
|
||||
slug: generateSlug(),
|
||||
mode: "static",
|
||||
slug: null,
|
||||
type: qrType.id,
|
||||
label: label && label.length > 0 ? label : null,
|
||||
data: toJsonInput(validated.data),
|
||||
@@ -114,6 +157,32 @@ 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" } },
|
||||
},
|
||||
});
|
||||
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||
}
|
||||
|
||||
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const created = await prisma.qrCode.create({
|
||||
data: {
|
||||
slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
|
||||
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" } },
|
||||
},
|
||||
});
|
||||
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
||||
@@ -122,6 +191,14 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": CONTENT_TYPES[format],
|
||||
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format)}"`,
|
||||
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format, parsed.data.label)}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
+179
-16
@@ -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 ? (
|
||||
<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,6 +509,7 @@ export function CodesList() {
|
||||
</td>
|
||||
|
||||
<td className="hidden px-3 py-2.5 md:table-cell">
|
||||
{code.shortUrl ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
@@ -483,6 +532,11 @@ export function CodesList() {
|
||||
/>
|
||||
</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,6 +676,7 @@ export function CodesList() {
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
{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">
|
||||
@@ -634,6 +701,16 @@ export function CodesList() {
|
||||
</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"
|
||||
|
||||
@@ -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,14 @@ 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,
|
||||
}: {
|
||||
typeId: QrTypeId;
|
||||
maySetSlug?: boolean;
|
||||
}) {
|
||||
const qrType = qrTypes[typeId];
|
||||
const Form = qrForms[typeId];
|
||||
|
||||
@@ -51,6 +59,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 +84,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 +176,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 +188,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 +196,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 +220,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 +318,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 +342,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 +363,15 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isDynamic ? (
|
||||
<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 +382,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 +493,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>
|
||||
@@ -417,7 +502,6 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
|
||||
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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":
|
||||
|
||||
+13
-2
@@ -31,20 +31,29 @@ 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
|
||||
target =
|
||||
istStatisch || !code.slug
|
||||
? ""
|
||||
: qrType.hasLandingPage
|
||||
? landingUrl(code.slug, code.domain?.hostname)
|
||||
: qrType.redirectTarget(data);
|
||||
summary = qrType.summary(data);
|
||||
@@ -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: {
|
||||
|
||||
+17
-5
@@ -1,19 +1,31 @@
|
||||
import type { EcLevel } from "@/lib/qr/design";
|
||||
import type { QrTypeId } from "@/lib/qr/type-module";
|
||||
|
||||
/** Statisch oder dynamisch — bestimmt, was ein Code überhaupt kann. */
|
||||
export type QrCodeMode = "static" | "dynamic";
|
||||
|
||||
/**
|
||||
* Darstellung eines dynamischen Codes an der API-Grenze. Bewusst frei von
|
||||
* Darstellung eines gespeicherten Codes an der API-Grenze. Bewusst frei von
|
||||
* Server-Abhängigkeiten, damit Client-Komponenten den Typ verwenden können.
|
||||
*/
|
||||
export type QrCodeDto = {
|
||||
id: string;
|
||||
slug: string;
|
||||
/**
|
||||
* Ein statischer Code trägt seinen Inhalt selbst. Er ist nur abgelegt, damit
|
||||
* man ihn wiederfindet und erneut herunterladen kann — Kurz-Link, Ziel und
|
||||
* Scan-Zähler bleiben deshalb leer.
|
||||
*/
|
||||
mode: QrCodeMode;
|
||||
/** Kurz-Link. Leer bei statischen Codes. */
|
||||
slug: string | null;
|
||||
/** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */
|
||||
aliases: string[];
|
||||
type: QrTypeId;
|
||||
label: string | null;
|
||||
data: Record<string, unknown>;
|
||||
/** Die Kurz-URL, die im QR-Code steckt. */
|
||||
shortUrl: string;
|
||||
/** Aktuelles Weiterleitungsziel. */
|
||||
/** Die Kurz-URL, die im QR-Code steckt. Leer bei statischen Codes. */
|
||||
shortUrl: string | null;
|
||||
/** Aktuelles Weiterleitungsziel. Leer bei statischen Codes. */
|
||||
target: string;
|
||||
/** Lesbare Kurzfassung des Ziels. */
|
||||
summary: string;
|
||||
|
||||
+29
-11
@@ -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,16 +139,9 @@ export async function resolveQrRequest(
|
||||
};
|
||||
}
|
||||
|
||||
/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */
|
||||
export function downloadFilename(
|
||||
qrType: QrTypeModule,
|
||||
data: unknown,
|
||||
extension: string,
|
||||
): string {
|
||||
let base = "qr-code";
|
||||
try {
|
||||
const summary = qrType
|
||||
.summary(data)
|
||||
/** Macht aus beliebigem Text einen brauchbaren Dateinamen-Bestandteil. */
|
||||
function alsDateiname(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replaceAll("ä", "ae")
|
||||
.replaceAll("ö", "oe")
|
||||
@@ -155,7 +150,30 @@ export function downloadFilename(
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
if (summary) base = `qr-${summary}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
const gesaeubert = alsDateiname(summary);
|
||||
if (gesaeubert) base = `qr-${gesaeubert}`;
|
||||
} catch {
|
||||
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user