Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e2d222e86 | ||
|
|
fcc63ff69a | ||
|
|
1f4753b753 | ||
|
|
cdda7df17a | ||
|
|
c6891f66ff | ||
|
|
ff28352907 | ||
|
|
830d6d7379 | ||
|
|
a4bf3c77b6 | ||
|
|
43a0504f38 | ||
|
|
14b9ffc22f | ||
|
|
87ffdcae04 | ||
|
|
a7af9c9290 | ||
|
|
101f664cef | ||
|
|
3cb5421a20 | ||
|
|
c06f87e6d9 | ||
|
|
69c34c4f0c | ||
|
|
60f46bcdb5 | ||
|
|
142536140c | ||
|
|
1cf1df54df | ||
|
|
b96e9ddbe5 | ||
|
|
586e1f042f | ||
|
|
7f67a35218 | ||
|
|
eeaf3820b7 |
@@ -0,0 +1,93 @@
|
|||||||
|
# Änderungen
|
||||||
|
|
||||||
|
Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der
|
||||||
|
Anwendung unter **Version und Änderungen**, erreichbar über die
|
||||||
|
Versionsnummer in der Fußzeile.
|
||||||
|
|
||||||
|
> Diese Datei wird erzeugt: `npm run changelog`.
|
||||||
|
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
|
||||||
|
|
||||||
|
## 1.7.1 — 21.08.2026
|
||||||
|
|
||||||
|
Bilder zu den neuen Funktionen in der Bedienungsanleitung.
|
||||||
|
|
||||||
|
- Die Bedienungsanleitung zeigt die Funktionen aus 1.7.0 jetzt auch im Bild: Logo im Code, Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und Domain-Bericht.
|
||||||
|
- Alle übrigen Bilder mit erneuert — sie stammten noch aus 1.2.0.
|
||||||
|
- Das Skript, das die Bilder erzeugt, lief seit einer Umbenennung ins Leere und tut es wieder.
|
||||||
|
|
||||||
|
## 1.7.0 — 21.08.2026
|
||||||
|
|
||||||
|
Logo im Code, Freigabe je Gruppe und ein Domain-Check.
|
||||||
|
|
||||||
|
- Ein Logo in der Mitte des Codes — das der Installation oder ein eigenes je Code, 10 bis 25 % der Kantenlänge. Die Fehlerkorrektur springt dabei auf H.
|
||||||
|
- QR-Typen lassen sich je Gruppe freigeben. Die Grundeinstellung bleibt die Grundlage; eine globale Abschaltung gewinnt immer.
|
||||||
|
- Der Knopf „Prüfen“ bei jeder Domain meldet DNS, Zertifikat, Erreichbarkeit — und ob dahinter wirklich diese Installation steht.
|
||||||
|
- Eine Kurzhilfe auf der Domain-Seite führt in sechs Schritten auf, was für eine neue Domain einzurichten ist.
|
||||||
|
- „Meine Codes“ lässt sich nach Art filtern: alle, nur dynamische, nur statische.
|
||||||
|
- Beim Ablegen eines statischen Codes wird gewarnt, wenn derselbe Inhalt schon in der Gruppe liegt.
|
||||||
|
|
||||||
|
## 1.6.0 — 21.08.2026
|
||||||
|
|
||||||
|
Sichtbare Version mit Änderungsverlauf.
|
||||||
|
|
||||||
|
- Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.
|
||||||
|
- Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.
|
||||||
|
- Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.
|
||||||
|
|
||||||
|
## 1.5.0 — 20.08.2026
|
||||||
|
|
||||||
|
Statische Codes lassen sich benennen und ablegen.
|
||||||
|
|
||||||
|
- Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.
|
||||||
|
- Der Name bestimmt den Dateinamen beim Herunterladen.
|
||||||
|
- Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.
|
||||||
|
|
||||||
|
## 1.4.0 — 17.08.2026
|
||||||
|
|
||||||
|
Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.
|
||||||
|
|
||||||
|
- Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.
|
||||||
|
- Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.
|
||||||
|
- Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.
|
||||||
|
- Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.
|
||||||
|
- Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.
|
||||||
|
|
||||||
|
## 1.3.0 — 16.08.2026
|
||||||
|
|
||||||
|
Eigenes Erscheinungsbild und ein Dark Mode.
|
||||||
|
|
||||||
|
- Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.
|
||||||
|
- Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.
|
||||||
|
- Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.
|
||||||
|
- Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.
|
||||||
|
|
||||||
|
## 1.2.0 — 15.08.2026
|
||||||
|
|
||||||
|
Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.
|
||||||
|
|
||||||
|
- Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.
|
||||||
|
- Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.
|
||||||
|
- Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.
|
||||||
|
- Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.
|
||||||
|
|
||||||
|
## 1.1.0 — 14.08.2026
|
||||||
|
|
||||||
|
Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.
|
||||||
|
|
||||||
|
- Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.
|
||||||
|
- Landingpages als eigene Gattung für Typen ohne fremdes Ziel.
|
||||||
|
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.
|
||||||
|
- Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.
|
||||||
|
- Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.
|
||||||
|
- „Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.
|
||||||
|
- Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.
|
||||||
|
|
||||||
|
## 1.0.0 — 13.08.2026
|
||||||
|
|
||||||
|
Erste lauffähige Fassung.
|
||||||
|
|
||||||
|
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.
|
||||||
|
- QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.
|
||||||
|
- Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.
|
||||||
|
- Download als PNG und SVG, serverseitig gerendert.
|
||||||
|
- Update-Skript für Linux, macOS und Windows.
|
||||||
@@ -18,21 +18,28 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
|
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
|
||||||
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
|
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
|
||||||
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
||||||
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
|
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
|
||||||
Ziel ändern, löschen.
|
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
|
||||||
|
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Filter nach Gruppe
|
||||||
|
und nach Art, Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch
|
||||||
|
mit Namen ablegen; liegt derselbe Inhalt schon dort, wird gewarnt.
|
||||||
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
||||||
erhoben, ohne Name und IP-Adresse.
|
erhoben, ohne Name und IP-Adresse.
|
||||||
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
||||||
Administrator, Bearbeiter und Betrachter.
|
Administrator, Bearbeiter und Betrachter.
|
||||||
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
|
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt — mit Kurzhilfe
|
||||||
|
zur Einrichtung und einer Prüfung, ob eine Domain wirklich hierher führt.
|
||||||
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
||||||
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst.
|
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst —
|
||||||
|
für die ganze Installation oder je Gruppe.
|
||||||
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
|
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
|
||||||
Admin-Bereich einstellbar.
|
Admin-Bereich einstellbar.
|
||||||
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
|
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
|
||||||
eigene Wahl.
|
eigene Wahl.
|
||||||
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
|
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
|
||||||
nachträglich änderbar — die vorherige Adresse bleibt gültig.
|
nachträglich änderbar — die vorherige Adresse bleibt gültig.
|
||||||
|
- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt
|
||||||
|
zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
|
||||||
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
|
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
|
||||||
von selbst; alles Nötige steht in einer `.env`.
|
von selbst; alles Nötige steht in einer `.env`.
|
||||||
|
|
||||||
@@ -47,7 +54,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
|
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
|
||||||
Rollen und der Weg zurück
|
Rollen und der Weg zurück
|
||||||
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
|
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
|
||||||
- **[Roadmap](docs/roadmap.md)** — was gebaut ist und was kommen könnte
|
- **[Änderungen](CHANGELOG.md)** — was in welcher Version dazugekommen ist
|
||||||
|
- **[Roadmap](docs/roadmap.md)** — was als Nächstes kommen könnte
|
||||||
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
|
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
|
||||||
Härtungen und was bewusst offen bleibt
|
Härtungen und was bewusst offen bleibt
|
||||||
|
|
||||||
@@ -349,6 +357,30 @@ prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit
|
|||||||
404 — über einen gefälschten Header lässt sich also keine fremde Domain
|
404 — über einen gefälschten Header lässt sich also keine fremde Domain
|
||||||
ansprechen.
|
ansprechen.
|
||||||
|
|
||||||
|
### Nachprüfen, ob eine Domain wirklich hierher führt
|
||||||
|
|
||||||
|
Auf der Domain-Seite steht bei jedem Eintrag ein Knopf **Prüfen**. Er geht den
|
||||||
|
Weg der Scanner nach und meldet vier Dinge:
|
||||||
|
|
||||||
|
| Schritt | Was geprüft wird |
|
||||||
|
| --- | --- |
|
||||||
|
| DNS | Löst der Name auf? Auf welche Adressen? |
|
||||||
|
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
|
||||||
|
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
|
||||||
|
| Ziel | Steht dahinter **diese** Installation? |
|
||||||
|
|
||||||
|
Der letzte Schritt ist der eigentliche Wert. Die Anwendung ruft dazu unter der
|
||||||
|
Domain eine eigene Route auf und lässt sich einen HMAC über eine frische
|
||||||
|
Zufallszahl zurückgeben; das Geheimnis dafür steht in der Datenbank und
|
||||||
|
verlässt den Server nie. Nur so unterscheidet sich „die Domain zeigt
|
||||||
|
irgendwohin“ von „die Domain zeigt hierher“.
|
||||||
|
|
||||||
|
> **Grenze der Aussage:** Der Aufruf startet dort, wo der Container läuft.
|
||||||
|
> Wer internes DNS oder Hairpin-NAT hat, bekommt womöglich einen Fehler,
|
||||||
|
> obwohl von außen alles stimmt. Umgekehrt gilt: Meldet die Prüfung von innen
|
||||||
|
> Erfolg, heißt das noch nicht, dass die Domain auch von außen erreichbar ist.
|
||||||
|
> Dafür bleibt der Blick von einem fremden Netz aus.
|
||||||
|
|
||||||
Zwei Dinge, die daraus folgen:
|
Zwei Dinge, die daraus folgen:
|
||||||
|
|
||||||
- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende
|
- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende
|
||||||
@@ -579,12 +611,32 @@ Zwei Eigenschaften sind dabei zu unterscheiden:
|
|||||||
|
|
||||||
| | Bedeutung | Woher |
|
| | Bedeutung | Woher |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **umgesetzt** | Der Typ ist ausprogrammiert. In Version 1 gilt das nur für „Website“. | Registry im Code |
|
| **umgesetzt** | Der Typ ist ausprogrammiert. Seit 1.1.0 gilt das für alle fünfzehn. | Registry im Code |
|
||||||
| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
|
| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
|
||||||
|
|
||||||
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
|
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
|
||||||
umgesetzt, erscheint als Kachel „Bald verfügbar“.
|
umgesetzt, erscheint als Kachel „Bald verfügbar“.
|
||||||
|
|
||||||
|
#### Freigabe je Gruppe
|
||||||
|
|
||||||
|
Die Auswahl **Freigabe für** über der Liste hat zwei Stufen:
|
||||||
|
|
||||||
|
- **Alle Gruppen (Grundeinstellung)** — gilt für die ganze Installation.
|
||||||
|
- **Nur: \<Gruppe\>** — weicht für diese eine Gruppe davon ab. Die
|
||||||
|
Marketingabteilung bekommt Social Media, die Werkstatt nur Website und PDF.
|
||||||
|
|
||||||
|
Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung. **Die globale
|
||||||
|
Abschaltung gewinnt immer:** Ein Typ, der in der Grundeinstellung aus ist,
|
||||||
|
lässt sich in keiner Gruppe einschalten — der Schalter steht dort sichtbar
|
||||||
|
still und sagt warum. Andersherum wäre „hier abgeschaltet“ keine verlässliche
|
||||||
|
Aussage mehr, und wer den Schalter umlegt, müsste alle Gruppen durchsehen, um
|
||||||
|
zu wissen, was er getan hat.
|
||||||
|
|
||||||
|
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
|
||||||
|
in Werkstatt und Marketing ist, sieht beides. Welche Freigabe gilt, entscheidet
|
||||||
|
sich erst beim Anlegen — dort zählt die gewählte Gruppe, und die Route prüft
|
||||||
|
das noch einmal.
|
||||||
|
|
||||||
> **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab,
|
> **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab,
|
||||||
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
|
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
|
||||||
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
|
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
|
||||||
@@ -614,13 +666,21 @@ Dazu kommen
|
|||||||
|
|
||||||
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
|
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
|
||||||
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
|
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
|
||||||
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen.
|
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen,
|
||||||
|
- ein **Filter nach Art**, sobald dynamische und statische Codes
|
||||||
|
nebeneinander liegen.
|
||||||
|
|
||||||
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
|
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
|
||||||
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
|
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
|
||||||
Kartenansicht.
|
Kartenansicht.
|
||||||
|
|
||||||
Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden.
|
Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
|
||||||
|
Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Beim
|
||||||
|
Ablegen läuft eine **Dublettenprüfung**: Liegt in derselben Gruppe schon ein
|
||||||
|
statischer Code mit identischem Inhalt, hält das Ablegen an und nennt ihn —
|
||||||
|
eine Rückfrage mit dem Knopf „Trotzdem ablegen“, keine Sperre. Verglichen wird nur der Inhalt, nicht die Gestaltung. Steht der
|
||||||
|
Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
|
||||||
|
Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
|
||||||
|
|
||||||
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
|
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
|
||||||
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
|
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
|
||||||
@@ -960,8 +1020,10 @@ deutschen Meldung.
|
|||||||
| `DELETE` | `/api/codes/{id}` | Code löschen |
|
| `DELETE` | `/api/codes/{id}` | Code löschen |
|
||||||
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
|
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
|
||||||
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
|
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
|
||||||
| `GET` | `/api/admin/types` | Zustand aller QR-Typen |
|
| `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) |
|
||||||
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten |
|
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten (`groupId` optional) |
|
||||||
|
| `POST` | `/api/admin/domains/{id}/check` | Domain prüfen: DNS, Zertifikat, Erreichbarkeit, Ziel |
|
||||||
|
| `GET` | `/api/domain-check` | Beweis für die Domain-Prüfung (`?nonce=…`, ohne Anmeldung) |
|
||||||
|
|
||||||
Beispiel:
|
Beispiel:
|
||||||
|
|
||||||
|
|||||||
@@ -112,12 +112,42 @@ gescannt zu werden.
|
|||||||
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
|
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
|
||||||
trennen kann.
|
trennen kann.
|
||||||
|
|
||||||
|
### Ein Logo in die Mitte setzen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Ganz unten in der Gestaltung steht **Logo in der Mitte**. Ein Haken genügt,
|
||||||
|
dann fragt die Anwendung, woher das Bild kommen soll:
|
||||||
|
|
||||||
|
- **Logo dieser Installation** — das, was unter Verwaltung → Erscheinungsbild
|
||||||
|
hinterlegt ist. Kein Upload je Code nötig. Die Wahl erscheint nur, wenn dort
|
||||||
|
überhaupt eines liegt.
|
||||||
|
- **Eigenes Logo** — je Code hochgeladen, für Kundenprojekte mit fremdem
|
||||||
|
Zeichen. PNG, JPEG, WebP oder AVIF, bis 2 MB.
|
||||||
|
|
||||||
|
Die **Größe** liegt zwischen 10 und 25 % der Kantenlänge; voreingestellt sind
|
||||||
|
20 %, das verdeckt rund 4 % der Fläche. Hinter dem Bild liegt eine Fläche in
|
||||||
|
der Hintergrundfarbe, damit es sich absetzt.
|
||||||
|
|
||||||
|
**Die Fehlerkorrektur springt dabei auf H** und lässt sich nicht mehr ändern —
|
||||||
|
was das Bild verdeckt, muss sie ausgleichen. Das steht auch so unter der
|
||||||
|
Auswahl; heimlich geschieht es nicht.
|
||||||
|
|
||||||
|
Ab 22 % erscheint eine Warnung. Die Fehlerkorrektur trägt das zwar, aber
|
||||||
|
manche Scanner-Apps suchen in der Mitte nach Ausrichtungsmustern.
|
||||||
|
|
||||||
|
> **Einmal selbst scannen, bevor etwas in den Druck geht.** Ein Code mit Logo
|
||||||
|
> ist die einzige Stelle, an der die Anwendung etwas ausliefern kann, das auf
|
||||||
|
> dem einen Gerät geht und auf dem anderen nicht. Zwei verschiedene Apps
|
||||||
|
> kosten eine halbe Minute und ersparen eine Neuauflage.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Meine Codes
|
## Meine Codes
|
||||||
|
|
||||||
Hier stehen alle **dynamischen** Codes. Statische erscheinen nicht — sie
|
Hier stehen die **dynamischen** Codes und die statischen, die du abgelegt
|
||||||
werden nicht gespeichert.
|
hast. Beide Arten stehen nebeneinander in derselben Liste — ein statischer
|
||||||
|
trägt statt einer Kurz-URL das Abzeichen „statisch“ und zählt keine Scans.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -128,6 +158,11 @@ Projekte nebeneinander.
|
|||||||
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
|
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
|
||||||
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
|
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
|
||||||
liegen.
|
liegen.
|
||||||
|
- Ein **Filter nach Art** erscheint, sobald dynamische und statische Codes
|
||||||
|
nebeneinander liegen: *Alle Arten*, *Nur dynamische*, *Nur statische*.
|
||||||
|

|
||||||
|
Steht er auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung
|
||||||
|
nach Scans mit ihr — bei einer Liste aus lauter Strichen sagt sie nichts.
|
||||||
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
|
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
|
||||||
|
|
||||||
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
|
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
|
||||||
@@ -135,6 +170,23 @@ Vorschau. Die Wahl merkt sich der Browser.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Statische Codes ablegen
|
||||||
|
|
||||||
|
Ein statischer Code ist mit dem Download eigentlich fertig — sein Inhalt
|
||||||
|
steckt in ihm. Der Knopf **In „Meine Codes“ ablegen** legt ihn trotzdem in die
|
||||||
|
Liste, damit du ihn wiederfindest und erneut herunterladen kannst.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Liegt dort schon ein statischer Code mit **identischem Inhalt**, hält das
|
||||||
|
Ablegen an und zeigt, welcher das ist und wann er entstand. Der Knopf heißt
|
||||||
|
dann **Trotzdem ablegen** — es ist eine Warnung, keine Sperre. Verglichen wird
|
||||||
|
nur der Inhalt, nicht die Gestaltung: Derselbe Code in Schwarz-Weiß und in
|
||||||
|
Hausfarben gilt hier als Dublette.
|
||||||
|
|
||||||
|
Bei **dynamischen** Codes fragt niemand nach. Zwei Kurz-Links auf dasselbe
|
||||||
|
Ziel sind der Normalfall, sobald zwei Kampagnen getrennt gezählt werden.
|
||||||
|
|
||||||
### Ziel ändern
|
### Ziel ändern
|
||||||
|
|
||||||

|

|
||||||
@@ -247,6 +299,27 @@ sich Typen abschalten; sie verschwinden dann aus der Auswahl.
|
|||||||
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
|
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
|
||||||
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
|
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
|
||||||
|
|
||||||
|
**Freigabe je Gruppe.** Über der Liste steht die Auswahl **Freigabe für**:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Auswahl | Wirkung |
|
||||||
|
| --- | --- |
|
||||||
|
| Alle Gruppen (Grundeinstellung) | gilt für die ganze Installation |
|
||||||
|
| Nur: *eine Gruppe* | weicht für diese eine Gruppe ab |
|
||||||
|
|
||||||
|
So bekommt die Marketingabteilung Social Media und die Werkstatt nur Website
|
||||||
|
und PDF. Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung.
|
||||||
|
|
||||||
|
**Die Grundeinstellung gewinnt immer.** Ein Typ, der dort abgeschaltet ist,
|
||||||
|
lässt sich in keiner Gruppe einschalten — der Schalter steht dann ausgegraut
|
||||||
|
und nennt den Grund. Andersherum wäre „hier abgeschaltet“ keine verlässliche
|
||||||
|
Aussage mehr.
|
||||||
|
|
||||||
|
Wer in mehreren Gruppen ist, sieht auf der Startseite die **Vereinigung**.
|
||||||
|
Welche Freigabe zählt, entscheidet sich beim Anlegen anhand der gewählten
|
||||||
|
Gruppe.
|
||||||
|
|
||||||
### Gruppen
|
### Gruppen
|
||||||
|
|
||||||

|

|
||||||
@@ -275,7 +348,34 @@ mehreren Gruppen dienen; die Codes bleiben trotzdem strikt nach Gruppe
|
|||||||
getrennt.
|
getrennt.
|
||||||
|
|
||||||
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||||
Anwendung leiten — siehe README, Abschnitt „Mehrere Domains“.
|
Anwendung leiten. Was dafür einzurichten ist, steht nicht nur im README: Der
|
||||||
|
Knopf **Kurzhilfe** über der Liste führt es in sechs Schritten auf — DNS,
|
||||||
|
Weiterleitung im Proxy samt der drei nötigen Kopfzeilen, Zertifikat,
|
||||||
|
Registrieren, Gruppenzuteilung, Nachprüfen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Prüfen, ob eine Domain wirklich hierher führt.** Bei jedem Eintrag steht ein
|
||||||
|
Knopf **Prüfen**. Er geht den Weg der Scanner nach und meldet vier Dinge:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Schritt | Was er sagt |
|
||||||
|
| --- | --- |
|
||||||
|
| DNS | ob der Name auflöst, und auf welche Adressen |
|
||||||
|
| Zertifikat | gültig bis wann, von wem ausgestellt |
|
||||||
|
| Erreichbar | ob dort ein Webserver antwortet, und wie schnell |
|
||||||
|
| Ziel | ob dahinter **diese** Installation steht |
|
||||||
|
|
||||||
|
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
|
||||||
|
sagt wenig; die Prüfung lässt sich beweisen, dass es diese Anwendung ist.
|
||||||
|
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
|
||||||
|
Meldung würde die eine wichtige verdecken.
|
||||||
|
|
||||||
|
> **Der Aufruf startet dort, wo der Container läuft.** Bei internem DNS oder
|
||||||
|
> Hairpin-NAT kann er scheitern, obwohl von außen alles stimmt. Und
|
||||||
|
> umgekehrt: Ein Erfolg von innen beweist noch nicht, dass die Domain von
|
||||||
|
> außen erreichbar ist. Dafür bleibt der Blick von einem fremden Netz aus.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -102,7 +102,59 @@ Browser landet.
|
|||||||
| Ziel später änderbar | nein | ja, jederzeit |
|
| Ziel später änderbar | nein | ja, jederzeit |
|
||||||
| Scans gezählt | nein | ja, mit Datum des letzten |
|
| Scans gezählt | nein | ja, mit Datum des letzten |
|
||||||
| Braucht diesen Server | nie | dauerhaft |
|
| Braucht diesen Server | nie | dauerhaft |
|
||||||
| In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler |
|
| In „Meine Codes“ | auf Wunsch, als Schnappschuss | immer |
|
||||||
|
|
||||||
|
### Ein statischer Code mit Namen
|
||||||
|
|
||||||
|
Beide Betriebsarten haben ein Feld **Name (optional)**. Es tut zweierlei:
|
||||||
|
|
||||||
|
- Es bestimmt den **Dateinamen beim Herunterladen** — aus „Plakat Eingang“
|
||||||
|
wird `qr-plakat-eingang.png` statt `qr-example-com-plakat.png`.
|
||||||
|
- Auf Wunsch lässt sich der Code damit **in „Meine Codes“ ablegen**, um ihn
|
||||||
|
wiederzufinden und erneut herunterzuladen.
|
||||||
|
|
||||||
|
Ein abgelegter statischer Code ist ein **Schnappschuss**, kein steuerbares
|
||||||
|
Ziel. Änderbar ist nur sein Name; Inhalt und Gestaltung bleiben, wie sie beim
|
||||||
|
Ablegen waren — denn genau das steckt im gedruckten Bild. Er hat keinen
|
||||||
|
Kurz-Link, ist unter `/r/…` nicht erreichbar und zählt keine Scans; in der
|
||||||
|
Liste steht dort „statisch“ und ein Strich.
|
||||||
|
|
||||||
|
Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten —
|
||||||
|
der vorherige Eintrag bleibt als eigener Schnappschuss bestehen.
|
||||||
|
|
||||||
|
**Dublettenprüfung.** Liegt in derselben Gruppe schon ein statischer Code mit
|
||||||
|
identischem Inhalt, hält das Ablegen an und zeigt, welcher Eintrag das ist und
|
||||||
|
wann er entstand. Der Knopf heißt dann **Trotzdem ablegen** — eine Warnung,
|
||||||
|
keine Sperre: Zwei Fassungen desselben Codes können gewollt sein.
|
||||||
|
|
||||||
|
Verglichen wird nur der Inhalt, nicht die Gestaltung. Derselbe Code in
|
||||||
|
Schwarz-Weiß und in Hausfarben gilt also als Dublette — was er streng
|
||||||
|
genommen auch ist, denn gescannt wird beides Mal dasselbe. Dynamische Codes
|
||||||
|
bleiben außen vor: Zwei Kurz-Links auf dasselbe Ziel sind der Normalfall,
|
||||||
|
sobald zwei Kampagnen getrennt gezählt werden sollen.
|
||||||
|
|
||||||
|
### Ein Logo in der Mitte
|
||||||
|
|
||||||
|
Ein Bild in die Mitte des Codes setzen, wie man es von Firmen-Codes kennt.
|
||||||
|
Zur Wahl stehen zwei Quellen:
|
||||||
|
|
||||||
|
- das **Logo dieser Installation** aus dem Erscheinungsbild — ein Haken
|
||||||
|
genügt, kein Upload je Code;
|
||||||
|
- ein **eigenes Logo** je Code, für Kundenprojekte mit fremdem Zeichen.
|
||||||
|
|
||||||
|
Die Größe ist zwischen 10 und 25 % der Kantenlänge einstellbar; voreingestellt
|
||||||
|
sind 20 %, das entspricht rund 4 % der Fläche. Hinter dem Bild liegt eine
|
||||||
|
Fläche in der Hintergrundfarbe, damit es sich absetzt.
|
||||||
|
|
||||||
|
**Die Fehlerkorrektur wird dabei auf H erzwungen** — was das Bild verdeckt,
|
||||||
|
muss sie ausgleichen. Die Auswahl steht dann fest auf H und sagt auch warum;
|
||||||
|
heimlich geschieht das nicht. Ab 22 % erscheint eine Warnung: Die
|
||||||
|
Fehlerkorrektur trägt das zwar, aber manche Scanner-Apps suchen in der Mitte
|
||||||
|
nach Ausrichtungsmustern.
|
||||||
|
|
||||||
|
Jeder erzeugte Code wird in der Prüfung wieder eingelesen — über mehrere
|
||||||
|
Größen, Prozentsätze und Inhaltslängen. Das ersetzt trotzdem nicht den einen
|
||||||
|
Scan mit dem eigenen Handy, bevor etwas in den Druck geht.
|
||||||
|
|
||||||
### Der Kurz-Link
|
### Der Kurz-Link
|
||||||
|
|
||||||
@@ -263,8 +315,9 @@ Die **Vorschau** wird serverseitig gerendert und aktualisiert sich bei jeder
|
|||||||
|
|
||||||
## Meine Codes
|
## Meine Codes
|
||||||
|
|
||||||
Zeigt alle dynamischen Codes — statische erscheinen nicht, weil sie nicht
|
Zeigt die dynamischen Codes und die statischen, die abgelegt wurden. Ein
|
||||||
gespeichert werden.
|
abgelegter statischer Code trägt statt einer Kurz-URL das Abzeichen
|
||||||
|
„statisch“ und zählt keine Scans.
|
||||||
|
|
||||||
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
|
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
|
||||||
mit großer Vorschau. Die Wahl merkt sich der Browser.
|
mit großer Vorschau. Die Wahl merkt sich der Browser.
|
||||||
@@ -272,6 +325,10 @@ gespeichert werden.
|
|||||||
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
|
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
|
||||||
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
|
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
|
||||||
es auch eine Gruppenspalte.
|
es auch eine Gruppenspalte.
|
||||||
|
- **Filter nach Art** (*Alle Arten*, *Nur dynamische*, *Nur statische*),
|
||||||
|
sobald beide Arten vorkommen. Bei „Nur statische“ verschwindet die
|
||||||
|
Scan-Spalte und mit ihr die Sortierung danach: Statische zählen keine
|
||||||
|
Scans, die Spalte wäre eine Reihe aus Strichen.
|
||||||
- **Kurz-URL kopieren** mit einem Klick.
|
- **Kurz-URL kopieren** mit einem Klick.
|
||||||
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
|
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
|
||||||
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
|
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
|
||||||
@@ -374,6 +431,53 @@ Domains werden in der Verwaltung angelegt und Gruppen zugeteilt.
|
|||||||
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||||
Anwendung leiten.
|
Anwendung leiten.
|
||||||
|
|
||||||
|
### Kurzhilfe zur Einrichtung
|
||||||
|
|
||||||
|
Was außerhalb der Anwendung einzurichten ist, steht nicht nur im README: Auf
|
||||||
|
der Domain-Seite öffnet der Knopf **Kurzhilfe** eine Erklärung in sechs
|
||||||
|
Schritten — DNS-Eintrag, Weiterleitung im Reverse Proxy samt der drei nötigen
|
||||||
|
Kopfzeilen zum Abtippen, Zertifikat, Registrieren, Gruppenzuteilung,
|
||||||
|
Nachprüfen. Der Hostname aus `BASE_URL` wird als Beispiel eingesetzt.
|
||||||
|
|
||||||
|
Bewusst ein Dialog und kein Dauertext: Die Erklärung braucht man einmal beim
|
||||||
|
Einrichten und danach nie wieder.
|
||||||
|
|
||||||
|
### Prüfen, ob eine Domain hierher führt
|
||||||
|
|
||||||
|
Bei jedem Eintrag steht ein Knopf **Prüfen**. Er geht den Weg der Scanner nach
|
||||||
|
und meldet vier Dinge:
|
||||||
|
|
||||||
|
| Schritt | Was geprüft wird |
|
||||||
|
| --- | --- |
|
||||||
|
| DNS | Löst der Name auf? Auf welche Adressen? |
|
||||||
|
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
|
||||||
|
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
|
||||||
|
| Ziel | Steht dahinter **diese** Installation? |
|
||||||
|
|
||||||
|
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
|
||||||
|
sagt wenig — es könnte die Parkseite des Registrars sein oder eine andere
|
||||||
|
Installation dieser Anwendung. Deshalb ruft die Prüfung unter der Domain eine
|
||||||
|
eigene Route auf und lässt sich einen HMAC über eine frische Zufallszahl
|
||||||
|
zurückgeben. Das Geheimnis dafür entsteht beim ersten Check, liegt in der
|
||||||
|
Datenbank und verlässt den Server nie.
|
||||||
|
|
||||||
|
Die Meldungen sind im Klartext: abgelaufen, selbst ausgestellt, gilt für einen
|
||||||
|
anderen Namen, Zwischenzertifikat fehlt, Port 443 nimmt nichts an, „es
|
||||||
|
antwortet etwas, aber nicht diese Anwendung“. Läuft ein Zertifikat in
|
||||||
|
höchstens 14 Tagen ab, gilt der Schritt als Warnung statt als Erfolg.
|
||||||
|
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
|
||||||
|
Meldung würde die eine wichtige verdecken.
|
||||||
|
|
||||||
|
**Die Grenze der Aussage** steht unter jedem Bericht: Der Aufruf startet dort,
|
||||||
|
wo der Container läuft. Bei internem DNS oder Hairpin-NAT kann er scheitern,
|
||||||
|
obwohl von außen alles stimmt; umgekehrt beweist ein Erfolg von innen nicht,
|
||||||
|
dass die Domain von außen erreichbar ist.
|
||||||
|
|
||||||
|
Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer aus
|
||||||
|
der Anfrage — sonst wäre die Route ein Werkzeug, mit dem sich vom Server aus
|
||||||
|
beliebige Adressen abklopfen ließen. Näheres in
|
||||||
|
[sicherheit.md](sicherheit.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Erscheinungsbild
|
## Erscheinungsbild
|
||||||
@@ -448,7 +552,7 @@ auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
|
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
|
||||||
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
|
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
|
||||||
| **QR-Typen** | einzelne Typen ausblenden |
|
| **QR-Typen** | einzelne Typen ausblenden — für alle oder je Gruppe |
|
||||||
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
|
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
|
||||||
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
|
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
|
||||||
| **Domains** | anlegen, Gruppen zuteilen, abschalten |
|
| **Domains** | anlegen, Gruppen zuteilen, abschalten |
|
||||||
@@ -460,6 +564,20 @@ lassen sich bearbeiten. Nur neue lassen sich nicht mehr anlegen. Ohne Eintrag
|
|||||||
in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation
|
in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation
|
||||||
alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
|
alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
|
||||||
|
|
||||||
|
Die Freigabe hat **zwei Ebenen**. Die Grundeinstellung gilt für die ganze
|
||||||
|
Installation; je Gruppe lässt sich davon abweichen — die Marketingabteilung
|
||||||
|
bekommt Social Media, die Werkstatt nur Website und PDF. Fehlt eine Abweichung,
|
||||||
|
folgt die Gruppe der Grundeinstellung.
|
||||||
|
|
||||||
|
**Die globale Abschaltung gewinnt immer.** Ein Typ, der in der
|
||||||
|
Grundeinstellung aus ist, lässt sich in keiner Gruppe einschalten; der
|
||||||
|
Schalter steht dort sichtbar still und nennt den Grund. Andersherum wäre „hier
|
||||||
|
abgeschaltet“ keine verlässliche Aussage mehr.
|
||||||
|
|
||||||
|
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
|
||||||
|
in beiden Gruppen ist, sieht beides; welche Freigabe zählt, entscheidet sich
|
||||||
|
beim Anlegen anhand der gewählten Gruppe.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
@@ -482,6 +600,21 @@ alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Version und Änderungen
|
||||||
|
|
||||||
|
Die Fußzeile nennt die laufende Version und führt auf eine Seite mit dem
|
||||||
|
vollständigen Änderungsverlauf. Nach einem Update ist das die schnellste
|
||||||
|
Antwort auf die Frage, ob der Container wirklich mit dem neuen Abbild läuft:
|
||||||
|
Steht dort noch die alte Nummer, ist er es nicht.
|
||||||
|
|
||||||
|
Der Verlauf wird an **einer** Stelle gepflegt (`src/lib/changelog.ts`).
|
||||||
|
Daraus entstehen sowohl die Seite in der Anwendung als auch die
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md) im Projekt (`npm run changelog`); eine
|
||||||
|
Prüfung wacht darüber, dass beides zusammenpasst. Zu jeder Version gibt es
|
||||||
|
einen Git-Tag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Schnittstelle
|
## Schnittstelle
|
||||||
|
|
||||||
Alles, was die Oberfläche tut, geht auch über HTTP:
|
Alles, was die Oberfläche tut, geht auch über HTTP:
|
||||||
|
|||||||
@@ -7,86 +7,21 @@ Aufwände sind grobe Einschätzungen, keine Zusagen.
|
|||||||
|
|
||||||
## Gebaut
|
## Gebaut
|
||||||
|
|
||||||
### Version 1 — Grundgerüst (13.08.2026)
|
Der vollständige Verlauf mit Versionsnummern steht im
|
||||||
|
[Changelog](../CHANGELOG.md) — und in der Anwendung selbst unter
|
||||||
|
**Version und Änderungen**, erreichbar über die Versionsnummer in der
|
||||||
|
Fußzeile.
|
||||||
|
|
||||||
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, ohne Reverse
|
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
|
||||||
Proxy, Migrationen beim Start
|
Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
|
||||||
- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein
|
1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
|
||||||
Formular, ohne Eingriff in den übrigen Code
|
und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes,
|
||||||
- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und
|
1.6.0 den Versionshinweis und 1.7.0 sechs Erweiterungen auf einmal — Logo im
|
||||||
Scan-Zähler
|
Code, Freigabe je Gruppe, Domain-Check, Dublettenprüfung, Filter nach Art und
|
||||||
- Gestaltung: Farben mit Kontrastwarnung, Größe, Fehlerkorrektur, Rand
|
die Kurzhilfe zu den Domains.
|
||||||
- Download als PNG und SVG, serverseitig gerendert
|
|
||||||
- „Meine Codes“ mit Ziel ändern und Löschen
|
|
||||||
- Deutsche Oberfläche, deutsche Fehlermeldungen, Validierung mit Zod
|
|
||||||
- Update-Skript für Linux/macOS und Windows
|
|
||||||
|
|
||||||
### Typen einzeln freigeben (14.08.2026)
|
Damit sind zwei Punkte erledigt, die hier lange unter „Als Nächstes“ standen:
|
||||||
|
**QR-Typen je Gruppe freigeben** und **Logo im QR-Code**.
|
||||||
Ausblenden nicht gebrauchter Typen in der Verwaltung. Bereits erstellte Codes
|
|
||||||
eines abgeschalteten Typs funktionieren weiter.
|
|
||||||
|
|
||||||
### Phase A — acht weitere Typen (14.08.2026)
|
|
||||||
|
|
||||||
vCard, App-Link, WhatsApp, E-Mail, SMS, Standort, Zahlung (GiroCode und
|
|
||||||
Zahlungslink), WLAN.
|
|
||||||
|
|
||||||
### Phase B1 — Anmeldung, Gruppen, Rollen (14.08.2026)
|
|
||||||
|
|
||||||
Abschaltbare Anmeldung, Benutzerverwaltung, Gruppen mit den Rollen
|
|
||||||
Administrator, Bearbeiter und Betrachter, Sitzungen in der Datenbank,
|
|
||||||
scrypt-Passwörter, Sperre nach Fehlversuchen.
|
|
||||||
|
|
||||||
### Phase B2 — mehrere Domains (14.08.2026)
|
|
||||||
|
|
||||||
Domains für Kurz-URLs, den Gruppen zugeteilt. Slug je Domain eindeutig,
|
|
||||||
Host-Header gegen die registrierten Domains geprüft.
|
|
||||||
|
|
||||||
### Listenansicht (14.08.2026)
|
|
||||||
|
|
||||||
„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter — gedacht für
|
|
||||||
viele Projekte nebeneinander. Karten bleiben als zweite Ansicht.
|
|
||||||
|
|
||||||
### Phase C — die letzten sechs Typen (14.08.2026)
|
|
||||||
|
|
||||||
Mehrere Links, Social Media, Video, PDF, Bild, Feedback. Dazu:
|
|
||||||
|
|
||||||
- Landingpages als eigene Gattung (`/s/{slug}`), ohne App-Navigation
|
|
||||||
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen
|
|
||||||
- Feedback mit anonymer Erhebung und Auswertung
|
|
||||||
|
|
||||||
Damit sind **alle 15 vorgesehenen Typen** gebaut.
|
|
||||||
|
|
||||||
### Single Sign-on (15.08.2026)
|
|
||||||
|
|
||||||
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
|
|
||||||
ID-Token.
|
|
||||||
|
|
||||||
### Erscheinungsbild (16.08.2026)
|
|
||||||
|
|
||||||
Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar. Die Farbe
|
|
||||||
wirkt über die Farbvariablen, die Tailwind ohnehin benutzt — deshalb ohne
|
|
||||||
Umbau der rund 60 eingefärbten Stellen.
|
|
||||||
|
|
||||||
### Dark Mode (16.08.2026)
|
|
||||||
|
|
||||||
Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System.
|
|
||||||
Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne
|
|
||||||
Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl.
|
|
||||||
|
|
||||||
### Sprechende Kurz-Links (17.08.2026)
|
|
||||||
|
|
||||||
Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die
|
|
||||||
vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde
|
|
||||||
jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren
|
|
||||||
vorbehalten, per Einstellung für Bearbeiter freigebbar.
|
|
||||||
|
|
||||||
Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1).
|
|
||||||
|
|
||||||
### Dokumentation (15.08.2026)
|
|
||||||
|
|
||||||
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
|
|
||||||
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,27 +30,7 @@ Die Bilder erzeugt ein Skript, damit sie nicht veralten.
|
|||||||
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
|
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
|
||||||
Person, die den Code kennt.
|
Person, die den Code kennt.
|
||||||
|
|
||||||
### 1. QR-Typen je Gruppe freigeben · mittel
|
### 1. Statistik über die Zeit · mittel
|
||||||
|
|
||||||
Heute gilt die Freigabe eines Typs für die ganze Installation: Was
|
|
||||||
abgeschaltet ist, ist für alle weg. Bei mehreren Gruppen ist das zu grob — die
|
|
||||||
Marketingabteilung braucht Social Media, die Werkstatt nur Website und PDF.
|
|
||||||
|
|
||||||
Geplant: Die Freigabe bekommt eine zweite Dimension. Es bleibt eine globale
|
|
||||||
Einstellung als Grundlage, und je Gruppe lässt sich davon abweichen. Wer in
|
|
||||||
mehreren Gruppen ist, sieht auf der Startseite die Vereinigung; beim Anlegen
|
|
||||||
entscheidet die gewählte Gruppe.
|
|
||||||
|
|
||||||
Zu klären ist der Fall, in dem ein Typ global aus, in einer Gruppe aber an
|
|
||||||
ist — entweder die Gruppe darf das überstimmen, oder die globale Abschaltung
|
|
||||||
gewinnt immer. Die zweite Lesart ist die weniger überraschende und
|
|
||||||
wahrscheinlich die richtige.
|
|
||||||
|
|
||||||
Berührt: `QrTypeSetting` (zusammengesetzter Schlüssel aus Typ und Gruppe),
|
|
||||||
`src/lib/qr/availability.ts`, die Startseite und die Typ-Verwaltung, die dann
|
|
||||||
eine Gruppenauswahl bekommt.
|
|
||||||
|
|
||||||
### 2. Statistik über die Zeit · mittel
|
|
||||||
|
|
||||||
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
|
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
|
||||||
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
|
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
|
||||||
@@ -129,7 +44,7 @@ sagt nichts über einzelne Menschen aus und braucht deshalb weder Einwilligung
|
|||||||
noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine
|
noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine
|
||||||
andere Entscheidung als „ein Diagramm wäre schön“.
|
andere Entscheidung als „ein Diagramm wäre schön“.
|
||||||
|
|
||||||
### 3. Papierkorb statt Löschen · klein
|
### 2. Papierkorb statt Löschen · klein
|
||||||
|
|
||||||
Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot,
|
Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot,
|
||||||
samt seiner Dateien und Rückmeldungen.
|
samt seiner Dateien und Rückmeldungen.
|
||||||
@@ -139,20 +54,7 @@ Geplant: Gelöschte Codes 30 Tage aufheben und dann endgültig entfernen. In
|
|||||||
im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code
|
im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code
|
||||||
bekommen und die gedruckten Codes zeigten plötzlich woandershin.
|
bekommen und die gedruckten Codes zeigten plötzlich woandershin.
|
||||||
|
|
||||||
### 4. Logo im QR-Code · mittel
|
### 3. Ablaufdatum und Zeitsteuerung · mittel
|
||||||
|
|
||||||
Ein kleines Bild in die Mitte setzen, wie man es von Firmen-Codes kennt.
|
|
||||||
|
|
||||||
Geplant: Upload eines Logos je Code, mittig eingesetzt, mit erzwungener
|
|
||||||
Fehlerkorrektur **H** und einer Warnung, sobald zu viel Fläche verdeckt wird.
|
|
||||||
|
|
||||||
Das ist der Punkt mit dem größten Risiko, etwas Kaputtes auszuliefern: Ein
|
|
||||||
Code, der auf dem eigenen Handy noch funktioniert, kann bei anderen
|
|
||||||
Scanner-Apps durchfallen. Gehört deshalb nicht ohne eine ehrliche Prüfung mit
|
|
||||||
mehreren Geräten und Apps ausgeliefert — und mit einer klaren Obergrenze für
|
|
||||||
die verdeckte Fläche.
|
|
||||||
|
|
||||||
### 5. Ablaufdatum und Zeitsteuerung · mittel
|
|
||||||
|
|
||||||
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
|
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
|
||||||
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
|
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
|
||||||
@@ -169,6 +71,20 @@ Geplant, in zwei Stufen:
|
|||||||
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
|
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
|
||||||
Fenstern geschieht.
|
Fenstern geschieht.
|
||||||
|
|
||||||
|
### 4. Gestaltung eines gespeicherten Codes ändern · klein
|
||||||
|
|
||||||
|
Aufgefallen beim Einbau des Logos: In „Meine Codes“ lässt sich die Gestaltung
|
||||||
|
eines abgelegten Codes gar nicht mehr anfassen — weder Farben noch Größe noch
|
||||||
|
Logo. Wer ein Logo nachrüsten will, kommt nur über die Schnittstelle daran.
|
||||||
|
|
||||||
|
Bei einem **dynamischen** Code ist das unnötig streng: Das gedruckte Bild
|
||||||
|
bleibt zwar, wie es ist, aber der erneute Download soll ja gerade die neue
|
||||||
|
Gestaltung tragen. Geplant: derselbe Gestaltungsbereich wie im Generator, im
|
||||||
|
Bearbeiten-Dialog.
|
||||||
|
|
||||||
|
Bei einem **statischen** Code bleibt es beim Nein — er ist ein Schnappschuss,
|
||||||
|
und eine geänderte Gestaltung ergäbe ein anderes Bild als das gedruckte.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Später denkbar
|
## Später denkbar
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
* Voraussetzungen:
|
* Voraussetzungen:
|
||||||
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
|
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
|
||||||
* Codes im Bild landen), erreichbar unter BASE
|
* Codes im Bild landen), erreichbar unter BASE
|
||||||
|
* - `BASE_URL` der Instanz muss auf **dieselbe** Adresse zeigen wie BASE.
|
||||||
|
* Sonst scheitert der Domain-Check im Bild 21, weil die Anwendung sich
|
||||||
|
* selbst unter der eingetragenen Adresse nicht erreicht.
|
||||||
* - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
|
* - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
|
||||||
* Regelbetrieb
|
* Regelbetrieb
|
||||||
* - Playwright: npm i -D playwright && npx playwright install chromium
|
* - Playwright: npm i -D playwright && npx playwright install chromium
|
||||||
@@ -18,6 +21,7 @@ import { mkdir } from "node:fs/promises";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
|
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
|
||||||
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
|
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
|
||||||
@@ -197,16 +201,47 @@ const handy = await browser.newContext({
|
|||||||
locale: "de-DE",
|
locale: "de-DE",
|
||||||
});
|
});
|
||||||
|
|
||||||
async function schuss(context, pfad, datei, { ganzeSeite = true, vorher } = {}) {
|
/**
|
||||||
|
* Nimmt eine Seite auf.
|
||||||
|
*
|
||||||
|
* `bereich` grenzt auf einen Ausschnitt ein — für Dialoge und einzelne Karten,
|
||||||
|
* bei denen die ganze Seite mehr verdeckt als zeigt.
|
||||||
|
*/
|
||||||
|
async function schuss(
|
||||||
|
context,
|
||||||
|
pfad,
|
||||||
|
datei,
|
||||||
|
{ ganzeSeite = true, vorher, bereich } = {},
|
||||||
|
) {
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
|
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
|
||||||
if (vorher) await vorher(page);
|
if (vorher) await vorher(page);
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
await page.screenshot({ path: join(ZIEL, datei), fullPage: ganzeSeite });
|
const ziel = join(ZIEL, datei);
|
||||||
|
if (bereich) {
|
||||||
|
await page.locator(bereich).first().screenshot({ path: ziel });
|
||||||
|
} else {
|
||||||
|
await page.screenshot({ path: ziel, fullPage: ganzeSeite });
|
||||||
|
}
|
||||||
await page.close();
|
await page.close();
|
||||||
console.log(" ✓", datei);
|
console.log(" ✓", datei);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Beispiellogo für die Aufnahmen zum Logo im Code.
|
||||||
|
*
|
||||||
|
* Erzeugt statt mitgeliefert: So liegt kein Bild im Repo, das niemand pflegt,
|
||||||
|
* und die Farbe passt zu den übrigen Aufnahmen.
|
||||||
|
*/
|
||||||
|
async function beispiellogo() {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">
|
||||||
|
<rect width="256" height="256" rx="56" fill="#312E81"/>
|
||||||
|
<circle cx="128" cy="128" r="66" fill="none" stroke="#FFFFFF" stroke-width="18"/>
|
||||||
|
<circle cx="128" cy="128" r="20" fill="#FFFFFF"/>
|
||||||
|
</svg>`;
|
||||||
|
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Bilder werden erzeugt:");
|
console.log("Bilder werden erzeugt:");
|
||||||
|
|
||||||
await schuss(desktop, "/", "01-startseite.png");
|
await schuss(desktop, "/", "01-startseite.png");
|
||||||
@@ -242,7 +277,7 @@ await schuss(desktop, "/meine-codes", "06-ziel-bearbeiten.png", {
|
|||||||
await page.waitForTimeout(400);
|
await page.waitForTimeout(400);
|
||||||
// Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt.
|
// Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt.
|
||||||
await page.locator("tbody tr").last().getByRole("button", { name: /bearbeiten/ }).click();
|
await page.locator("tbody tr").last().getByRole("button", { name: /bearbeiten/ }).click();
|
||||||
await page.getByRole("heading", { name: "Ziel bearbeiten" }).waitFor();
|
await page.getByRole("heading", { name: "Code bearbeiten" }).waitFor();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,8 +293,129 @@ await schuss(desktop, "/verwaltung/gruppen", "13-verwaltung-gruppen.png");
|
|||||||
await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png");
|
await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png");
|
||||||
await schuss(desktop, "/konto", "15-konto.png");
|
await schuss(desktop, "/konto", "15-konto.png");
|
||||||
|
|
||||||
|
/* ------------------------------------------- Seit 1.7.0: die neuen Funktionen */
|
||||||
|
|
||||||
|
const logo = await beispiellogo();
|
||||||
|
|
||||||
|
// Logo in der Mitte: ganze Seite, damit Auswahl und Vorschau zusammen im Bild
|
||||||
|
// sind — das eine erklärt das andere.
|
||||||
|
await schuss(desktop, "/erstellen/website", "16-logo-im-code.png", {
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.locator("#website-url").fill("https://example.com/nordwind");
|
||||||
|
await page.getByRole("checkbox", { name: /Logo in die Mitte/ }).check();
|
||||||
|
await page.setInputFiles('input[type="file"]', {
|
||||||
|
name: "logo.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: logo,
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Für die Dublettenwarnung braucht es einen bereits abgelegten statischen Code.
|
||||||
|
const statisch = await api("/api/codes", {
|
||||||
|
method: "POST",
|
||||||
|
json: {
|
||||||
|
type: "website",
|
||||||
|
data: { url: "https://example.com/aushang" },
|
||||||
|
design: DESIGN,
|
||||||
|
mode: "static",
|
||||||
|
label: "Aushang Eingang",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (statisch.status === 201) angelegt.push(statisch.body.code);
|
||||||
|
|
||||||
|
await schuss(desktop, "/erstellen/website", "17-dublettenwarnung.png", {
|
||||||
|
bereich: "section:has-text('Art des Codes')",
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.locator("#website-url").fill("https://example.com/aushang");
|
||||||
|
await page.locator('button[data-mode="static"]').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await page.getByRole("button", { name: /ablegen/i }).click();
|
||||||
|
await page.getByText("Diesen Inhalt hast du schon abgelegt").waitFor({
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter nach Art — sichtbar, weil jetzt beide Arten in der Liste stehen.
|
||||||
|
await schuss(desktop, "/meine-codes", "18-meine-codes-filter.png", {
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.getByLabel("Nach Art filtern").selectOption("static");
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Freigabe je Gruppe: eine zweite Gruppe, damit die Auswahl etwas zu wählen hat.
|
||||||
|
const gruppe = await api("/api/admin/groups", {
|
||||||
|
method: "POST",
|
||||||
|
json: { name: "Marketing" },
|
||||||
|
});
|
||||||
|
const gruppeId = gruppe.body?.group?.id ?? null;
|
||||||
|
|
||||||
|
if (gruppeId) {
|
||||||
|
// Ein Typ in dieser Gruppe abgeschaltet, damit das Bild den Fall zeigt,
|
||||||
|
// um den es geht.
|
||||||
|
await api("/api/admin/types", {
|
||||||
|
method: "PATCH",
|
||||||
|
json: { type: "payment", enabled: false, groupId: gruppeId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await schuss(desktop, "/verwaltung/typen", "19-verwaltung-typen-gruppe.png", {
|
||||||
|
vorher: async (page) => {
|
||||||
|
if (gruppeId) {
|
||||||
|
await page.getByLabel("Freigabe für").selectOption(gruppeId);
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kurzhilfe: nur der Dialog, die Seite dahinter trägt nichts bei.
|
||||||
|
await schuss(desktop, "/verwaltung/domains", "20-domain-kurzhilfe.png", {
|
||||||
|
bereich: "dialog[open]",
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.getByRole("button", { name: "Kurzhilfe" }).click();
|
||||||
|
await page.locator("dialog[open]").waitFor();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Domain-Check: der Bericht zur eigenen Adresse. Weil BASE_URL und der Port
|
||||||
|
// des Containers hier zusammenpassen, geht die Prüfung durch — genau der
|
||||||
|
// Fall, den die Anleitung zeigen soll.
|
||||||
|
await schuss(desktop, "/verwaltung/domains", "21-domain-check.png", {
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.getByRole("button", { name: "Prüfen" }).first().click();
|
||||||
|
await page
|
||||||
|
.locator("text=/Die Domain führt hierher|So kommt niemand hier an/")
|
||||||
|
.first()
|
||||||
|
.waitFor({ timeout: 30000 });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
|
/* --- Was über die Oberfläche entstand, kennt `angelegt` noch nicht --------- */
|
||||||
|
|
||||||
|
const bestand = await api("/api/codes");
|
||||||
|
for (const eintrag of bestand.body?.codes ?? []) {
|
||||||
|
if (!angelegt.some((vorhanden) => vorhanden.id === eintrag.id)) {
|
||||||
|
angelegt.push(eintrag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Abweichung und die Gruppe wieder zurücknehmen — das Skript soll eine
|
||||||
|
// Instanz so hinterlassen, wie es sie vorgefunden hat.
|
||||||
|
if (gruppeId) {
|
||||||
|
await api("/api/admin/types", {
|
||||||
|
method: "PATCH",
|
||||||
|
json: { type: "payment", enabled: true, groupId: gruppeId },
|
||||||
|
});
|
||||||
|
await api(`/api/admin/groups/${gruppeId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------ Aufräumen */
|
/* ------------------------------------------------------------ Aufräumen */
|
||||||
|
|
||||||
for (const eintrag of angelegt) {
|
for (const eintrag of angelegt) {
|
||||||
|
|||||||
@@ -191,6 +191,29 @@ Konto nur bei **bestätigter** E-Mail-Adresse.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Der Domain-Check und seine Grenzen
|
||||||
|
|
||||||
|
Auf der Domain-Seite ruft der Knopf **Prüfen** die eingetragene Domain vom
|
||||||
|
Server aus auf. Drei Dinge grenzen das ein:
|
||||||
|
|
||||||
|
- Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer
|
||||||
|
aus der Anfrage. Sonst wäre die Route ein Werkzeug, mit dem sich vom Server
|
||||||
|
aus beliebige Adressen abklopfen ließen.
|
||||||
|
- Auslösen darf sie nur, wer `instance:manage` hat — dieselbe Berechtigung,
|
||||||
|
die auch Domains anlegt.
|
||||||
|
- Die Route, die den Beweis liefert (`/api/domain-check`), ist **ohne
|
||||||
|
Anmeldung** erreichbar; der Aufruf kommt ja von außen. Sie gibt nichts
|
||||||
|
preis: Die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Ohne
|
||||||
|
das Geheimnis lässt er sich nicht bilden, und aus ihm folgt nichts über das
|
||||||
|
Geheimnis. Es entsteht beim ersten Check und verlässt den Server nie.
|
||||||
|
|
||||||
|
Beim Zertifikat läuft die Verbindung bewusst mit `rejectUnauthorized: false`.
|
||||||
|
Nur so lässt sich ein schlechtes Zertifikat **beschreiben** — abgelaufen,
|
||||||
|
selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
|
||||||
|
Übertragen wird über diese Verbindung nichts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
Bewusst nicht umgesetzt, mit Begründung.
|
Bewusst nicht umgesetzt, mit Begründung.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.1.0",
|
"version": "1.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.1.0",
|
"version": "1.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -566,7 +567,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -2065,6 +2065,72 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.2",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||||
@@ -4039,7 +4105,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -7795,7 +7860,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.1.0",
|
"@img/colour": "^1.1.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -7845,7 +7909,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.1.0",
|
"version": "1.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"changelog": "node scripts/changelog.mjs",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Statische Codes lassen sich mit Namen ablegen, damit man sie wiederfindet
|
||||||
|
-- und erneut herunterladen kann.
|
||||||
|
|
||||||
|
-- Alles, was es bisher gibt, ist dynamisch.
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "mode" TEXT NOT NULL DEFAULT 'dynamic';
|
||||||
|
CREATE INDEX "QrCode_mode_idx" ON "QrCode"("mode");
|
||||||
|
|
||||||
|
-- Ein statischer Code hat keinen Kurz-Link: Er traegt seinen Inhalt selbst und
|
||||||
|
-- fuehrt auf nichts, was dieser Server ausliefert. In PostgreSQL schliessen
|
||||||
|
-- sich mehrere NULL in einem eindeutigen Index nicht aus — die Bedingung
|
||||||
|
-- „je Domain eindeutig" bleibt fuer dynamische Codes also unveraendert.
|
||||||
|
ALTER TABLE "QrCode" ALTER COLUMN "slug" DROP NOT NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Der Domain-Check ruft die Domain von aussen auf und prueft, ob er dabei bei
|
||||||
|
-- dieser Installation landet. Beweis dafuer ist ein HMAC ueber eine
|
||||||
|
-- Zufallszahl, gebildet mit einem Geheimnis, das den Server nie verlaesst.
|
||||||
|
--
|
||||||
|
-- Nullable und ohne Vorbelegung: Der Wert entsteht beim ersten Check. Eine
|
||||||
|
-- feste Vorbelegung waere fuer alle Installationen dieselbe und damit wertlos.
|
||||||
|
ALTER TABLE "InstanceSetting" ADD COLUMN "checkSecret" TEXT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Ein Logo in der Mitte des Codes, wie man es von Firmen-Codes kennt.
|
||||||
|
--
|
||||||
|
-- Drei Angaben gehoeren zur Gestaltung: woher das Bild kommt, welche Datei es
|
||||||
|
-- ist und wie gross es sein soll. Ohne Herkunft gibt es kein Logo — deshalb
|
||||||
|
-- sind die ersten beiden Spalten leer erlaubt und ohne Vorbelegung.
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "logoSource" TEXT;
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "logoUploadId" TEXT;
|
||||||
|
|
||||||
|
-- Die Groesse hat eine Vorbelegung, damit bestehende Codes einen gueltigen
|
||||||
|
-- Wert tragen. Ohne Logo bleibt er wirkungslos.
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "logoScale" INTEGER NOT NULL DEFAULT 20;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- QR-Typen lassen sich je Gruppe abschalten.
|
||||||
|
--
|
||||||
|
-- Die globale Freigabe in "QrTypeSetting" bleibt die Grundlage. Diese Tabelle
|
||||||
|
-- traegt nur Abweichungen davon: Fehlt eine Zeile, folgt die Gruppe der
|
||||||
|
-- globalen Einstellung. Eine globale Abschaltung laesst sich hierueber nicht
|
||||||
|
-- aufheben — global aus gewinnt immer.
|
||||||
|
--
|
||||||
|
-- Eigene Tabelle statt eines zusammengesetzten Schluessels in
|
||||||
|
-- "QrTypeSetting": Der globale Fall braeuchte dort eine leere Gruppe, und
|
||||||
|
-- PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
|
||||||
|
-- nebeneinander zu.
|
||||||
|
CREATE TABLE "QrTypeGroupSetting" (
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "QrTypeGroupSetting_pkey" PRIMARY KEY ("type","groupId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "QrTypeGroupSetting_groupId_idx" ON "QrTypeGroupSetting"("groupId");
|
||||||
|
|
||||||
|
-- Mit der Gruppe verschwindet auch ihre Abweichung.
|
||||||
|
ALTER TABLE "QrTypeGroupSetting" ADD CONSTRAINT "QrTypeGroupSetting_groupId_fkey"
|
||||||
|
FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -87,6 +87,7 @@ model Group {
|
|||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
codes QrCode[]
|
codes QrCode[]
|
||||||
domainGrants DomainGrant[]
|
domainGrants DomainGrant[]
|
||||||
|
typeSettings QrTypeGroupSetting[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
||||||
@@ -155,6 +156,31 @@ model QrTypeSetting {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Abweichung von der globalen Freigabe fuer eine einzelne Gruppe.
|
||||||
|
///
|
||||||
|
/// Bewusst eine eigene Tabelle statt eines zusammengesetzten Schluessels in
|
||||||
|
/// `QrTypeSetting`: Der globale Fall braeuchte dort eine leere Gruppe, und
|
||||||
|
/// PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
|
||||||
|
/// nebeneinander zu — die Eindeutigkeit der globalen Zeile waere also gerade
|
||||||
|
/// nicht gesichert.
|
||||||
|
///
|
||||||
|
/// Fehlt eine Zeile, folgt die Gruppe der globalen Einstellung. Eine Zeile
|
||||||
|
/// mit `enabled = false` schaltet den Typ **nur** in dieser Gruppe ab. Eine
|
||||||
|
/// Zeile mit `enabled = true` kann eine globale Abschaltung **nicht**
|
||||||
|
/// aufheben — global aus gewinnt immer.
|
||||||
|
model QrTypeGroupSetting {
|
||||||
|
/// Kennung aus der Typ-Registry, z. B. "website".
|
||||||
|
type String
|
||||||
|
groupId String
|
||||||
|
enabled Boolean
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([type, groupId])
|
||||||
|
@@index([groupId])
|
||||||
|
}
|
||||||
|
|
||||||
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
|
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
|
||||||
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
||||||
///
|
///
|
||||||
@@ -162,10 +188,18 @@ model QrTypeSetting {
|
|||||||
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||||
model QrCode {
|
model QrCode {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
/// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei
|
/// Statisch oder dynamisch. Ein statischer Code traegt seinen Inhalt selbst
|
||||||
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
/// und wird nur abgelegt, damit man ihn wiederfindet und erneut
|
||||||
|
/// herunterladen kann — er hat weder Kurz-Link noch Scan-Zaehler.
|
||||||
|
mode String @default("dynamic")
|
||||||
|
/// Kurz-Link. Nur je Domain eindeutig — derselbe sprechende Slug darf auf
|
||||||
|
/// zwei Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
||||||
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
|
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
|
||||||
slug String
|
///
|
||||||
|
/// Leer bei statischen Codes: Die fuehren auf nichts, was dieser Server
|
||||||
|
/// ausliefert. In PostgreSQL schliessen sich mehrere NULL in einem
|
||||||
|
/// eindeutigen Index nicht aus, die Bedingung bleibt also gueltig.
|
||||||
|
slug String?
|
||||||
type String
|
type String
|
||||||
label String?
|
label String?
|
||||||
data Json
|
data Json
|
||||||
@@ -174,6 +208,14 @@ model QrCode {
|
|||||||
size Int @default(512)
|
size Int @default(512)
|
||||||
ecLevel String @default("M")
|
ecLevel String @default("M")
|
||||||
margin Int @default(4)
|
margin Int @default(4)
|
||||||
|
/// Logo in der Mitte: "branding" nimmt das Logo der Installation,
|
||||||
|
/// "upload" eine eigene Datei. Leer heisst: kein Logo.
|
||||||
|
logoSource String?
|
||||||
|
/// Die eigene Datei, wenn `logoSource` = "upload". Sie haengt ueber
|
||||||
|
/// `Upload.qrCodeId` am Code und verschwindet mit ihm.
|
||||||
|
logoUploadId String?
|
||||||
|
/// Kantenlaenge des Logos in Prozent der Kantenlaenge des Codes.
|
||||||
|
logoScale Int @default(20)
|
||||||
scans Int @default(0)
|
scans Int @default(0)
|
||||||
lastScanAt DateTime?
|
lastScanAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -202,6 +244,7 @@ model QrCode {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
@@index([domainId])
|
@@index([domainId])
|
||||||
|
@@index([mode])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
|
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
|
||||||
@@ -253,6 +296,14 @@ model InstanceSetting {
|
|||||||
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
|
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
|
||||||
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
|
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
|
||||||
editorsMaySetSlug Boolean @default(false)
|
editorsMaySetSlug Boolean @default(false)
|
||||||
|
/// Geheimnis fuer den Domain-Check.
|
||||||
|
///
|
||||||
|
/// Die Pruefung ruft die Domain von aussen auf und laesst sich einen HMAC
|
||||||
|
/// ueber eine Zufallszahl zurueckgeben. Nur wer dieses Geheimnis kennt,
|
||||||
|
/// kann ihn bilden — so steht fest, dass wirklich diese Installation
|
||||||
|
/// geantwortet hat und nicht irgendein anderer Server. Es verlaesst den
|
||||||
|
/// Server nie und entsteht beim ersten Check.
|
||||||
|
checkSecret String?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Schreibt CHANGELOG.md aus src/lib/changelog.ts.
|
||||||
|
*
|
||||||
|
* node scripts/changelog.mjs erzeugen
|
||||||
|
* node scripts/changelog.mjs --check nur prüfen, ob die Datei aktuell ist
|
||||||
|
*
|
||||||
|
* Warum erzeugt und nicht von Hand gepflegt: Die Anwendung zeigt denselben
|
||||||
|
* Verlauf unter /version an. Zwei gepflegte Listen liefen unweigerlich
|
||||||
|
* auseinander — und ein Changelog, der nicht stimmt, ist schlimmer als keiner.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const wurzel = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const ziel = join(wurzel, "CHANGELOG.md");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Quelle ist TypeScript, dieses Skript aber schlichtes Node. Statt einen
|
||||||
|
* Übersetzer dazuzunehmen, wird der Datenteil gelesen und ausgewertet — er
|
||||||
|
* besteht ausschließlich aus Zeichenketten und Listen.
|
||||||
|
*/
|
||||||
|
function ladeVerlauf() {
|
||||||
|
const quelle = readFileSync(join(wurzel, "src", "lib", "changelog.ts"), "utf8");
|
||||||
|
const anfang = quelle.indexOf("export const CHANGELOG");
|
||||||
|
if (anfang === -1) throw new Error("CHANGELOG-Liste nicht gefunden.");
|
||||||
|
|
||||||
|
const start = quelle.indexOf("[", anfang);
|
||||||
|
const ende = quelle.indexOf("\n];", start);
|
||||||
|
if (start === -1 || ende === -1) throw new Error("Liste nicht abgrenzbar.");
|
||||||
|
|
||||||
|
const literal = quelle.slice(start, ende + 2);
|
||||||
|
return Function(`"use strict"; return (${literal});`)();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatiereDatum(iso) {
|
||||||
|
const [jahr, monat, tag] = iso.split("-");
|
||||||
|
return `${tag}.${monat}.${jahr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baue(verlauf) {
|
||||||
|
const zeilen = [
|
||||||
|
"# Änderungen",
|
||||||
|
"",
|
||||||
|
"Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der",
|
||||||
|
"Anwendung unter **Version und Änderungen**, erreichbar über die",
|
||||||
|
"Versionsnummer in der Fußzeile.",
|
||||||
|
"",
|
||||||
|
"> Diese Datei wird erzeugt: `npm run changelog`.",
|
||||||
|
"> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const eintrag of verlauf) {
|
||||||
|
zeilen.push(`## ${eintrag.version} — ${formatiereDatum(eintrag.date)}`, "");
|
||||||
|
zeilen.push(eintrag.summary, "");
|
||||||
|
for (const änderung of eintrag.changes) zeilen.push(`- ${änderung}`);
|
||||||
|
zeilen.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return zeilen.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inhalt = baue(ladeVerlauf());
|
||||||
|
|
||||||
|
if (process.argv.includes("--check")) {
|
||||||
|
let vorhanden = "";
|
||||||
|
try {
|
||||||
|
vorhanden = readFileSync(ziel, "utf8");
|
||||||
|
} catch {
|
||||||
|
console.error("CHANGELOG.md fehlt. Erzeugen mit: npm run changelog");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (vorhanden !== inhalt) {
|
||||||
|
console.error(
|
||||||
|
"CHANGELOG.md passt nicht mehr zu src/lib/changelog.ts. Neu erzeugen mit: npm run changelog",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("CHANGELOG.md ist aktuell.");
|
||||||
|
} else {
|
||||||
|
writeFileSync(ziel, inhalt, "utf8");
|
||||||
|
console.log("CHANGELOG.md geschrieben.");
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { QrGenerator } from "@/components/qr-generator";
|
import { QrGenerator } from "@/components/qr-generator";
|
||||||
import { resolveTargetGroup } from "@/lib/auth/groups";
|
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||||
|
import { getBranding } from "@/lib/branding";
|
||||||
import { can } from "@/lib/auth/policy";
|
import { can } from "@/lib/auth/policy";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import { requirePageActor } from "@/lib/auth/guard";
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
@@ -52,7 +53,12 @@ function Hinweis({
|
|||||||
export default async function CreatePage({ params }: Props) {
|
export default async function CreatePage({ params }: Props) {
|
||||||
const actor = await requirePageActor();
|
const actor = await requirePageActor();
|
||||||
const { type } = await params;
|
const { type } = await params;
|
||||||
const state = await getQrTypeState(type);
|
|
||||||
|
// Welche Gruppe gilt, entscheidet mit darueber, ob der Typ hier offensteht.
|
||||||
|
const zielgruppe = await resolveTargetGroup(actor, undefined).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
const state = await getQrTypeState(type, zielgruppe);
|
||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -63,7 +69,11 @@ export default async function CreatePage({ params }: Props) {
|
|||||||
<Hinweis
|
<Hinweis
|
||||||
icon="lock"
|
icon="lock"
|
||||||
titel={`„${state.qrType.title}“ ist abgeschaltet`}
|
titel={`„${state.qrType.title}“ ist abgeschaltet`}
|
||||||
text="Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben."
|
text={
|
||||||
|
state.enabledGlobally
|
||||||
|
? "Dieser QR-Typ ist für deine Gruppe abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen für die Gruppe wieder freigeben."
|
||||||
|
: "Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,8 +92,20 @@ export default async function CreatePage({ params }: Props) {
|
|||||||
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
|
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
|
||||||
// noch einmal.
|
// noch einmal.
|
||||||
const { editorsMaySetSlug } = await getSettings();
|
const { editorsMaySetSlug } = await getSettings();
|
||||||
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
|
const maySetSlug = can(actor, "codes:slug", {
|
||||||
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
|
groupId: zielgruppe,
|
||||||
|
editorsMaySetSlug,
|
||||||
|
});
|
||||||
|
|
||||||
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
|
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
|
||||||
|
// der eigene Upload.
|
||||||
|
const { logoUploadId } = await getBranding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QrGenerator
|
||||||
|
typeId={state.qrType.id}
|
||||||
|
maySetSlug={maySetSlug}
|
||||||
|
hasBrandingLogo={logoUploadId !== null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
|||||||
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
||||||
import { can } from "@/lib/auth/policy";
|
import { can } from "@/lib/auth/policy";
|
||||||
import { getBranding } from "@/lib/branding";
|
import { getBranding } from "@/lib/branding";
|
||||||
|
import { APP_VERSION } from "@/lib/changelog";
|
||||||
|
|
||||||
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
|
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
|
||||||
// werden.
|
// werden.
|
||||||
@@ -128,9 +129,20 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
|||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
|
|
||||||
<footer className="border-t border-slate-200 bg-surface">
|
<footer className="border-t border-slate-200 bg-surface">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
|
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-4 py-6 text-sm text-slate-500 sm:px-6">
|
||||||
|
<span>
|
||||||
{branding.appName}
|
{branding.appName}
|
||||||
{branding.footerText ? ` · ${branding.footerText}` : null}
|
{branding.footerText ? ` · ${branding.footerText}` : null}
|
||||||
|
</span>
|
||||||
|
{/* Welche Fassung hier läuft — nach einem Update die schnellste
|
||||||
|
Antwort auf die Frage, ob das Update auch angekommen ist. */}
|
||||||
|
<Link
|
||||||
|
href="/version"
|
||||||
|
title="Version und Änderungen"
|
||||||
|
className="rounded font-mono text-xs tabular-nums transition-colors hover:text-slate-900"
|
||||||
|
>
|
||||||
|
v{APP_VERSION}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export default async function MyCodesPage() {
|
|||||||
Meine Codes
|
Meine Codes
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-slate-600">
|
<p className="mt-2 text-slate-600">
|
||||||
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der
|
Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans,
|
||||||
Scans. Statische Codes erscheinen hier nicht — sie werden nicht
|
dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“
|
||||||
gespeichert.
|
lässt sich die Liste auf eine der beiden Arten einschränken.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ import Link from "next/link";
|
|||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { Badge, Notice } from "@/components/ui";
|
import { Badge, Notice } from "@/components/ui";
|
||||||
import { requirePageActor } from "@/lib/auth/guard";
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
import { getVisibleQrTypes } from "@/lib/qr/availability";
|
import { getVisibleQrTypesForActor } from "@/lib/qr/availability";
|
||||||
|
|
||||||
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
|
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
|
||||||
// änderbar — die Startseite darf deshalb nicht vorgerendert werden.
|
// änderbar — die Startseite darf deshalb nicht vorgerendert werden.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
await requirePageActor();
|
const actor = await requirePageActor();
|
||||||
const types = await getVisibleQrTypes();
|
// Wer in mehreren Gruppen ist, sieht die Vereinigung ihrer Freigaben.
|
||||||
|
const types = await getVisibleQrTypesForActor(actor);
|
||||||
const usableCount = types.filter((entry) => entry.usable).length;
|
const usableCount = types.filter((entry) => entry.usable).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { Badge, Card } from "@/components/ui";
|
||||||
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
|
import {
|
||||||
|
APP_VERSION,
|
||||||
|
CHANGELOG,
|
||||||
|
formatChangelogDate,
|
||||||
|
} from "@/lib/changelog";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Version und Änderungen" };
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function VersionPage() {
|
||||||
|
await requirePageActor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<Icon name="arrowLeft" className="size-4" />
|
||||||
|
Zur Typ-Auswahl
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Version und Änderungen
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Hier läuft Version{" "}
|
||||||
|
<span className="font-semibold text-slate-900">{APP_VERSION}</span>.
|
||||||
|
Darunter steht, was in welcher Fassung dazugekommen ist.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Nach einem Update lohnt ein Blick hierher: Steht oben noch die alte
|
||||||
|
Nummer, läuft der Container noch mit dem alten Abbild.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{CHANGELOG.map((eintrag) => {
|
||||||
|
const laeuft = eintrag.version === APP_VERSION;
|
||||||
|
return (
|
||||||
|
<li key={eintrag.version}>
|
||||||
|
<Card>
|
||||||
|
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
{eintrag.version}
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-slate-500 tabular-nums">
|
||||||
|
{formatChangelogDate(eintrag.date)}
|
||||||
|
</span>
|
||||||
|
{laeuft ? (
|
||||||
|
<Badge tone="accent">läuft hier</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-3 text-slate-600">{eintrag.summary}</p>
|
||||||
|
|
||||||
|
<ul className="space-y-1.5 text-sm text-slate-700">
|
||||||
|
{eintrag.changes.map((zeile) => (
|
||||||
|
<li key={zeile} className="flex gap-2.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-2 size-1.5 shrink-0 rounded-full bg-slate-300"
|
||||||
|
/>
|
||||||
|
<span>{zeile}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
|
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
|
||||||
import { requirePagePermission } from "@/lib/auth/guard";
|
import { requirePagePermission } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import { getQrTypeAvailability } from "@/lib/qr/availability";
|
import { getQrTypeAvailability } from "@/lib/qr/availability";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "QR-Typen" };
|
export const metadata: Metadata = { title: "QR-Typen" };
|
||||||
@@ -15,16 +16,23 @@ export default async function TypeSettingsPage() {
|
|||||||
const availability = await getQrTypeAvailability();
|
const availability = await getQrTypeAvailability();
|
||||||
|
|
||||||
const initial: TypeSetting[] = availability.map(
|
const initial: TypeSetting[] = availability.map(
|
||||||
({ qrType, implemented, enabled }) => ({
|
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup }) => ({
|
||||||
id: qrType.id,
|
id: qrType.id,
|
||||||
title: qrType.title,
|
title: qrType.title,
|
||||||
description: qrType.description,
|
description: qrType.description,
|
||||||
icon: qrType.icon,
|
icon: qrType.icon,
|
||||||
implemented,
|
implemented,
|
||||||
enabled,
|
enabled,
|
||||||
|
enabledGlobally,
|
||||||
|
disabledInGroup,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const groups = await prisma.group.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
<Link
|
<Link
|
||||||
@@ -44,6 +52,11 @@ export default async function TypeSettingsPage() {
|
|||||||
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
|
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
|
||||||
anlegen.
|
anlegen.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Die Grundeinstellung gilt für die ganze Installation. Je Gruppe lässt
|
||||||
|
sich davon abweichen — was hier abgeschaltet ist, bleibt aber überall
|
||||||
|
abgeschaltet.
|
||||||
|
</p>
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
|
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
|
||||||
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
|
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
|
||||||
@@ -51,7 +64,7 @@ export default async function TypeSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TypeSettings initial={initial} />
|
<TypeSettings initial={initial} groups={groups} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { pruefeDomain } from "@/lib/domain-check";
|
||||||
|
import { errorResponse } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft eine registrierte Domain: DNS, Zertifikat, Erreichbarkeit — und ob
|
||||||
|
* dahinter wirklich diese Installation steht.
|
||||||
|
*
|
||||||
|
* `POST`, obwohl nichts gespeichert wird: Der Aufruf stößt eine Verbindung
|
||||||
|
* nach außen an und ist nichts, was sich vorsorglich abrufen lässt.
|
||||||
|
*
|
||||||
|
* Geprüft wird ausschließlich der Hostname aus der Datenbank. Ein Hostname
|
||||||
|
* aus der Anfrage würde diese Route zum Werkzeug machen, mit dem sich vom
|
||||||
|
* Server aus beliebige Adressen abklopfen lassen.
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
_request: Request,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
const domain = await prisma.domain.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { hostname: true },
|
||||||
|
});
|
||||||
|
if (!domain) {
|
||||||
|
throw new QrRequestError("Diese Domain gibt es nicht.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ report: await pruefeDomain(domain.hostname) });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import { z } from "zod";
|
|||||||
import { getActor } from "@/lib/auth/actor";
|
import { getActor } from "@/lib/auth/actor";
|
||||||
import { requirePermission } from "@/lib/auth/policy";
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
getQrTypeAvailability,
|
getQrTypeAvailability,
|
||||||
setQrTypeEnabled,
|
setQrTypeEnabled,
|
||||||
|
setQrTypeEnabledForGroup,
|
||||||
} from "@/lib/qr/availability";
|
} from "@/lib/qr/availability";
|
||||||
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
|
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
@@ -15,23 +17,45 @@ export const dynamic = "force-dynamic";
|
|||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
|
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
|
||||||
enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }),
|
enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }),
|
||||||
|
/**
|
||||||
|
* Für welche Gruppe der Schalter gilt. Ohne Angabe die Grundeinstellung
|
||||||
|
* für die ganze Installation.
|
||||||
|
*/
|
||||||
|
groupId: z.string().trim().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Zustand aller QR-Typen für die Verwaltung. */
|
/**
|
||||||
export async function GET(): Promise<Response> {
|
* Zustand aller QR-Typen für die Verwaltung.
|
||||||
|
*
|
||||||
|
* Mit `?group=<id>` die Sicht einer einzelnen Gruppe, sonst die
|
||||||
|
* Grundeinstellung.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
requirePermission(await getActor(), "instance:manage");
|
requirePermission(await getActor(), "instance:manage");
|
||||||
const types = await getQrTypeAvailability();
|
|
||||||
|
const groupId = request.nextUrl.searchParams.get("group") ?? undefined;
|
||||||
|
if (groupId !== undefined) {
|
||||||
|
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
|
||||||
|
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = await getQrTypeAvailability(groupId);
|
||||||
return Response.json({
|
return Response.json({
|
||||||
types: types.map(({ qrType, implemented, enabled, usable }) => ({
|
groupId: groupId ?? null,
|
||||||
|
types: types.map(
|
||||||
|
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup, usable }) => ({
|
||||||
id: qrType.id,
|
id: qrType.id,
|
||||||
title: qrType.title,
|
title: qrType.title,
|
||||||
description: qrType.description,
|
description: qrType.description,
|
||||||
icon: qrType.icon,
|
icon: qrType.icon,
|
||||||
implemented,
|
implemented,
|
||||||
enabled,
|
enabled,
|
||||||
|
enabledGlobally,
|
||||||
|
disabledInGroup,
|
||||||
usable,
|
usable,
|
||||||
})),
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
@@ -48,8 +72,17 @@ export async function PATCH(request: NextRequest): Promise<Response> {
|
|||||||
return jsonError(firstZodMessage(parsed.error));
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
}
|
}
|
||||||
|
|
||||||
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled);
|
const { type, enabled, groupId } = parsed.data;
|
||||||
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
|
|
||||||
|
if (groupId === undefined) {
|
||||||
|
await setQrTypeEnabled(type, enabled);
|
||||||
|
} else {
|
||||||
|
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
|
||||||
|
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
|
||||||
|
await setQrTypeEnabledForGroup(type, groupId, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ type, enabled, groupId: groupId ?? null });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { getActor } from "@/lib/auth/actor";
|
|||||||
import { requirePermission } from "@/lib/auth/policy";
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { shortUrl } from "@/lib/env";
|
import { shortUrl } from "@/lib/env";
|
||||||
|
import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||||
import { errorResponse } from "@/lib/http";
|
import { errorResponse } from "@/lib/http";
|
||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
import { getQrType } from "@/lib/qr/registry";
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
|
import { ladeLogo } from "@/lib/qr/logo";
|
||||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||||
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
|
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
|
||||||
|
|
||||||
@@ -47,13 +49,22 @@ export async function GET(
|
|||||||
size: code.size,
|
size: code.size,
|
||||||
ecLevel: code.ecLevel,
|
ecLevel: code.ecLevel,
|
||||||
margin: code.margin,
|
margin: code.margin,
|
||||||
|
logoSource: code.logoSource,
|
||||||
|
logoUploadId: code.logoUploadId,
|
||||||
|
logoScale: code.logoScale,
|
||||||
});
|
});
|
||||||
|
const logo = await ladeLogo(design);
|
||||||
|
|
||||||
const content = shortUrl(code.slug, code.domain.hostname);
|
// Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
|
||||||
|
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
|
||||||
|
const content =
|
||||||
|
code.mode === "static" || !code.slug
|
||||||
|
? requireImplementedQrType(code.type).encode(code.data)
|
||||||
|
: shortUrl(code.slug, code.domain.hostname);
|
||||||
const body =
|
const body =
|
||||||
format === "png"
|
format === "png"
|
||||||
? new Uint8Array(await renderPng(content, design))
|
? new Uint8Array(await renderPng(content, design, logo))
|
||||||
: await renderSvg(content, design);
|
: await renderSvg(content, design, logo);
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": CONTENT_TYPES[format],
|
"Content-Type": CONTENT_TYPES[format],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { assertDynamicPossible } from "@/lib/qr/request";
|
|||||||
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
|
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
|
||||||
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import { attachUploads } from "@/lib/uploads";
|
import { attachUpload, attachUploads, detachUpload } from "@/lib/uploads";
|
||||||
import { QrRequestError } from "@/lib/qr/type-module";
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -96,6 +96,15 @@ export async function PATCH(
|
|||||||
/** Dateien, die nach der Änderung zu diesem Code gehören. */
|
/** Dateien, die nach der Änderung zu diesem Code gehören. */
|
||||||
let neueDateien: string[] | null = null;
|
let neueDateien: string[] | null = null;
|
||||||
|
|
||||||
|
// Der Inhalt eines statischen Codes steckt im gedruckten Bild. Ihn hier zu
|
||||||
|
// ändern würde die Ablage von dem entfernen, was tatsächlich im Umlauf
|
||||||
|
// ist — der Eintrag ist ein Schnappschuss, kein steuerbares Ziel.
|
||||||
|
if (parsed.data.data !== undefined && existing.mode === "static") {
|
||||||
|
return jsonError(
|
||||||
|
"Der Inhalt eines statischen Codes lässt sich nicht ändern — er steckt im gedruckten Code selbst. Änderbar ist nur der Name.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.data.data !== undefined) {
|
if (parsed.data.data !== undefined) {
|
||||||
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
|
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
|
||||||
// Verwaltung abgeschalteter Typ soll bestehende Codes nicht
|
// Verwaltung abgeschalteter Typ soll bestehende Codes nicht
|
||||||
@@ -124,11 +133,31 @@ export async function PATCH(
|
|||||||
Object.assign(update, parsed.data.design);
|
Object.assign(update, parsed.data.design);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein eigenes Logo gehört zur Gestaltung, nicht zum Inhalt. Es muss
|
||||||
|
// trotzdem an den Code gehängt werden — sonst liegt die Datei herrenlos
|
||||||
|
// herum und wird nach einem Tag weggeräumt, mitsamt dem Logo im Code.
|
||||||
|
const logoVorher =
|
||||||
|
existing.logoSource === "upload" ? existing.logoUploadId : null;
|
||||||
|
const logoNachher =
|
||||||
|
parsed.data.design === undefined
|
||||||
|
? logoVorher
|
||||||
|
: parsed.data.design.logoSource === "upload"
|
||||||
|
? parsed.data.design.logoUploadId
|
||||||
|
: null;
|
||||||
|
|
||||||
/* ------------------------------------------------------- Kurz-Link */
|
/* ------------------------------------------------------- Kurz-Link */
|
||||||
|
|
||||||
const neuerSlug = parsed.data.slug?.trim();
|
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 =
|
const umbenannt =
|
||||||
neuerSlug !== undefined &&
|
neuerSlug !== undefined &&
|
||||||
|
existing.slug !== null &&
|
||||||
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
|
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,7 +220,8 @@ export async function PATCH(
|
|||||||
await tx.qrCodeAlias.create({
|
await tx.qrCodeAlias.create({
|
||||||
data: {
|
data: {
|
||||||
domainId: existing.domainId,
|
domainId: existing.domainId,
|
||||||
slug: existing.slug,
|
// `umbenannt` gilt nur bei vorhandenem Kurz-Link.
|
||||||
|
slug: existing.slug as string,
|
||||||
qrCodeId: existing.id,
|
qrCodeId: existing.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -207,7 +237,18 @@ export async function PATCH(
|
|||||||
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
|
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
|
||||||
// später als verwaist weggeräumt.
|
// später als verwaist weggeräumt.
|
||||||
if (neueDateien !== null) {
|
if (neueDateien !== null) {
|
||||||
await attachUploads(updated.id, neueDateien);
|
await attachUploads(
|
||||||
|
updated.id,
|
||||||
|
logoNachher ? [...neueDateien, logoNachher] : neueDateien,
|
||||||
|
);
|
||||||
|
} else if (logoNachher && logoNachher !== logoVorher) {
|
||||||
|
await attachUpload(updated.id, logoNachher);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein abgelegtes oder ausgetauschtes Logo verliert seinen Bezug und wird
|
||||||
|
// später weggeräumt.
|
||||||
|
if (logoVorher && logoVorher !== logoNachher) {
|
||||||
|
await detachUpload(updated.id, logoVorher);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ code: toQrCodeDto(updated) });
|
return Response.json({ code: toQrCodeDto(updated) });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
|||||||
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { resolveTargetDomain } from "@/lib/domains";
|
import { resolveTargetDomain } from "@/lib/domains";
|
||||||
|
import { findStaticDuplicate } from "@/lib/duplicates";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
import { requireAvailableQrType } from "@/lib/qr/availability";
|
import { requireAvailableQrType } from "@/lib/qr/availability";
|
||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
@@ -28,6 +29,12 @@ const createSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||||
.optional(),
|
.optional(),
|
||||||
|
/**
|
||||||
|
* Statisch oder dynamisch. Ein statischer Code wird nur abgelegt, damit man
|
||||||
|
* ihn wiederfindet — er bekommt keinen Kurz-Link und zählt keine Scans.
|
||||||
|
*/
|
||||||
|
mode: z.enum(["static", "dynamic"], { error: "Ungültiger Modus." })
|
||||||
|
.default("dynamic"),
|
||||||
/**
|
/**
|
||||||
* Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt.
|
* Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt.
|
||||||
* Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge.
|
* Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge.
|
||||||
@@ -37,8 +44,32 @@ const createSchema = z.object({
|
|||||||
groupId: z.string().trim().min(1).optional(),
|
groupId: z.string().trim().min(1).optional(),
|
||||||
/** Unter welcher Domain die Kurz-URL entsteht. */
|
/** Unter welcher Domain die Kurz-URL entsteht. */
|
||||||
domainId: z.string().trim().min(1).optional(),
|
domainId: z.string().trim().min(1).optional(),
|
||||||
|
/**
|
||||||
|
* Eine gemeldete Dublette bewusst in Kauf nehmen. Ohne dieses Häkchen
|
||||||
|
* antwortet das Anlegen mit 409, sobald derselbe Inhalt schon als
|
||||||
|
* statischer Code in der Gruppe liegt.
|
||||||
|
*/
|
||||||
|
allowDuplicate: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Dateien, die zu einem Code gehören.
|
||||||
|
*
|
||||||
|
* Das sind die des Typ-Moduls — ein PDF, ein Bild — **und** ein eigenes Logo
|
||||||
|
* aus der Gestaltung. Wird das vergessen, löst `attachUploads` das Logo beim
|
||||||
|
* nächsten Speichern wieder vom Code und räumt es später als herrenlos weg.
|
||||||
|
*/
|
||||||
|
function dateienDesCodes(
|
||||||
|
qrType: { uploadIds: (data: never) => string[] },
|
||||||
|
data: unknown,
|
||||||
|
design: { logoSource: string | null; logoUploadId: string | null },
|
||||||
|
): string[] {
|
||||||
|
const ids = qrType.uploadIds(data as never);
|
||||||
|
return design.logoSource === "upload" && design.logoUploadId
|
||||||
|
? [...ids, design.logoUploadId]
|
||||||
|
: ids;
|
||||||
|
}
|
||||||
|
|
||||||
function isUniqueViolation(error: unknown): boolean {
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
typeof error === "object" &&
|
typeof error === "object" &&
|
||||||
@@ -94,15 +125,33 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
// ist — und der Slug ist nur je Domain eindeutig.
|
// ist — und der Slug ist nur je Domain eindeutig.
|
||||||
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId);
|
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId);
|
||||||
|
|
||||||
const qrType = await requireAvailableQrType(type);
|
// Die Freigabe haengt an der Zielgruppe — global abgeschaltet gewinnt.
|
||||||
|
const qrType = await requireAvailableQrType(type, groupId);
|
||||||
|
|
||||||
const validated = qrType.validate(parsed.data.data);
|
const validated = qrType.validate(parsed.data.data);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return jsonError(validated.message);
|
return jsonError(validated.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
|
||||||
assertDynamicPossible(qrType, validated.data);
|
assertDynamicPossible(qrType, validated.data);
|
||||||
|
}
|
||||||
|
|
||||||
// Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht —
|
// 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.
|
// sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte.
|
||||||
@@ -117,6 +166,48 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
await assertSlugAvailable(domainId, gewuenscht);
|
await assertSlugAvailable(domainId, gewuenscht);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zweimal derselbe statische Inhalt ergibt zweimal dasselbe Bild. Das ist
|
||||||
|
// erlaubt, aber selten gewollt — deshalb ein Halt mit Rückfrage statt
|
||||||
|
// eines stillen zweiten Eintrags.
|
||||||
|
if (istStatisch && !parsed.data.allowDuplicate) {
|
||||||
|
const dublette = await findStaticDuplicate(groupId, qrType.id, validated.data);
|
||||||
|
if (dublette) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: dublette.label
|
||||||
|
? `Diesen Inhalt hast du schon abgelegt — als „${dublette.label}“.`
|
||||||
|
: "Diesen Inhalt hast du schon abgelegt.",
|
||||||
|
duplicate: dublette,
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (istStatisch) {
|
||||||
|
const created = await prisma.qrCode.create({
|
||||||
|
data: {
|
||||||
|
mode: "static",
|
||||||
|
slug: null,
|
||||||
|
type: qrType.id,
|
||||||
|
label: label && label.length > 0 ? label : null,
|
||||||
|
data: toJsonInput(validated.data),
|
||||||
|
groupId,
|
||||||
|
domainId,
|
||||||
|
createdById: actor.kind === "user" ? actor.user.id : null,
|
||||||
|
...design,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
group: { select: { name: true } },
|
||||||
|
domain: { select: { hostname: true } },
|
||||||
|
createdBy: { select: { name: true, email: true } },
|
||||||
|
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
|
||||||
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
@@ -140,7 +231,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
});
|
});
|
||||||
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
||||||
// vorher lagen sie ohne Bezug herum.
|
// vorher lagen sie ohne Bezug herum.
|
||||||
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
|
||||||
|
|
||||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { bildeBeweis, getCheckSecret } from "@/lib/domain-check";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beweis-Route für den Domain-Check.
|
||||||
|
*
|
||||||
|
* Bewusst **ohne Anmeldung**: Der Aufruf kommt von außen über die zu prüfende
|
||||||
|
* Domain und trägt keine Sitzung mit sich. Preisgegeben wird dabei nichts —
|
||||||
|
* die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Wer das
|
||||||
|
* Geheimnis nicht kennt, kann ihn nicht bilden; wer ihn sieht, lernt daraus
|
||||||
|
* nichts über das Geheimnis.
|
||||||
|
*
|
||||||
|
* Dass es diese Anwendung gibt, verrät ohnehin schon jede Landingpage.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest): Promise<Response> {
|
||||||
|
const nonce = request.nextUrl.searchParams.get("nonce") ?? "";
|
||||||
|
|
||||||
|
// Eine Grenze nach oben, damit die Route nicht als Rechenknecht für fremde
|
||||||
|
// Daten taugt, und eine nach unten, damit sie nicht auf Zuruf antwortet.
|
||||||
|
if (!/^[a-f0-9]{16,64}$/.test(nonce)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Es fehlt eine gültige Zufallszahl." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proof = bildeBeweis(await getCheckSecret(), nonce);
|
||||||
|
return Response.json(
|
||||||
|
{ app: "qrcode-generator", proof },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Domain-Check konnte keinen Beweis bilden:", error);
|
||||||
|
return Response.json({ error: "Nicht möglich." }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { ladeLogo } from "@/lib/qr/logo";
|
||||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||||
import {
|
import {
|
||||||
downloadFilename,
|
downloadFilename,
|
||||||
@@ -38,15 +39,16 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
|
|
||||||
const { qrType, data, content } = await resolveQrRequest(parsed.data);
|
const { qrType, data, content } = await resolveQrRequest(parsed.data);
|
||||||
|
|
||||||
|
const logo = await ladeLogo(design);
|
||||||
const body =
|
const body =
|
||||||
format === "png"
|
format === "png"
|
||||||
? new Uint8Array(await renderPng(content, design))
|
? new Uint8Array(await renderPng(content, design, logo))
|
||||||
: await renderSvg(content, design);
|
: await renderSvg(content, design, logo);
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": CONTENT_TYPES[format],
|
"Content-Type": CONTENT_TYPES[format],
|
||||||
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format)}"`,
|
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format, parsed.data.label)}"`,
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
|
|||||||
|
|
||||||
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { ladeLogo } from "@/lib/qr/logo";
|
||||||
import { renderSvg } from "@/lib/qr/render";
|
import { renderSvg } from "@/lib/qr/render";
|
||||||
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { content } = await resolveQrRequest(parsed.data);
|
const { content } = await resolveQrRequest(parsed.data);
|
||||||
const svg = await renderSvg(content, parsed.data.design);
|
const logo = await ladeLogo(parsed.data.design);
|
||||||
|
const svg = await renderSvg(content, parsed.data.design, logo);
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -91,6 +91,17 @@ export default async function LandingPage({ params }: PageProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gefunden wird ein Code nur ueber seinen Kurz-Link; statische haben keinen
|
||||||
|
// und koennen hier deshalb gar nicht ankommen. Der Zweig ist reine Vorsorge.
|
||||||
|
if (!code.slug) {
|
||||||
|
return (
|
||||||
|
<LandingNotice title="Diese Seite gibt es nicht">
|
||||||
|
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
|
||||||
|
der ihn erstellt hat.
|
||||||
|
</LandingNotice>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Landing
|
<Landing
|
||||||
data={code.data}
|
data={code.data}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ function formatShortDate(value: string | null): string {
|
|||||||
|
|
||||||
type View = "list" | "cards";
|
type View = "list" | "cards";
|
||||||
type SortKey = "neueste" | "aelteste" | "name" | "scans";
|
type SortKey = "neueste" | "aelteste" | "name" | "scans";
|
||||||
|
/** Betriebsart, auf die die Liste eingeschränkt ist. */
|
||||||
|
type ModeFilter = "alle" | "dynamic" | "static";
|
||||||
|
|
||||||
const SORT_LABELS: Record<SortKey, string> = {
|
const SORT_LABELS: Record<SortKey, string> = {
|
||||||
neueste: "Neueste zuerst",
|
neueste: "Neueste zuerst",
|
||||||
@@ -51,6 +53,12 @@ const SORT_LABELS: Record<SortKey, string> = {
|
|||||||
scans: "Meiste Scans",
|
scans: "Meiste Scans",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<ModeFilter, string> = {
|
||||||
|
alle: "Alle Arten",
|
||||||
|
dynamic: "Nur dynamische",
|
||||||
|
static: "Nur statische",
|
||||||
|
};
|
||||||
|
|
||||||
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
|
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
|
||||||
const VIEW_STORAGE_KEY = "qrgen.codes.view";
|
const VIEW_STORAGE_KEY = "qrgen.codes.view";
|
||||||
|
|
||||||
@@ -94,6 +102,7 @@ export function CodesList({
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [sort, setSort] = useState<SortKey>("neueste");
|
const [sort, setSort] = useState<SortKey>("neueste");
|
||||||
const [groupFilter, setGroupFilter] = useState("alle");
|
const [groupFilter, setGroupFilter] = useState("alle");
|
||||||
|
const [modeFilter, setModeFilter] = useState<ModeFilter>("alle");
|
||||||
|
|
||||||
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
|
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
|
||||||
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
|
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
|
||||||
@@ -107,6 +116,14 @@ export function CodesList({
|
|||||||
window.localStorage.setItem(VIEW_STORAGE_KEY, next);
|
window.localStorage.setItem(VIEW_STORAGE_KEY, next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeModeFilter = (next: ModeFilter) => {
|
||||||
|
setModeFilter(next);
|
||||||
|
// Statische Codes zählen keine Scans. Bleibt die Sortierung danach
|
||||||
|
// stehen, sortiert die Liste nach lauter Nullen — und die Auswahl bietet
|
||||||
|
// eine Reihenfolge an, die es dort gar nicht gibt.
|
||||||
|
if (next === "static" && sort === "scans") setSort("neueste");
|
||||||
|
};
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
|
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
|
||||||
@@ -133,7 +150,7 @@ export function CodesList({
|
|||||||
setEditing(code);
|
setEditing(code);
|
||||||
setEditData(code.data);
|
setEditData(code.data);
|
||||||
setEditLabel(code.label ?? "");
|
setEditLabel(code.label ?? "");
|
||||||
setEditSlug(code.slug);
|
setEditSlug(code.slug ?? "");
|
||||||
setEditAliases(code.aliases);
|
setEditAliases(code.aliases);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
};
|
};
|
||||||
@@ -141,7 +158,9 @@ export function CodesList({
|
|||||||
const slugProblem =
|
const slugProblem =
|
||||||
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
|
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
|
||||||
const slugGeaendert =
|
const slugGeaendert =
|
||||||
editing !== null && editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
|
editing !== null &&
|
||||||
|
editing.slug !== null &&
|
||||||
|
editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
|
||||||
const freigegeben = editing
|
const freigegeben = editing
|
||||||
? editing.aliases.filter((alias) => !editAliases.includes(alias))
|
? editing.aliases.filter((alias) => !editAliases.includes(alias))
|
||||||
: [];
|
: [];
|
||||||
@@ -168,12 +187,18 @@ export function CodesList({
|
|||||||
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
json: {
|
json: {
|
||||||
data: editValidation.data,
|
|
||||||
label: editLabel.trim() || null,
|
label: editLabel.trim() || null,
|
||||||
// Nur mitschicken, wenn es überhaupt bearbeitet werden durfte.
|
// 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
|
...(maySetSlug
|
||||||
? { slug: editSlug.trim(), aliases: editAliases }
|
? { slug: editSlug.trim(), aliases: editAliases }
|
||||||
: {}),
|
: {}),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -209,6 +234,7 @@ export function CodesList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copy = async (code: QrCodeDto) => {
|
const copy = async (code: QrCodeDto) => {
|
||||||
|
if (!code.shortUrl) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code.shortUrl);
|
await navigator.clipboard.writeText(code.shortUrl);
|
||||||
setCopiedId(code.id);
|
setCopiedId(code.id);
|
||||||
@@ -232,11 +258,25 @@ export function CodesList({
|
|||||||
);
|
);
|
||||||
}, [codes]);
|
}, [codes]);
|
||||||
|
|
||||||
|
// Welche Betriebsarten überhaupt vorkommen. Ein Filter, der nichts zu
|
||||||
|
// filtern hat, steht nur im Weg — deshalb erscheint er erst, wenn beide
|
||||||
|
// Arten in der Liste stehen.
|
||||||
|
const kinds = useMemo(() => {
|
||||||
|
let dynamisch = false;
|
||||||
|
let statisch = false;
|
||||||
|
for (const code of codes ?? []) {
|
||||||
|
if (code.mode === "static") statisch = true;
|
||||||
|
else dynamisch = true;
|
||||||
|
}
|
||||||
|
return { dynamisch, statisch, beide: dynamisch && statisch };
|
||||||
|
}, [codes]);
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
|
|
||||||
const filtered = (codes ?? []).filter((code) => {
|
const filtered = (codes ?? []).filter((code) => {
|
||||||
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
|
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
|
||||||
|
if (modeFilter !== "alle" && code.mode !== modeFilter) return false;
|
||||||
if (!needle) return true;
|
if (!needle) return true;
|
||||||
return [
|
return [
|
||||||
displayName(code),
|
displayName(code),
|
||||||
@@ -264,7 +304,7 @@ export function CodesList({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return sorted;
|
return sorted;
|
||||||
}, [codes, query, sort, groupFilter]);
|
}, [codes, query, sort, groupFilter, modeFilter]);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- View */
|
/* ---------------------------------------------------------------- View */
|
||||||
|
|
||||||
@@ -278,6 +318,10 @@ export function CodesList({
|
|||||||
const svgHref = (code: QrCodeDto) =>
|
const svgHref = (code: QrCodeDto) =>
|
||||||
`/api/codes/${code.id}/qr?format=svg&download=1`;
|
`/api/codes/${code.id}/qr?format=svg&download=1`;
|
||||||
|
|
||||||
|
// Zeigt die Liste ausschließlich statische Codes, hat die Scan-Spalte
|
||||||
|
// nichts zu sagen — dann fällt sie weg, samt der Sortierung danach.
|
||||||
|
const nurStatische = modeFilter === "static" || (kinds.statisch && !kinds.dynamisch);
|
||||||
|
|
||||||
const toolbar = codes.length > 0 && (
|
const toolbar = codes.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="relative min-w-0 flex-1 basis-56">
|
<div className="relative min-w-0 flex-1 basis-56">
|
||||||
@@ -311,13 +355,32 @@ export function CodesList({
|
|||||||
</Select>
|
</Select>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{kinds.beide ? (
|
||||||
|
<Select
|
||||||
|
aria-label="Nach Art filtern"
|
||||||
|
className="!w-auto"
|
||||||
|
value={modeFilter}
|
||||||
|
onChange={(event) =>
|
||||||
|
changeModeFilter(event.target.value as ModeFilter)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(Object.keys(MODE_LABELS) as ModeFilter[]).map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{MODE_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
aria-label="Sortierung"
|
aria-label="Sortierung"
|
||||||
className="!w-auto"
|
className="!w-auto"
|
||||||
value={sort}
|
value={sort}
|
||||||
onChange={(event) => setSort(event.target.value as SortKey)}
|
onChange={(event) => setSort(event.target.value as SortKey)}
|
||||||
>
|
>
|
||||||
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => (
|
{(Object.keys(SORT_LABELS) as SortKey[])
|
||||||
|
.filter((key) => key !== "scans" || !nurStatische)
|
||||||
|
.map((key) => (
|
||||||
<option key={key} value={key}>
|
<option key={key} value={key}>
|
||||||
{SORT_LABELS[key]}
|
{SORT_LABELS[key]}
|
||||||
</option>
|
</option>
|
||||||
@@ -397,12 +460,13 @@ export function CodesList({
|
|||||||
{codes.length > 0 && visible.length === 0 ? (
|
{codes.length > 0 && visible.length === 0 ? (
|
||||||
<Card className="text-center">
|
<Card className="text-center">
|
||||||
<p className="text-sm text-slate-600">
|
<p className="text-sm text-slate-600">
|
||||||
Kein Code passt zur Suche.{" "}
|
Kein Code passt zu Suche und Filtern.{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setGroupFilter("alle");
|
setGroupFilter("alle");
|
||||||
|
setModeFilter("alle");
|
||||||
}}
|
}}
|
||||||
className="font-medium text-accent-text underline"
|
className="font-medium text-accent-text underline"
|
||||||
>
|
>
|
||||||
@@ -437,9 +501,11 @@ export function CodesList({
|
|||||||
Gruppe
|
Gruppe
|
||||||
</th>
|
</th>
|
||||||
) : null}
|
) : null}
|
||||||
|
{nurStatische ? null : (
|
||||||
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
||||||
Scans
|
Scans
|
||||||
</th>
|
</th>
|
||||||
|
)}
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
|
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
|
||||||
@@ -479,9 +545,18 @@ export function CodesList({
|
|||||||
<div className="truncate text-xs text-slate-500">
|
<div className="truncate text-xs text-slate-500">
|
||||||
{/* Nur der Pfad: Der Hostname ist auf dem Handy
|
{/* Nur der Pfad: Der Hostname ist auf dem Handy
|
||||||
gleich für alle Zeilen, der Slug unterscheidet. */}
|
gleich für alle Zeilen, der Slug unterscheidet. */}
|
||||||
<span className="font-mono md:hidden" title={code.shortUrl}>
|
{code.shortUrl ? (
|
||||||
|
<span
|
||||||
|
className="font-mono md:hidden"
|
||||||
|
title={code.shortUrl}
|
||||||
|
>
|
||||||
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="md:hidden">
|
||||||
|
{qrTypes[code.type]?.title ?? code.type} · statisch
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="hidden md:inline">
|
<span className="hidden md:inline">
|
||||||
{qrTypes[code.type]?.title ?? code.type}
|
{qrTypes[code.type]?.title ?? code.type}
|
||||||
</span>
|
</span>
|
||||||
@@ -491,6 +566,7 @@ export function CodesList({
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="hidden px-3 py-2.5 md:table-cell">
|
<td className="hidden px-3 py-2.5 md:table-cell">
|
||||||
|
{code.shortUrl ? (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<a
|
<a
|
||||||
href={code.shortUrl}
|
href={code.shortUrl}
|
||||||
@@ -513,6 +589,11 @@ export function CodesList({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
// Ein statischer Code hat keine Kurz-URL — sein Inhalt
|
||||||
|
// steckt in ihm selbst.
|
||||||
|
<Badge tone="neutral">statisch</Badge>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="hidden px-3 py-2.5 sm:table-cell">
|
<td className="hidden px-3 py-2.5 sm:table-cell">
|
||||||
@@ -527,7 +608,13 @@ export function CodesList({
|
|||||||
{code.target.replace(/^https?:\/\//, "")}
|
{code.target.replace(/^https?:\/\//, "")}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="block truncate text-red-600" title={code.summary}>
|
<span
|
||||||
|
className={cx(
|
||||||
|
"block truncate",
|
||||||
|
code.mode === "static" ? "text-slate-600" : "text-red-600",
|
||||||
|
)}
|
||||||
|
title={code.summary}
|
||||||
|
>
|
||||||
{code.summary}
|
{code.summary}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -541,9 +628,20 @@ export function CodesList({
|
|||||||
</td>
|
</td>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{nurStatische ? null : (
|
||||||
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<td
|
<td
|
||||||
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
|
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
|
||||||
@@ -640,6 +738,7 @@ export function CodesList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="mt-3 space-y-2 text-sm">
|
<dl className="mt-3 space-y-2 text-sm">
|
||||||
|
{code.shortUrl ? (
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||||
<dt className="text-slate-500">Kurz-URL:</dt>
|
<dt className="text-slate-500">Kurz-URL:</dt>
|
||||||
<dd className="flex items-center gap-2">
|
<dd className="flex items-center gap-2">
|
||||||
@@ -664,6 +763,16 @@ export function CodesList({
|
|||||||
</button>
|
</button>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
// Statisch: Der Inhalt steckt im Code selbst, es gibt keine
|
||||||
|
// Kurz-URL und nichts, was sich nachträglich umbiegen liesse.
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||||
|
<dt className="text-slate-500">Art:</dt>
|
||||||
|
<dd className="text-slate-800">
|
||||||
|
statisch — Inhalt steckt im Code, nicht änderbar
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||||
<dt className="text-slate-500">Ziel:</dt>
|
<dt className="text-slate-500">Ziel:</dt>
|
||||||
@@ -772,11 +881,12 @@ export function CodesList({
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-slate-600">
|
<p className="text-sm text-slate-600">
|
||||||
Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt
|
{editing.mode === "static"
|
||||||
gültig, solange der Kurz-Link derselbe ist.
|
? "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>
|
</p>
|
||||||
|
|
||||||
{EditForm ? (
|
{editing.mode === "static" ? null : EditForm ? (
|
||||||
<EditForm
|
<EditForm
|
||||||
value={editData}
|
value={editData}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -792,7 +902,7 @@ export function CodesList({
|
|||||||
</Notice>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{maySetSlug ? (
|
{maySetSlug && editing.mode !== "static" ? (
|
||||||
<>
|
<>
|
||||||
<Field
|
<Field
|
||||||
label="Kurz-Link"
|
label="Kurz-Link"
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
|
import { FileField } from "@/components/qr-forms/file-field";
|
||||||
import { Field, Notice, TextInput, cx } from "@/components/ui";
|
import { Field, Notice, TextInput, cx } from "@/components/ui";
|
||||||
import { evaluateContrast } from "@/lib/qr/contrast";
|
import { evaluateContrast } from "@/lib/qr/contrast";
|
||||||
import {
|
import {
|
||||||
COLOR_PRESETS,
|
COLOR_PRESETS,
|
||||||
EC_LEVELS,
|
EC_LEVELS,
|
||||||
EC_LEVEL_LABELS,
|
EC_LEVEL_LABELS,
|
||||||
|
LOGO_SCALE_WARN,
|
||||||
|
MAX_LOGO_SCALE,
|
||||||
MAX_SIZE,
|
MAX_SIZE,
|
||||||
|
MIN_LOGO_SCALE,
|
||||||
MIN_SIZE,
|
MIN_SIZE,
|
||||||
type EcLevel,
|
type EcLevel,
|
||||||
|
type LogoSource,
|
||||||
type QrDesign,
|
type QrDesign,
|
||||||
} from "@/lib/qr/design";
|
} from "@/lib/qr/design";
|
||||||
|
|
||||||
@@ -62,9 +67,12 @@ function ColorField({
|
|||||||
export function DesignPanel({
|
export function DesignPanel({
|
||||||
design,
|
design,
|
||||||
onChange,
|
onChange,
|
||||||
|
/** Ist unter „Erscheinungsbild“ ein Logo hinterlegt? Sonst entfällt die Wahl. */
|
||||||
|
hasBrandingLogo = false,
|
||||||
}: {
|
}: {
|
||||||
design: QrDesign;
|
design: QrDesign;
|
||||||
onChange: (design: QrDesign) => void;
|
onChange: (design: QrDesign) => void;
|
||||||
|
hasBrandingLogo?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
|
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
|
||||||
const contrast = evaluateContrast(design.fgColor, design.bgColor);
|
const contrast = evaluateContrast(design.fgColor, design.bgColor);
|
||||||
@@ -165,7 +173,11 @@ export function DesignPanel({
|
|||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Fehlerkorrektur"
|
label="Fehlerkorrektur"
|
||||||
hint={EC_LEVEL_LABELS[design.ecLevel]}
|
hint={
|
||||||
|
design.logoSource === null
|
||||||
|
? EC_LEVEL_LABELS[design.ecLevel]
|
||||||
|
: "Mit Logo gilt H — was das Bild verdeckt, muss die Fehlerkorrektur ausgleichen."
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
@@ -177,11 +189,14 @@ export function DesignPanel({
|
|||||||
key={level}
|
key={level}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={design.ecLevel === level}
|
aria-checked={
|
||||||
|
design.logoSource === null ? design.ecLevel === level : level === "H"
|
||||||
|
}
|
||||||
|
disabled={design.logoSource !== null}
|
||||||
onClick={() => patch({ ecLevel: level })}
|
onClick={() => patch({ ecLevel: level })}
|
||||||
className={cx(
|
className={cx(
|
||||||
"rounded-lg border py-2 text-sm font-medium transition-colors",
|
"rounded-lg border py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
design.ecLevel === level
|
(design.logoSource === null ? design.ecLevel : "H") === level
|
||||||
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
|
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
|
||||||
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
|
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
|
||||||
)}
|
)}
|
||||||
@@ -208,6 +223,147 @@ export function DesignPanel({
|
|||||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
|
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<LogoAuswahl
|
||||||
|
design={design}
|
||||||
|
patch={patch}
|
||||||
|
hasBrandingLogo={hasBrandingLogo}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------- Logo */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Bild in der Mitte des Codes.
|
||||||
|
*
|
||||||
|
* Bewusst ein eigener Abschnitt und nicht bloß ein Feld: Ein Logo zieht drei
|
||||||
|
* Entscheidungen nach sich — woher es kommt, wie groß es wird, und dass die
|
||||||
|
* Fehlerkorrektur dafür hochgesetzt wird. Das gehört sichtbar beieinander.
|
||||||
|
*/
|
||||||
|
function LogoAuswahl({
|
||||||
|
design,
|
||||||
|
patch,
|
||||||
|
hasBrandingLogo,
|
||||||
|
}: {
|
||||||
|
design: QrDesign;
|
||||||
|
patch: (changes: Partial<QrDesign>) => void;
|
||||||
|
hasBrandingLogo: boolean;
|
||||||
|
}) {
|
||||||
|
const an = design.logoSource !== null;
|
||||||
|
|
||||||
|
const waehle = (quelle: LogoSource | null) => {
|
||||||
|
if (quelle === null) {
|
||||||
|
patch({ logoSource: null, logoUploadId: null });
|
||||||
|
} else if (quelle === "branding") {
|
||||||
|
patch({ logoSource: "branding", logoUploadId: null });
|
||||||
|
} else {
|
||||||
|
patch({ logoSource: "upload" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="border-t border-slate-200 pt-4">
|
||||||
|
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
||||||
|
Logo in der Mitte
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<label className="mt-3 flex cursor-pointer items-start gap-2.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={an}
|
||||||
|
onChange={(event) =>
|
||||||
|
waehle(
|
||||||
|
event.target.checked ? (hasBrandingLogo ? "branding" : "upload") : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="mt-0.5 size-4 shrink-0 rounded border-slate-300 accent-indigo-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700">
|
||||||
|
Ein Logo in die Mitte des Codes setzen
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{an ? (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{hasBrandingLogo ? (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Herkunft des Logos"
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["branding", "Logo dieser Installation"],
|
||||||
|
["upload", "Eigenes Logo hochladen"],
|
||||||
|
] as [LogoSource, string][]
|
||||||
|
).map(([quelle, text]) => (
|
||||||
|
<label
|
||||||
|
key={quelle}
|
||||||
|
className="flex cursor-pointer items-center gap-2.5"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="logo-quelle"
|
||||||
|
checked={design.logoSource === quelle}
|
||||||
|
onChange={() => waehle(quelle)}
|
||||||
|
className="size-4 shrink-0 accent-indigo-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700">{text}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{design.logoSource === "upload" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FileField
|
||||||
|
kind="qrlogo"
|
||||||
|
label={design.logoUploadId ? "Anderes Logo wählen" : "Logo wählen"}
|
||||||
|
onUploaded={(datei) => patch({ logoUploadId: datei.fileId })}
|
||||||
|
/>
|
||||||
|
{design.logoUploadId ? null : (
|
||||||
|
<p className="text-sm text-amber-700">
|
||||||
|
Noch keine Datei gewählt — bis dahin bleibt die Mitte frei.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={`Größe: ${design.logoScale} % der Kantenlänge`}
|
||||||
|
htmlFor="qr-logo-scale"
|
||||||
|
hint="Bezogen auf die Breite des Codes. Bei 20 % sind rund 4 % der Fläche verdeckt."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="qr-logo-scale"
|
||||||
|
type="range"
|
||||||
|
min={MIN_LOGO_SCALE}
|
||||||
|
max={MAX_LOGO_SCALE}
|
||||||
|
step={1}
|
||||||
|
value={design.logoScale}
|
||||||
|
onChange={(event) => patch({ logoScale: Number(event.target.value) })}
|
||||||
|
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{design.logoScale > LOGO_SCALE_WARN ? (
|
||||||
|
<Notice tone="warning">
|
||||||
|
Ab {LOGO_SCALE_WARN} % wird es knapp. Die Fehlerkorrektur trägt das
|
||||||
|
zwar, aber manche Scanner-Apps suchen in der Mitte nach
|
||||||
|
Ausrichtungsmustern. Prüfe den fertigen Code mit mehr als einer App,
|
||||||
|
bevor er in den Druck geht.
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Hinter dem Logo liegt eine Fläche in der Hintergrundfarbe, damit es
|
||||||
|
sich absetzt. Ein Code mit Logo gehört vor dem Druck einmal
|
||||||
|
eingescannt — das ist die einzige Prüfung, die wirklich zählt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { DomainBericht } from "@/components/domain-bericht";
|
||||||
|
import { DomainHilfe } from "@/components/domain-hilfe";
|
||||||
|
import { HelpButton } from "@/components/help-button";
|
||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { Modal } from "@/components/modal";
|
import { Modal } from "@/components/modal";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +17,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { ApiError, apiJson } from "@/lib/api-client";
|
import { ApiError, apiJson } from "@/lib/api-client";
|
||||||
|
import type { Pruefbericht } from "@/lib/domain-check";
|
||||||
|
|
||||||
type Grant = { groupId: string; groupName: string; isDefault: boolean };
|
type Grant = { groupId: string; groupName: string; isDefault: boolean };
|
||||||
|
|
||||||
@@ -44,6 +48,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
|||||||
|
|
||||||
const [deleting, setDeleting] = useState<AdminDomain | null>(null);
|
const [deleting, setDeleting] = useState<AdminDomain | null>(null);
|
||||||
|
|
||||||
|
/** Prüfberichte je Domain — bleiben stehen, bis erneut geprüft wird. */
|
||||||
|
const [reports, setReports] = useState<Record<string, Pruefbericht>>({});
|
||||||
|
const [checking, setChecking] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await apiJson<{
|
const result = await apiJson<{
|
||||||
@@ -122,6 +130,26 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const check = async (domain: AdminDomain) => {
|
||||||
|
setChecking(domain.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { report } = await apiJson<{ report: Pruefbericht }>(
|
||||||
|
`/api/admin/domains/${domain.id}/check`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
setReports((bisher) => ({ ...bisher, [domain.id]: report }));
|
||||||
|
} catch (caught) {
|
||||||
|
setError(
|
||||||
|
caught instanceof ApiError
|
||||||
|
? caught.message
|
||||||
|
: "Die Prüfung ließ sich nicht ausführen.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setChecking(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (domains === null) {
|
if (domains === null) {
|
||||||
return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
|
return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
|
||||||
}
|
}
|
||||||
@@ -134,10 +162,14 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
|||||||
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
|
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
|
||||||
weiterleitet. Die Weiterleitung erkennt sie am{" "}
|
weiterleitet. Die Weiterleitung erkennt sie am{" "}
|
||||||
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
|
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
|
||||||
gegen diese Liste — ein unbekannter Hostname endet mit 404.
|
gegen diese Liste — ein unbekannter Hostname endet mit 404. Was dafür
|
||||||
|
einzurichten ist, steht in der Kurzhilfe.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<HelpButton title="Eine Domain hinzufügen" label="Kurzhilfe">
|
||||||
|
<DomainHilfe baseUrlHostname={baseUrlHostname} />
|
||||||
|
</HelpButton>
|
||||||
<Button type="button" onClick={() => setCreating(true)}>
|
<Button type="button" onClick={() => setCreating(true)}>
|
||||||
Domain registrieren
|
Domain registrieren
|
||||||
</Button>
|
</Button>
|
||||||
@@ -192,6 +224,16 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
|||||||
<Icon name="pencil" className="size-3.5" />
|
<Icon name="pencil" className="size-3.5" />
|
||||||
Gruppen zuteilen
|
Gruppen zuteilen
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={checking !== null}
|
||||||
|
onClick={() => void check(domain)}
|
||||||
|
>
|
||||||
|
<Icon name="scan" className="size-3.5" />
|
||||||
|
{checking === domain.id ? "Prüft …" : "Prüfen"}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -213,6 +255,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
|||||||
Löschen
|
Löschen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{reports[domain.id] ? (
|
||||||
|
<DomainBericht bericht={reports[domain.id]} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { cx } from "@/components/ui";
|
||||||
|
import type { Pruefbericht, SchrittStatus } from "@/lib/domain-check";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Ergebnis eines Domain-Checks.
|
||||||
|
*
|
||||||
|
* Vier Zeilen, eine je Schritt, mit dem Grund im Klartext daneben. Bewusst
|
||||||
|
* ohne Ampelfarbe als einziges Merkmal: Jede Zeile trägt ein eigenes Symbol
|
||||||
|
* und einen eigenen Text, damit sie auch ohne Farbe lesbar bleibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SYMBOLE: Record<SchrittStatus, { icon: "check" | "warning" | "eyeOff"; farbe: string; wort: string }> = {
|
||||||
|
ok: { icon: "check", farbe: "text-emerald-600", wort: "in Ordnung" },
|
||||||
|
warnung: { icon: "warning", farbe: "text-amber-600", wort: "Achtung" },
|
||||||
|
fehler: { icon: "warning", farbe: "text-red-600", wort: "Fehler" },
|
||||||
|
uebersprungen: { icon: "eyeOff", farbe: "text-slate-400", wort: "übersprungen" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const RAHMEN: Record<Pruefbericht["gesamt"], string> = {
|
||||||
|
ok: "border-emerald-300 bg-emerald-50/60",
|
||||||
|
warnung: "border-amber-300 bg-amber-50/60",
|
||||||
|
fehler: "border-red-300 bg-red-50/60",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FAZIT: Record<Pruefbericht["gesamt"], string> = {
|
||||||
|
ok: "Die Domain führt hierher.",
|
||||||
|
warnung: "Es läuft, aber etwas will beachtet werden.",
|
||||||
|
fehler: "So kommt niemand hier an.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DomainBericht({ bericht }: { bericht: Pruefbericht }) {
|
||||||
|
return (
|
||||||
|
<div className={cx("mt-4 rounded-xl border p-4", RAHMEN[bericht.gesamt])}>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
|
{FAZIT[bericht.gesamt]}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-3 space-y-1.5">
|
||||||
|
{bericht.schritte.map((schritt) => {
|
||||||
|
const zeichen = SYMBOLE[schritt.status];
|
||||||
|
return (
|
||||||
|
<li key={schritt.name} className="flex items-start gap-2 text-sm">
|
||||||
|
<Icon
|
||||||
|
name={zeichen.icon}
|
||||||
|
className={cx("mt-0.5 size-4 shrink-0", zeichen.farbe)}
|
||||||
|
/>
|
||||||
|
<span className="sr-only">{zeichen.wort}: </span>
|
||||||
|
<span className="w-24 shrink-0 font-medium text-slate-700">
|
||||||
|
{schritt.titel}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 text-slate-600">
|
||||||
|
{schritt.detail}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="mt-3 text-xs text-slate-500">
|
||||||
|
Aufgerufen wurde{" "}
|
||||||
|
<code className="font-mono">{bericht.geprueft}</code> — von dort, wo
|
||||||
|
dieser Container läuft. Bei internem DNS oder Hairpin-NAT kann die
|
||||||
|
Prüfung scheitern, obwohl von außen alles stimmt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { HelpCode, HelpStep } from "@/components/help-button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Inhalt der Kurzhilfe auf der Domain-Seite.
|
||||||
|
*
|
||||||
|
* Verdichtet aus dem README-Abschnitt „Hinter einem Reverse Proxy“. Bewusst
|
||||||
|
* nur die Schritte, die diese Anwendung betreffen — wie man im eigenen Proxy
|
||||||
|
* einen Eintrag anlegt, steht in dessen Handbuch besser.
|
||||||
|
*/
|
||||||
|
export function DomainHilfe({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||||
|
const beispiel = baseUrlHostname || "qr.example.com";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p>
|
||||||
|
Eine Domain hier zu registrieren genügt nicht — sie muss den Browser
|
||||||
|
auch hierher führen. Dazu gehören drei Dinge außerhalb dieser
|
||||||
|
Anwendung: ein DNS-Eintrag, ein Eintrag im Reverse Proxy und ein
|
||||||
|
Zertifikat.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="space-y-4">
|
||||||
|
<HelpStep n={1} title="DNS-Eintrag anlegen">
|
||||||
|
<p>
|
||||||
|
Beim Anbieter der Domain einen <strong>A-Record</strong> (bei IPv6
|
||||||
|
zusätzlich <strong>AAAA</strong>) auf die öffentliche Adresse des
|
||||||
|
Reverse Proxy setzen. Ein CNAME auf einen bestehenden Namen tut es
|
||||||
|
auch.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Steht der Name nur im internen DNS oder ist er nur über VPN
|
||||||
|
erreichbar, funktionieren gedruckte Codes{" "}
|
||||||
|
<strong>auch nur dort</strong>. Für Aushänge, Flyer und
|
||||||
|
Verpackungen muss er öffentlich auflösbar sein.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
|
||||||
|
<HelpStep n={2} title="Im Reverse Proxy weiterleiten">
|
||||||
|
<p>
|
||||||
|
Für die Domain einen eigenen Eintrag anlegen, der auf diese
|
||||||
|
Anwendung zeigt — Ziel ist der Docker-Host auf Port{" "}
|
||||||
|
<code className="font-mono text-xs">3000</code>, oder im selben
|
||||||
|
Docker-Netz der Servicename{" "}
|
||||||
|
<code className="font-mono text-xs">app</code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Diese drei Kopfzeilen muss der Proxy mitschicken. Nginx Proxy
|
||||||
|
Manager, Traefik und Caddy tun das von sich aus; ein blankes nginx
|
||||||
|
braucht sie im Serverblock:
|
||||||
|
</p>
|
||||||
|
<HelpCode>{`proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`}</HelpCode>
|
||||||
|
<p>
|
||||||
|
Ohne <code className="font-mono text-xs">X-Forwarded-Host</code>{" "}
|
||||||
|
landet jeder Aufruf unter der Standard-Domain, und Kurz-URLs der
|
||||||
|
Zweitdomain werden nicht gefunden.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
|
||||||
|
<HelpStep n={3} title="Zertifikat anfordern">
|
||||||
|
<p>
|
||||||
|
HTTPS erledigt der Proxy. Im Nginx Proxy Manager ist das der Reiter{" "}
|
||||||
|
<em>SSL</em> mit <em>Force SSL</em>; Traefik und Caddy holen das
|
||||||
|
Zertifikat selbst, sobald der DNS-Eintrag steht.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Ohne gültiges Zertifikat brechen viele Scanner-Apps den Aufruf ohne
|
||||||
|
Erklärung ab — der Code sieht dann für die Nutzer schlicht kaputt
|
||||||
|
aus.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
|
||||||
|
<HelpStep n={4} title="Domain hier registrieren">
|
||||||
|
<p>
|
||||||
|
Über <strong>Domain registrieren</strong> den Hostnamen eintragen —
|
||||||
|
genau so, wie er im Browser steht, ohne{" "}
|
||||||
|
<code className="font-mono text-xs">https://</code> und ohne Pfad:
|
||||||
|
</p>
|
||||||
|
<HelpCode>{beispiel}</HelpCode>
|
||||||
|
<p>
|
||||||
|
Die Weiterleitung prüft den Hostnamen gegen diese Liste. Ein
|
||||||
|
unbekannter Name endet mit 404 — über eine gefälschte Kopfzeile
|
||||||
|
lässt sich also keine fremde Domain ansprechen.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
|
||||||
|
<HelpStep n={5} title="Einer Gruppe zuteilen">
|
||||||
|
<p>
|
||||||
|
Eine frisch registrierte Domain gehört noch niemandem. Erst über{" "}
|
||||||
|
<strong>Gruppen zuteilen</strong> können Mitglieder dieser Gruppe
|
||||||
|
dort Codes anlegen. Die als <em>Voreinstellung</em> markierte
|
||||||
|
Domain ist die, die beim Anlegen vorausgewählt ist.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
|
||||||
|
<HelpStep n={6} title="Nachprüfen">
|
||||||
|
<p>
|
||||||
|
Dafür gibt es den Knopf <strong>Prüfen</strong> bei jedem Eintrag
|
||||||
|
in der Liste. Er meldet DNS, Zertifikat, Erreichbarkeit — und ob
|
||||||
|
hinter der Domain wirklich diese Installation steht.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Der Aufruf startet dort, wo dieser Container läuft. Bei internem
|
||||||
|
DNS oder Hairpin-NAT kann er scheitern, obwohl von außen alles
|
||||||
|
stimmt. Von einem fremden Netz aus hilft dann:
|
||||||
|
</p>
|
||||||
|
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
|
||||||
|
<p>
|
||||||
|
Zum Schluss einen Code der zugeteilten Gruppe anlegen, die Domain
|
||||||
|
auswählen und den fertigen Code einmal mit dem Handy scannen — das
|
||||||
|
ist die einzige Prüfung, die wirklich zählt.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<h3 className="font-semibold text-slate-900">
|
||||||
|
Was <code className="font-mono text-xs">BASE_URL</code> damit zu tun hat
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-slate-600">
|
||||||
|
Die Umgebungsvariable{" "}
|
||||||
|
<code className="font-mono text-xs">BASE_URL</code> ist die
|
||||||
|
Standard-Adresse: Sie greift bei Aufrufen über eine nicht
|
||||||
|
registrierte Adresse und bestimmt, ob Kurz-URLs mit{" "}
|
||||||
|
<code className="font-mono text-xs">http</code> oder{" "}
|
||||||
|
<code className="font-mono text-xs">https</code> gebildet werden.
|
||||||
|
Weitere Domains werden hier registriert, nicht dort eingetragen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { Modal } from "@/components/modal";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurzhilfe: ein Fragezeichen neben einer Überschrift, das eine Erklärung
|
||||||
|
* aufklappt.
|
||||||
|
*
|
||||||
|
* Bewusst ein Dialog und kein Dauertext auf der Seite — die Erklärung braucht
|
||||||
|
* man einmal beim Einrichten und danach nie wieder. Sie ständig zu zeigen
|
||||||
|
* würde die Seite für alle anderen Aufrufe zumüllen.
|
||||||
|
*/
|
||||||
|
export function HelpButton({
|
||||||
|
title,
|
||||||
|
label = "Hilfe",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
/** Überschrift des Dialogs. */
|
||||||
|
title: string;
|
||||||
|
/** Beschriftung des Knopfes. */
|
||||||
|
label?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
>
|
||||||
|
<Icon name="help" className="size-4" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
size="lg"
|
||||||
|
title={title}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
footer={
|
||||||
|
<Button type="button" onClick={() => setOpen(false)}>
|
||||||
|
Verstanden
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="max-h-[65vh] overflow-y-auto pr-1 text-sm text-slate-700">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------- Bausteine für den Inhalt */
|
||||||
|
|
||||||
|
/** Ein nummerierter Schritt mit Überschrift. */
|
||||||
|
export function HelpStep({
|
||||||
|
n,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
n: number;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-fg">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold text-slate-900">{title}</h3>
|
||||||
|
<div className="mt-1 space-y-2 text-slate-600">{children}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Befehl oder Wert zum Abtippen — auf schmalen Schirmen scrollbar. */
|
||||||
|
export function HelpCode({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<pre className="overflow-x-auto rounded-lg bg-slate-100 px-3 py-2 font-mono text-xs text-slate-800">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -238,6 +238,13 @@ const PATHS = {
|
|||||||
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
|
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
help: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.2 2.4c-.6.2-.95.75-.95 1.4v.45" />
|
||||||
|
<path d="M11.75 16.75h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type IconName = keyof typeof PATHS;
|
export type IconName = keyof typeof PATHS;
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ export function Modal({
|
|||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
|
size = "md",
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
/** `lg` für längere Texte wie eine Kurzhilfe. */
|
||||||
|
size?: "md" | "lg";
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDialogElement>(null);
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
@@ -39,7 +42,9 @@ export function Modal({
|
|||||||
// Klick auf die Hintergrundfläche des Dialogs schließt ihn.
|
// Klick auf die Hintergrundfläche des Dialogs schließt ihn.
|
||||||
if (event.target === ref.current) onClose();
|
if (event.target === ref.current) onClose();
|
||||||
}}
|
}}
|
||||||
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50"
|
className={`m-auto w-[calc(100%-2rem)] ${
|
||||||
|
size === "lg" ? "max-w-2xl" : "max-w-lg"
|
||||||
|
} rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50`}
|
||||||
>
|
>
|
||||||
<div className="p-5 sm:p-6">
|
<div className="p-5 sm:p-6">
|
||||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||||
|
|||||||
@@ -43,13 +43,31 @@ function sameData(a: unknown, b: unknown): boolean {
|
|||||||
return stableStringify(a) === stableStringify(b);
|
return stableStringify(a) === stableStringify(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der bereits abgelegte Code, den die Dublettenprüfung gemeldet hat. */
|
||||||
|
type Dublette = { id: string; label: string | null; createdAt: string };
|
||||||
|
|
||||||
|
/** Liest die Meldung aus einer 409-Antwort — oder gibt auf. */
|
||||||
|
function leseDublette(details: unknown): Dublette | null {
|
||||||
|
if (details === null || typeof details !== "object") return null;
|
||||||
|
const feld = (details as { duplicate?: unknown }).duplicate;
|
||||||
|
if (feld === null || typeof feld !== "object") return null;
|
||||||
|
const { id, label, createdAt } = feld as Record<string, unknown>;
|
||||||
|
if (typeof id !== "string" || typeof createdAt !== "string") return null;
|
||||||
|
return { id, label: typeof label === "string" ? label : null, createdAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dublettenDatum = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
|
||||||
|
|
||||||
export function QrGenerator({
|
export function QrGenerator({
|
||||||
typeId,
|
typeId,
|
||||||
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
|
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
|
||||||
maySetSlug = true,
|
maySetSlug = true,
|
||||||
|
/** Ist ein Logo der Installation hinterlegt? Sonst nur eigener Upload. */
|
||||||
|
hasBrandingLogo = false,
|
||||||
}: {
|
}: {
|
||||||
typeId: QrTypeId;
|
typeId: QrTypeId;
|
||||||
maySetSlug?: boolean;
|
maySetSlug?: boolean;
|
||||||
|
hasBrandingLogo?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const qrType = qrTypes[typeId];
|
const qrType = qrTypes[typeId];
|
||||||
const Form = qrForms[typeId];
|
const Form = qrForms[typeId];
|
||||||
@@ -65,6 +83,14 @@ export function QrGenerator({
|
|||||||
const [saved, setSaved] = useState<QrCodeDto | null>(null);
|
const [saved, setSaved] = useState<QrCodeDto | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
/**
|
||||||
|
* Gemeldete Dublette samt dem Inhalt, für den sie gilt. Ändert sich der
|
||||||
|
* Inhalt, ist die Warnung überholt und verschwindet von selbst — ohne
|
||||||
|
* einen Effekt, der sie hinterherräumt.
|
||||||
|
*/
|
||||||
|
const [duplicate, setDuplicate] = useState<
|
||||||
|
{ treffer: Dublette; inhalt: string } | null
|
||||||
|
>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const [preview, setPreview] = useState<PreviewState | null>(null);
|
const [preview, setPreview] = useState<PreviewState | null>(null);
|
||||||
@@ -84,11 +110,13 @@ export function QrGenerator({
|
|||||||
const dataError = !validation.ok && touched ? validation.message : null;
|
const dataError = !validation.ok && touched ? validation.message : null;
|
||||||
|
|
||||||
const mode = isDynamic ? "dynamic" : "static";
|
const mode = isDynamic ? "dynamic" : "static";
|
||||||
const slug = isDynamic ? saved?.slug : undefined;
|
const slug = (isDynamic ? saved?.slug : undefined) ?? undefined;
|
||||||
|
|
||||||
const requestBody = useMemo(
|
const requestBody = useMemo(
|
||||||
() => ({ type: typeId, data, design, mode, slug }),
|
// Der Name geht mit: Der Server bildet daraus den Dateinamen beim
|
||||||
[typeId, data, design, mode, slug],
|
// Herunterladen. Auf die Vorschau hat er keinen Einfluss.
|
||||||
|
() => ({ type: typeId, data, design, mode, slug, label: label.trim() || undefined }),
|
||||||
|
[typeId, data, design, mode, slug, label],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ready = validation.ok && designValid;
|
const ready = validation.ok && designValid;
|
||||||
@@ -185,17 +213,29 @@ export function QrGenerator({
|
|||||||
!sameData(saved.design, design) ||
|
!sameData(saved.design, design) ||
|
||||||
(saved.label ?? "") !== label.trim());
|
(saved.label ?? "") !== label.trim());
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
// Nur zeigen, solange der Inhalt derselbe ist, für den die Meldung kam.
|
||||||
|
const dublette =
|
||||||
|
duplicate !== null &&
|
||||||
|
duplicate.inhalt === stableStringify(validation.ok ? validation.data : data)
|
||||||
|
? duplicate.treffer
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleSave = useCallback(async (trotzDublette = false) => {
|
||||||
if (!validation.ok || !designValid || slugProblem) return;
|
if (!validation.ok || !designValid || slugProblem) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
setDuplicate(null);
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
type: typeId,
|
type: typeId,
|
||||||
data: validation.data,
|
data: validation.data,
|
||||||
design,
|
design,
|
||||||
|
mode,
|
||||||
label: label.trim() || undefined,
|
label: label.trim() || undefined,
|
||||||
slug: wunschSlug.trim() || undefined,
|
// Ein statischer Code hat keinen Kurz-Link.
|
||||||
|
slug: isDynamic ? wunschSlug.trim() || undefined : undefined,
|
||||||
|
// Nur beim zweiten Anlauf, nachdem die Warnung dastand.
|
||||||
|
allowDuplicate: trotzDublette,
|
||||||
};
|
};
|
||||||
const result = saved
|
const result = saved
|
||||||
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
|
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
|
||||||
@@ -208,18 +248,40 @@ export function QrGenerator({
|
|||||||
});
|
});
|
||||||
setSaved(result.code);
|
setSaved(result.code);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage.
|
||||||
|
const gemeldet =
|
||||||
|
error instanceof ApiError && error.status === 409
|
||||||
|
? leseDublette(error.details)
|
||||||
|
: null;
|
||||||
|
if (gemeldet) {
|
||||||
|
setDuplicate({ treffer: gemeldet, inhalt: stableStringify(validation.data) });
|
||||||
|
} else {
|
||||||
setSaveError(
|
setSaveError(
|
||||||
error instanceof ApiError
|
error instanceof ApiError
|
||||||
? error.message
|
? error.message
|
||||||
: "Der Code konnte nicht gespeichert werden.",
|
: "Der Code konnte nicht gespeichert werden.",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [validation, designValid, slugProblem, typeId, design, label, wunschSlug, saved]);
|
}, [validation, designValid, slugProblem, typeId, design, mode, isDynamic, label, wunschSlug, saved]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wechselt die Betriebsart und vergisst dabei einen zuvor gespeicherten
|
||||||
|
* Code. Ein abgelegter statischer Eintrag ist etwas anderes als ein
|
||||||
|
* gespeicherter dynamischer — beides gleichzeitig festzuhalten führte
|
||||||
|
* dazu, dass die Oberfläche den falschen Zustand zeigt.
|
||||||
|
*/
|
||||||
|
const wechsleModus = useCallback((dynamisch: boolean) => {
|
||||||
|
setIsDynamic(dynamisch);
|
||||||
|
setSaved(null);
|
||||||
|
setSaveError(null);
|
||||||
|
setDuplicate(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
if (!saved) return;
|
if (!saved?.shortUrl) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(saved.shortUrl);
|
await navigator.clipboard.writeText(saved.shortUrl);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
@@ -302,7 +364,7 @@ export function QrGenerator({
|
|||||||
data-mode="static"
|
data-mode="static"
|
||||||
aria-pressed={!isDynamic}
|
aria-pressed={!isDynamic}
|
||||||
disabled={!qrType.supportsStatic}
|
disabled={!qrType.supportsStatic}
|
||||||
onClick={() => setIsDynamic(false)}
|
onClick={() => wechsleModus(false)}
|
||||||
className={cx(
|
className={cx(
|
||||||
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
!isDynamic
|
!isDynamic
|
||||||
@@ -326,7 +388,7 @@ export function QrGenerator({
|
|||||||
data-mode="dynamic"
|
data-mode="dynamic"
|
||||||
aria-pressed={isDynamic}
|
aria-pressed={isDynamic}
|
||||||
disabled={!qrType.supportsDynamic}
|
disabled={!qrType.supportsDynamic}
|
||||||
onClick={() => setIsDynamic(true)}
|
onClick={() => wechsleModus(true)}
|
||||||
className={cx(
|
className={cx(
|
||||||
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
isDynamic
|
isDynamic
|
||||||
@@ -347,12 +409,15 @@ export function QrGenerator({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isDynamic ? (
|
|
||||||
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
|
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
|
||||||
<Field
|
<Field
|
||||||
label="Name (optional)"
|
label="Name (optional)"
|
||||||
htmlFor="code-label"
|
htmlFor="code-label"
|
||||||
hint="Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
|
hint={
|
||||||
|
isDynamic
|
||||||
|
? "Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
|
||||||
|
: "Bestimmt den Dateinamen beim Herunterladen — und den Eintrag, falls du ihn ablegst."
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="code-label"
|
id="code-label"
|
||||||
@@ -363,9 +428,7 @@ export function QrGenerator({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{/* Nur beim Anlegen: Danach steht der Kurz-Link im
|
{!isDynamic || saved || !maySetSlug ? null : (
|
||||||
Bearbeiten-Dialog, wo auch die früheren Adressen liegen. */}
|
|
||||||
{saved || !maySetSlug ? null : (
|
|
||||||
<Field
|
<Field
|
||||||
label="Kurz-Link (optional)"
|
label="Kurz-Link (optional)"
|
||||||
htmlFor="code-slug"
|
htmlFor="code-slug"
|
||||||
@@ -384,7 +447,67 @@ export function QrGenerator({
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{saved ? (
|
{!isDynamic ? (
|
||||||
|
/* Statisch: Der Code ist ohne Speichern fertig. Ablegen ist
|
||||||
|
ein Angebot, kein Zwang — es geht nur ums Wiederfinden.
|
||||||
|
Nach einer Inhaltsänderung ist die Ablage ein Schnappschuss
|
||||||
|
von vorher; dann wird sie wieder angeboten. */
|
||||||
|
saved && !hasUnsavedChanges ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Notice tone="success">
|
||||||
|
<span className="font-medium">Abgelegt.</span> Du findest
|
||||||
|
den Code jetzt unter „Meine Codes“ und kannst ihn dort
|
||||||
|
erneut herunterladen. Der Code selbst ändert sich dadurch
|
||||||
|
nicht — sein Inhalt steckt in ihm.
|
||||||
|
</Notice>
|
||||||
|
<Link
|
||||||
|
href="/meine-codes"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-accent-text hover:text-accent-soft-fg"
|
||||||
|
>
|
||||||
|
Zu „Meine Codes“
|
||||||
|
<Icon name="external" className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{dublette ? (
|
||||||
|
<Notice tone="warning">
|
||||||
|
<span className="font-medium">
|
||||||
|
Diesen Inhalt hast du schon abgelegt
|
||||||
|
</span>{" "}
|
||||||
|
— {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "}
|
||||||
|
vom {dublettenDatum.format(new Date(dublette.createdAt))}.
|
||||||
|
Zweimal derselbe Inhalt ergibt zweimal denselben Code.
|
||||||
|
<span className="mt-1 block text-xs">
|
||||||
|
Verglichen wird nur der Inhalt, nicht die Gestaltung —
|
||||||
|
derselbe Code in anderen Farben gilt hier als Dublette.
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 flex flex-wrap items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/meine-codes"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium underline"
|
||||||
|
>
|
||||||
|
Den vorhandenen ansehen
|
||||||
|
<Icon name="external" className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void handleSave(dublette !== null)}
|
||||||
|
disabled={saving || !ready}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? "Legt ab …"
|
||||||
|
: dublette
|
||||||
|
? "Trotzdem ablegen"
|
||||||
|
: "In „Meine Codes“ ablegen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : saved ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Notice tone="success">
|
<Notice tone="success">
|
||||||
<span className="font-medium">Code gespeichert.</span> Der
|
<span className="font-medium">Code gespeichert.</span> Der
|
||||||
@@ -412,7 +535,7 @@ export function QrGenerator({
|
|||||||
{hasUnsavedChanges ? (
|
{hasUnsavedChanges ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={() => void handleSave()}
|
||||||
disabled={saving || !ready}
|
disabled={saving || !ready}
|
||||||
>
|
>
|
||||||
{saving ? "Speichert …" : "Änderungen speichern"}
|
{saving ? "Speichert …" : "Änderungen speichern"}
|
||||||
@@ -444,7 +567,7 @@ export function QrGenerator({
|
|||||||
</Notice>
|
</Notice>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={() => void handleSave()}
|
||||||
disabled={saving || !ready || Boolean(slugProblem)}
|
disabled={saving || !ready || Boolean(slugProblem)}
|
||||||
>
|
>
|
||||||
{saving ? "Speichert …" : "Dynamischen Code speichern"}
|
{saving ? "Speichert …" : "Dynamischen Code speichern"}
|
||||||
@@ -454,7 +577,6 @@ export function QrGenerator({
|
|||||||
|
|
||||||
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -463,7 +585,11 @@ export function QrGenerator({
|
|||||||
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
|
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
|
||||||
Gestaltung
|
Gestaltung
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<DesignPanel design={design} onChange={setDesign} />
|
<DesignPanel
|
||||||
|
design={design}
|
||||||
|
onChange={setDesign}
|
||||||
|
hasBrandingLogo={hasBrandingLogo}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import { Icon, type IconName } from "@/components/icons";
|
import { Icon, type IconName } from "@/components/icons";
|
||||||
import { Badge, Card, Notice, cx } from "@/components/ui";
|
import { Badge, Card, Notice, Select, cx } from "@/components/ui";
|
||||||
import { ApiError, apiJson } from "@/lib/api-client";
|
import { ApiError, apiJson } from "@/lib/api-client";
|
||||||
import type { QrTypeId } from "@/lib/qr/type-module";
|
import type { QrTypeId } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
@@ -13,9 +13,19 @@ export type TypeSetting = {
|
|||||||
description: string;
|
description: string;
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
implemented: boolean;
|
implemented: boolean;
|
||||||
|
/** Freigegeben in der gerade gezeigten Sicht (global oder Gruppe). */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** Die globale Freigabe für sich genommen. */
|
||||||
|
enabledGlobally: boolean;
|
||||||
|
/** In der gezeigten Gruppe ausdrücklich abgewählt. */
|
||||||
|
disabledInGroup: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GroupOption = { id: string; name: string };
|
||||||
|
|
||||||
|
/** Die Grundeinstellung, wenn keine Gruppe gewählt ist. */
|
||||||
|
const GLOBAL = "global";
|
||||||
|
|
||||||
function Toggle({
|
function Toggle({
|
||||||
checked,
|
checked,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -50,29 +60,71 @@ function Toggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
|
export function TypeSettings({
|
||||||
|
initial,
|
||||||
|
groups = [],
|
||||||
|
}: {
|
||||||
|
initial: TypeSetting[];
|
||||||
|
groups?: GroupOption[];
|
||||||
|
}) {
|
||||||
const [types, setTypes] = useState(initial);
|
const [types, setTypes] = useState(initial);
|
||||||
|
const [scope, setScope] = useState<string>(GLOBAL);
|
||||||
const [pending, setPending] = useState<QrTypeId | null>(null);
|
const [pending, setPending] = useState<QrTypeId | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const inGruppe = scope !== GLOBAL;
|
||||||
|
const gruppenname = groups.find((g) => g.id === scope)?.name ?? "";
|
||||||
|
|
||||||
const visibleCount = types.filter((entry) => entry.enabled).length;
|
const visibleCount = types.filter((entry) => entry.enabled).length;
|
||||||
const usableCount = types.filter(
|
const usableCount = types.filter(
|
||||||
(entry) => entry.enabled && entry.implemented,
|
(entry) => entry.enabled && entry.implemented,
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
/** Sicht wechseln — die Zustände unterscheiden sich je Gruppe. */
|
||||||
|
const wechsle = useCallback(async (next: string) => {
|
||||||
|
setScope(next);
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await apiJson<{ types: TypeSetting[] }>(
|
||||||
|
next === GLOBAL ? "/api/admin/types" : `/api/admin/types?group=${next}`,
|
||||||
|
);
|
||||||
|
setTypes(result.types);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(
|
||||||
|
caught instanceof ApiError
|
||||||
|
? caught.message
|
||||||
|
: "Die Freigaben konnten nicht geladen werden.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggle = async (id: QrTypeId, enabled: boolean) => {
|
const toggle = async (id: QrTypeId, enabled: boolean) => {
|
||||||
const before = types;
|
const before = types;
|
||||||
// Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter
|
// Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter
|
||||||
// soll sich nicht zäh anfühlen.
|
// soll sich nicht zäh anfühlen.
|
||||||
setTypes((current) =>
|
setTypes((current) =>
|
||||||
current.map((entry) => (entry.id === id ? { ...entry, enabled } : entry)),
|
current.map((entry) =>
|
||||||
|
entry.id === id
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
enabled,
|
||||||
|
...(inGruppe
|
||||||
|
? { disabledInGroup: !enabled }
|
||||||
|
: { enabledGlobally: enabled }),
|
||||||
|
}
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setPending(id);
|
setPending(id);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await apiJson("/api/admin/types", {
|
await apiJson("/api/admin/types", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
json: { type: id, enabled },
|
json: { type: id, enabled, ...(inGruppe ? { groupId: scope } : {}) },
|
||||||
});
|
});
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setTypes(before);
|
setTypes(before);
|
||||||
@@ -90,6 +142,40 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||||
|
|
||||||
|
{groups.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor="typen-gruppe"
|
||||||
|
className="text-sm font-medium text-slate-700"
|
||||||
|
>
|
||||||
|
Freigabe für
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
id="typen-gruppe"
|
||||||
|
className="!w-auto"
|
||||||
|
value={scope}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(event) => void wechsle(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value={GLOBAL}>Alle Gruppen (Grundeinstellung)</option>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<option key={group.id} value={group.id}>
|
||||||
|
Nur: {group.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{inGruppe ? (
|
||||||
|
<Notice tone="info">
|
||||||
|
Du siehst die Freigabe für <strong>{gruppenname}</strong>. Was hier
|
||||||
|
abgeschaltet ist, gilt nur für diese Gruppe. Ein Typ, der in der
|
||||||
|
Grundeinstellung aus ist, lässt sich hier nicht einschalten — die
|
||||||
|
globale Abschaltung gewinnt immer.
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{visibleCount === 0 ? (
|
{visibleCount === 0 ? (
|
||||||
<Notice tone="warning">
|
<Notice tone="warning">
|
||||||
Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es
|
Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es
|
||||||
@@ -138,17 +224,28 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-sm text-slate-500">
|
<p className="mt-0.5 text-sm text-slate-500">
|
||||||
{entry.enabled
|
{inGruppe && !entry.enabledGlobally
|
||||||
|
? "In der Grundeinstellung abgeschaltet — für diese Gruppe nicht einschaltbar."
|
||||||
|
: entry.enabled
|
||||||
? entry.implemented
|
? entry.implemented
|
||||||
? "Erscheint auf der Startseite und kann verwendet werden."
|
? inGruppe
|
||||||
|
? `Steht ${gruppenname} zur Verfügung.`
|
||||||
|
: "Erscheint auf der Startseite und kann verwendet werden."
|
||||||
: "Erscheint auf der Startseite als „Bald verfügbar“."
|
: "Erscheint auf der Startseite als „Bald verfügbar“."
|
||||||
|
: inGruppe
|
||||||
|
? `Für ${gruppenname} abgeschaltet — andere Gruppen bleiben unberührt.`
|
||||||
: "Ausgeblendet — erscheint nicht auf der Startseite."}
|
: "Ausgeblendet — erscheint nicht auf der Startseite."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={entry.enabled}
|
checked={entry.enabled}
|
||||||
disabled={pending === entry.id}
|
disabled={
|
||||||
|
pending === entry.id ||
|
||||||
|
loading ||
|
||||||
|
// Global aus gewinnt immer — der Schalter steht dann still.
|
||||||
|
(inGruppe && !entry.enabledGlobally)
|
||||||
|
}
|
||||||
label={`${entry.title} ${entry.enabled ? "ausblenden" : "freigeben"}`}
|
label={`${entry.title} ${entry.enabled ? "ausblenden" : "freigeben"}`}
|
||||||
onChange={(next) => void toggle(entry.id, next)}
|
onChange={(next) => void toggle(entry.id, next)}
|
||||||
/>
|
/>
|
||||||
@@ -158,8 +255,9 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
{usableCount} von {types.length} Typen nutzbar, {visibleCount} auf der
|
{inGruppe
|
||||||
Startseite sichtbar.
|
? `${usableCount} von ${types.length} Typen stehen ${gruppenname} zur Verfügung.`
|
||||||
|
: `${usableCount} von ${types.length} Typen nutzbar, ${visibleCount} auf der Startseite sichtbar.`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,42 @@
|
|||||||
/** Kleine Hilfen für Aufrufe der eigenen API aus Client-Komponenten. */
|
/** Kleine Hilfen für Aufrufe der eigenen API aus Client-Komponenten. */
|
||||||
|
|
||||||
async function readError(response: Response): Promise<string> {
|
async function readError(response: Response): Promise<ApiError> {
|
||||||
|
let body: unknown = null;
|
||||||
try {
|
try {
|
||||||
const body: unknown = await response.json();
|
body = await response.json();
|
||||||
if (
|
} catch {
|
||||||
|
// Antwort ohne JSON-Körper — unten kommt die allgemeine Meldung.
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
body !== null &&
|
body !== null &&
|
||||||
typeof body === "object" &&
|
typeof body === "object" &&
|
||||||
"error" in body &&
|
"error" in body &&
|
||||||
typeof (body as { error: unknown }).error === "string"
|
typeof (body as { error: unknown }).error === "string"
|
||||||
) {
|
? (body as { error: string }).error
|
||||||
return (body as { error: string }).error;
|
: `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`;
|
||||||
}
|
|
||||||
} catch {
|
return new ApiError(message, response.status, body);
|
||||||
// Antwort ohne JSON-Körper — unten kommt die allgemeine Meldung.
|
|
||||||
}
|
|
||||||
return `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {}
|
/**
|
||||||
|
* Fehler einer eigenen API-Antwort.
|
||||||
|
*
|
||||||
|
* Trägt neben der Meldung auch den Status und den Antwortkörper: Manche
|
||||||
|
* Fehler sind keine Sackgasse, sondern eine Rückfrage — etwa die gemeldete
|
||||||
|
* Dublette beim Ablegen, zu der die Oberfläche den vorhandenen Eintrag
|
||||||
|
* anzeigen will.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly details: unknown;
|
||||||
|
|
||||||
|
constructor(message: string, status = 0, details: unknown = null) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiJson<T>(
|
export async function apiJson<T>(
|
||||||
url: string,
|
url: string,
|
||||||
@@ -34,7 +53,7 @@ export async function apiJson<T>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(await readError(response));
|
throw await readError(response);
|
||||||
}
|
}
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
return undefined as T;
|
return undefined as T;
|
||||||
@@ -55,7 +74,7 @@ export async function downloadFile(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(await readError(response));
|
throw await readError(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
const disposition = response.headers.get("Content-Disposition") ?? "";
|
const disposition = response.headers.get("Content-Disposition") ?? "";
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Änderungsverlauf der Anwendung — die einzige Quelle dafür.
|
||||||
|
*
|
||||||
|
* Hieraus entsteht beides: die Seite unter `/version` und die `CHANGELOG.md`
|
||||||
|
* im Wurzelverzeichnis (`npm run changelog`). Zwei gepflegte Listen liefen
|
||||||
|
* unweigerlich auseinander; eine Prüfung wacht darüber, dass die erzeugte
|
||||||
|
* Datei zu dieser Quelle passt.
|
||||||
|
*
|
||||||
|
* Die Einträge bis 1.5.0 sind aus dem Abschnitt „Gebaut“ der Roadmap und der
|
||||||
|
* Git-Historie zusammengetragen: Versionsnummern gab es damals nicht, deshalb
|
||||||
|
* ist je **Erscheinungstag** eine gebildet. Nachträglich vergeben, aber die
|
||||||
|
* Daten selbst stimmen. Ab 1.6.0 trägt jede Fassung einen Git-Tag.
|
||||||
|
*/
|
||||||
|
export type ChangelogEntry = {
|
||||||
|
version: string;
|
||||||
|
/** ISO-Datum, an dem der Stand entstanden ist. */
|
||||||
|
date: string;
|
||||||
|
/** Eine Zeile zur Einordnung — was diese Version ausmacht. */
|
||||||
|
summary: string;
|
||||||
|
changes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHANGELOG: readonly ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "1.7.1",
|
||||||
|
date: "2026-08-21",
|
||||||
|
summary: "Bilder zu den neuen Funktionen in der Bedienungsanleitung.",
|
||||||
|
changes: [
|
||||||
|
"Die Bedienungsanleitung zeigt die Funktionen aus 1.7.0 jetzt auch im Bild: Logo im Code, Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und Domain-Bericht.",
|
||||||
|
"Alle übrigen Bilder mit erneuert — sie stammten noch aus 1.2.0.",
|
||||||
|
"Das Skript, das die Bilder erzeugt, lief seit einer Umbenennung ins Leere und tut es wieder.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.7.0",
|
||||||
|
date: "2026-08-21",
|
||||||
|
summary: "Logo im Code, Freigabe je Gruppe und ein Domain-Check.",
|
||||||
|
changes: [
|
||||||
|
"Ein Logo in der Mitte des Codes — das der Installation oder ein eigenes je Code, 10 bis 25 % der Kantenlänge. Die Fehlerkorrektur springt dabei auf H.",
|
||||||
|
"QR-Typen lassen sich je Gruppe freigeben. Die Grundeinstellung bleibt die Grundlage; eine globale Abschaltung gewinnt immer.",
|
||||||
|
"Der Knopf „Prüfen“ bei jeder Domain meldet DNS, Zertifikat, Erreichbarkeit — und ob dahinter wirklich diese Installation steht.",
|
||||||
|
"Eine Kurzhilfe auf der Domain-Seite führt in sechs Schritten auf, was für eine neue Domain einzurichten ist.",
|
||||||
|
"„Meine Codes“ lässt sich nach Art filtern: alle, nur dynamische, nur statische.",
|
||||||
|
"Beim Ablegen eines statischen Codes wird gewarnt, wenn derselbe Inhalt schon in der Gruppe liegt.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.6.0",
|
||||||
|
date: "2026-08-21",
|
||||||
|
summary: "Sichtbare Version mit Änderungsverlauf.",
|
||||||
|
changes: [
|
||||||
|
"Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.",
|
||||||
|
"Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.",
|
||||||
|
"Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.5.0",
|
||||||
|
date: "2026-08-20",
|
||||||
|
summary: "Statische Codes lassen sich benennen und ablegen.",
|
||||||
|
changes: [
|
||||||
|
"Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.",
|
||||||
|
"Der Name bestimmt den Dateinamen beim Herunterladen.",
|
||||||
|
"Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.4.0",
|
||||||
|
date: "2026-08-17",
|
||||||
|
summary: "Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.",
|
||||||
|
changes: [
|
||||||
|
"Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.",
|
||||||
|
"Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.",
|
||||||
|
"Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.",
|
||||||
|
"Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.",
|
||||||
|
"Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.3.0",
|
||||||
|
date: "2026-08-16",
|
||||||
|
summary: "Eigenes Erscheinungsbild und ein Dark Mode.",
|
||||||
|
changes: [
|
||||||
|
"Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.",
|
||||||
|
"Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.",
|
||||||
|
"Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.",
|
||||||
|
"Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.2.0",
|
||||||
|
date: "2026-08-15",
|
||||||
|
summary: "Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.",
|
||||||
|
changes: [
|
||||||
|
"Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.",
|
||||||
|
"Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.",
|
||||||
|
"Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.",
|
||||||
|
"Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.1.0",
|
||||||
|
date: "2026-08-14",
|
||||||
|
summary: "Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.",
|
||||||
|
changes: [
|
||||||
|
"Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.",
|
||||||
|
"Landingpages als eigene Gattung für Typen ohne fremdes Ziel.",
|
||||||
|
"Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.",
|
||||||
|
"Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.",
|
||||||
|
"Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.",
|
||||||
|
"„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.",
|
||||||
|
"Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
date: "2026-08-13",
|
||||||
|
summary: "Erste lauffähige Fassung.",
|
||||||
|
changes: [
|
||||||
|
"Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.",
|
||||||
|
"QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.",
|
||||||
|
"Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.",
|
||||||
|
"Download als PNG und SVG, serverseitig gerendert.",
|
||||||
|
"Update-Skript für Linux, macOS und Windows.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die laufende Version.
|
||||||
|
*
|
||||||
|
* Aus der `package.json`, damit es genau eine Stelle gibt, an der sie steht.
|
||||||
|
* Der Wert wird beim Bauen eingesetzt — im Container liegt kein Git, aus dem
|
||||||
|
* sich sonst etwas ableiten ließe.
|
||||||
|
*/
|
||||||
|
export const APP_VERSION: string = pkg.version;
|
||||||
|
|
||||||
|
/** Der Eintrag zur laufenden Version, falls er im Verlauf steht. */
|
||||||
|
export function currentEntry(): ChangelogEntry | undefined {
|
||||||
|
return CHANGELOG.find((eintrag) => eintrag.version === APP_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datum als „21.08.2026“ — ohne Abhängigkeit von der Zeitzone des Servers. */
|
||||||
|
export function formatChangelogDate(iso: string): string {
|
||||||
|
const [jahr, monat, tag] = iso.split("-");
|
||||||
|
return `${tag}.${monat}.${jahr}`;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import "server-only";
|
|||||||
import type { Prisma, QrCode } from "@/generated/prisma/client";
|
import type { Prisma, QrCode } from "@/generated/prisma/client";
|
||||||
import type { QrCodeDto } from "@/lib/dto";
|
import type { QrCodeDto } from "@/lib/dto";
|
||||||
import { landingUrl, shortUrl } from "@/lib/env";
|
import { landingUrl, shortUrl } from "@/lib/env";
|
||||||
import type { EcLevel } from "@/lib/qr/design";
|
import type { EcLevel, LogoSource } from "@/lib/qr/design";
|
||||||
import { getQrType } from "@/lib/qr/registry";
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
import type { QrTypeId } from "@/lib/qr/type-module";
|
import type { QrTypeId } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
@@ -38,14 +38,22 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
|||||||
const data = asRecord(code.data);
|
const data = asRecord(code.data);
|
||||||
const qrType = getQrType(code.type);
|
const qrType = getQrType(code.type);
|
||||||
|
|
||||||
|
const istStatisch = code.mode === "static";
|
||||||
|
|
||||||
let target = "";
|
let target = "";
|
||||||
let summary = "";
|
let summary = "";
|
||||||
if (qrType) {
|
if (qrType) {
|
||||||
try {
|
try {
|
||||||
|
// Ein statischer Code führt nirgendwohin — sein Inhalt steckt in ihm
|
||||||
|
// selbst. Als „Ziel“ bleibt deshalb nichts übrig; die Zusammenfassung
|
||||||
|
// sagt trotzdem, was drinsteht.
|
||||||
// Typen mit eigener Landingpage führen auf eine Seite dieser Anwendung.
|
// Typen mit eigener Landingpage führen auf eine Seite dieser Anwendung.
|
||||||
// Die ist als „Ziel“ das Ehrlichste — und obendrein anklickbar, um sie
|
// Die ist als „Ziel“ das Ehrlichste — und obendrein anklickbar, um sie
|
||||||
// sich anzusehen.
|
// sich anzusehen.
|
||||||
target = qrType.hasLandingPage
|
target =
|
||||||
|
istStatisch || !code.slug
|
||||||
|
? ""
|
||||||
|
: qrType.hasLandingPage
|
||||||
? landingUrl(code.slug, code.domain?.hostname)
|
? landingUrl(code.slug, code.domain?.hostname)
|
||||||
: qrType.redirectTarget(data);
|
: qrType.redirectTarget(data);
|
||||||
summary = qrType.summary(data);
|
summary = qrType.summary(data);
|
||||||
@@ -60,12 +68,13 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: code.id,
|
id: code.id,
|
||||||
|
mode: istStatisch ? "static" : "dynamic",
|
||||||
slug: code.slug,
|
slug: code.slug,
|
||||||
aliases: (code.aliases ?? []).map((alias) => alias.slug),
|
aliases: (code.aliases ?? []).map((alias) => alias.slug),
|
||||||
type: code.type as QrTypeId,
|
type: code.type as QrTypeId,
|
||||||
label: code.label,
|
label: code.label,
|
||||||
data,
|
data,
|
||||||
shortUrl: shortUrl(code.slug, code.domain?.hostname),
|
shortUrl: code.slug ? shortUrl(code.slug, code.domain?.hostname) : null,
|
||||||
target,
|
target,
|
||||||
summary,
|
summary,
|
||||||
design: {
|
design: {
|
||||||
@@ -74,6 +83,9 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
|||||||
size: code.size,
|
size: code.size,
|
||||||
ecLevel: code.ecLevel as EcLevel,
|
ecLevel: code.ecLevel as EcLevel,
|
||||||
margin: code.margin,
|
margin: code.margin,
|
||||||
|
logoSource: code.logoSource as LogoSource | null,
|
||||||
|
logoUploadId: code.logoUploadId,
|
||||||
|
logoScale: code.logoScale,
|
||||||
},
|
},
|
||||||
scans: code.scans,
|
scans: code.scans,
|
||||||
lastScanAt: code.lastScanAt?.toISOString() ?? null,
|
lastScanAt: code.lastScanAt?.toISOString() ?? null,
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import { lookup } from "node:dns/promises";
|
||||||
|
import { connect, type PeerCertificate } from "node:tls";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-Check: Führt die Domain wirklich hierher?
|
||||||
|
*
|
||||||
|
* Eine Domain zu registrieren ist der kleinste Teil der Arbeit — DNS, Reverse
|
||||||
|
* Proxy und Zertifikat liegen außerhalb dieser Anwendung, und wenn dort etwas
|
||||||
|
* fehlt, merkt man es erst am gedruckten Code, der nicht aufgeht.
|
||||||
|
*
|
||||||
|
* Die Prüfung geht deshalb den Weg der Scanner nach: Namen auflösen, das
|
||||||
|
* Zertifikat ansehen, die Adresse aufrufen — und sich beweisen lassen, dass
|
||||||
|
* am anderen Ende **diese** Installation steht. Der Beweis ist ein HMAC über
|
||||||
|
* eine frische Zufallszahl; das Geheimnis dafür verlässt den Server nie.
|
||||||
|
*
|
||||||
|
* Die Grenze der Aussage steht in der Meldung selbst: Der Aufruf startet dort,
|
||||||
|
* wo dieser Container läuft. Wer internes DNS oder Hairpin-NAT hat, bekommt
|
||||||
|
* hier womöglich einen Fehler, obwohl von außen alles stimmt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SchrittName = "dns" | "erreichbar" | "zertifikat" | "ziel";
|
||||||
|
export type SchrittStatus = "ok" | "warnung" | "fehler" | "uebersprungen";
|
||||||
|
|
||||||
|
export type Pruefschritt = {
|
||||||
|
name: SchrittName;
|
||||||
|
titel: string;
|
||||||
|
status: SchrittStatus;
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Pruefbericht = {
|
||||||
|
hostname: string;
|
||||||
|
/** Die Adresse, die tatsächlich aufgerufen wurde. */
|
||||||
|
geprueft: string;
|
||||||
|
gesamt: "ok" | "warnung" | "fehler";
|
||||||
|
schritte: Pruefschritt[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Antwort der öffentlichen Beweis-Route. */
|
||||||
|
export type Domainbeweis = { app: string; proof: string };
|
||||||
|
|
||||||
|
const APP_KENNUNG = "qrcode-generator";
|
||||||
|
const ZEITGRENZE_MS = 8000;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Geheimnis */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Geheimnis für den Beweis — beim ersten Aufruf erzeugt.
|
||||||
|
*
|
||||||
|
* Bewusst in der Datenbank und nicht im Arbeitsspeicher: Die prüfende Route
|
||||||
|
* und die antwortende Route sind zwei getrennte Bündel und teilen sich keinen
|
||||||
|
* Modulzustand. Ein Wert im Speicher wäre auf beiden Seiten ein anderer.
|
||||||
|
*/
|
||||||
|
export async function getCheckSecret(): Promise<string> {
|
||||||
|
const vorhanden = await prisma.instanceSetting.findUnique({
|
||||||
|
where: { id: "instance" },
|
||||||
|
select: { checkSecret: true },
|
||||||
|
});
|
||||||
|
if (vorhanden?.checkSecret) return vorhanden.checkSecret;
|
||||||
|
|
||||||
|
const neu = randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
// `updateMany` mit der Bedingung „noch leer“ statt eines schlichten
|
||||||
|
// `update`: Zwei gleichzeitige Prüfungen sollen nicht zwei verschiedene
|
||||||
|
// Geheimnisse hinterlassen, von denen eines sofort wieder ungültig ist.
|
||||||
|
await prisma.instanceSetting.upsert({
|
||||||
|
where: { id: "instance" },
|
||||||
|
create: { id: "instance", checkSecret: neu },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
await prisma.instanceSetting.updateMany({
|
||||||
|
where: { id: "instance", checkSecret: null },
|
||||||
|
data: { checkSecret: neu },
|
||||||
|
});
|
||||||
|
|
||||||
|
const danach = await prisma.instanceSetting.findUnique({
|
||||||
|
where: { id: "instance" },
|
||||||
|
select: { checkSecret: true },
|
||||||
|
});
|
||||||
|
return danach?.checkSecret ?? neu;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bildeBeweis(secret: string, nonce: string): string {
|
||||||
|
return createHmac("sha256", secret).update(nonce).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function gleich(a: string, b: string): boolean {
|
||||||
|
const links = Buffer.from(a, "utf8");
|
||||||
|
const rechts = Buffer.from(b, "utf8");
|
||||||
|
if (links.length !== rechts.length) return false;
|
||||||
|
return timingSafeEqual(links, rechts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ DNS */
|
||||||
|
|
||||||
|
function trenneHost(hostname: string): { host: string; port: number | null } {
|
||||||
|
const treffer = /^(.+):(\d{1,5})$/.exec(hostname);
|
||||||
|
if (!treffer) return { host: hostname, port: null };
|
||||||
|
return { host: treffer[1], port: Number(treffer[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruefeDns(host: string): Promise<Pruefschritt> {
|
||||||
|
try {
|
||||||
|
const adressen = await lookup(host, { all: true });
|
||||||
|
if (adressen.length === 0) {
|
||||||
|
return {
|
||||||
|
name: "dns",
|
||||||
|
titel: "DNS",
|
||||||
|
status: "fehler",
|
||||||
|
detail: `Für ${host} ist keine Adresse hinterlegt.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: "dns",
|
||||||
|
titel: "DNS",
|
||||||
|
status: "ok",
|
||||||
|
detail: `löst auf ${adressen.map((a) => a.address).join(", ")}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { code?: string }).code;
|
||||||
|
return {
|
||||||
|
name: "dns",
|
||||||
|
titel: "DNS",
|
||||||
|
status: "fehler",
|
||||||
|
detail:
|
||||||
|
code === "ENOTFOUND"
|
||||||
|
? `Der Name ${host} ist unbekannt — es fehlt der A- oder AAAA-Eintrag.`
|
||||||
|
: `Der Name ${host} ließ sich nicht auflösen (${code ?? "unbekannter Fehler"}).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- Zertifikat */
|
||||||
|
|
||||||
|
type Zertifikatslage =
|
||||||
|
| { erreichbar: true; schritt: Pruefschritt }
|
||||||
|
| { erreichbar: false; schritt: Pruefschritt };
|
||||||
|
|
||||||
|
const datumsformat = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
|
||||||
|
|
||||||
|
function beschreibeZertifikat(
|
||||||
|
zertifikat: PeerCertificate,
|
||||||
|
authorized: boolean,
|
||||||
|
fehler: string | undefined,
|
||||||
|
): Pruefschritt {
|
||||||
|
const bis = new Date(zertifikat.valid_to);
|
||||||
|
const tage = Math.floor((bis.getTime() - Date.now()) / 86_400_000);
|
||||||
|
// Die Felder eines Zertifikats dürfen mehrfach vorkommen; Node liefert sie
|
||||||
|
// dann als Liste.
|
||||||
|
const ersteZeile = (wert: string | string[] | undefined): string | undefined =>
|
||||||
|
Array.isArray(wert) ? wert[0] : wert;
|
||||||
|
const aussteller =
|
||||||
|
ersteZeile(zertifikat.issuer?.O) ??
|
||||||
|
ersteZeile(zertifikat.issuer?.CN) ??
|
||||||
|
"unbekannt";
|
||||||
|
|
||||||
|
if (!authorized) {
|
||||||
|
return {
|
||||||
|
name: "zertifikat",
|
||||||
|
titel: "Zertifikat",
|
||||||
|
status: "fehler",
|
||||||
|
detail: uebersetzeTlsFehler(fehler, aussteller, bis),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (tage < 0) {
|
||||||
|
return {
|
||||||
|
name: "zertifikat",
|
||||||
|
titel: "Zertifikat",
|
||||||
|
status: "fehler",
|
||||||
|
detail: `abgelaufen am ${datumsformat.format(bis)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: "zertifikat",
|
||||||
|
titel: "Zertifikat",
|
||||||
|
status: tage <= 14 ? "warnung" : "ok",
|
||||||
|
detail:
|
||||||
|
tage <= 14
|
||||||
|
? `läuft in ${tage} Tagen ab (${datumsformat.format(bis)}), ausgestellt von ${aussteller}`
|
||||||
|
: `gültig bis ${datumsformat.format(bis)}, ausgestellt von ${aussteller}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uebersetzeTlsFehler(
|
||||||
|
fehler: string | undefined,
|
||||||
|
aussteller: string,
|
||||||
|
bis: Date,
|
||||||
|
): string {
|
||||||
|
switch (fehler) {
|
||||||
|
case "CERT_HAS_EXPIRED":
|
||||||
|
return `abgelaufen am ${datumsformat.format(bis)}`;
|
||||||
|
case "DEPTH_ZERO_SELF_SIGNED_CERT":
|
||||||
|
case "SELF_SIGNED_CERT_IN_CHAIN":
|
||||||
|
return "selbst ausgestellt — Scanner-Apps brechen darauf ohne Erklärung ab";
|
||||||
|
case "ERR_TLS_CERT_ALTNAME_INVALID":
|
||||||
|
return "gilt für einen anderen Namen — im Proxy ist die falsche Domain hinterlegt";
|
||||||
|
case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
|
||||||
|
return `Zwischenzertifikat fehlt — der Proxy liefert die Kette unvollständig aus (${aussteller})`;
|
||||||
|
default:
|
||||||
|
return `nicht gültig (${fehler ?? "unbekannter Grund"})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Grund, aus dem ein Zertifikat durchfiel.
|
||||||
|
*
|
||||||
|
* Node liefert ihn je nach Fassung als Fehlerobjekt mit `code`, als
|
||||||
|
* Fehlerobjekt ohne `code` oder schlicht als Zeichenkette.
|
||||||
|
*/
|
||||||
|
function tlsGrund(fehler: unknown): string | undefined {
|
||||||
|
if (fehler === null || fehler === undefined) return undefined;
|
||||||
|
if (typeof fehler === "string") return fehler;
|
||||||
|
const objekt = fehler as { code?: unknown; message?: unknown };
|
||||||
|
if (typeof objekt.code === "string") return objekt.code;
|
||||||
|
if (typeof objekt.message === "string") return objekt.message;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruefeZertifikat(host: string): Promise<Zertifikatslage> {
|
||||||
|
return new Promise((aufloesen) => {
|
||||||
|
// `rejectUnauthorized: false`, um ein schlechtes Zertifikat **beschreiben**
|
||||||
|
// zu können statt nur an ihm zu scheitern. Übertragen wird hierüber nichts.
|
||||||
|
const socket = connect({
|
||||||
|
host,
|
||||||
|
port: 443,
|
||||||
|
servername: host,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeout: ZEITGRENZE_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fertig = (lage: Zertifikatslage) => {
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
aufloesen(lage);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.once("secureConnect", () => {
|
||||||
|
fertig({
|
||||||
|
erreichbar: true,
|
||||||
|
schritt: beschreibeZertifikat(
|
||||||
|
socket.getPeerCertificate(),
|
||||||
|
socket.authorized,
|
||||||
|
tlsGrund(socket.authorizationError),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.once("timeout", () => {
|
||||||
|
fertig({
|
||||||
|
erreichbar: false,
|
||||||
|
schritt: {
|
||||||
|
name: "zertifikat",
|
||||||
|
titel: "Zertifikat",
|
||||||
|
status: "fehler",
|
||||||
|
detail: "Auf Port 443 kam keine Antwort — läuft dort ein Webserver?",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.once("error", (error: NodeJS.ErrnoException) => {
|
||||||
|
fertig({
|
||||||
|
erreichbar: false,
|
||||||
|
schritt: {
|
||||||
|
name: "zertifikat",
|
||||||
|
titel: "Zertifikat",
|
||||||
|
status: "fehler",
|
||||||
|
detail:
|
||||||
|
error.code === "ECONNREFUSED"
|
||||||
|
? "Port 443 nimmt keine Verbindung an — es gibt hier kein HTTPS."
|
||||||
|
: `Die TLS-Verbindung scheiterte (${error.code ?? error.message}).`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- Aufruf */
|
||||||
|
|
||||||
|
type Abrufergebnis = {
|
||||||
|
erreichbar: Pruefschritt;
|
||||||
|
ziel: Pruefschritt;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der eigentliche Grund hinter einem `fetch failed`.
|
||||||
|
*
|
||||||
|
* Node verpackt Netzwerkfehler; die Meldung an der Oberfläche lautet sonst
|
||||||
|
* schlicht „fetch failed" und hilft niemandem weiter.
|
||||||
|
*/
|
||||||
|
function ursache(error: unknown): string | undefined {
|
||||||
|
const grund = (error as { cause?: unknown }).cause;
|
||||||
|
if (grund === null || grund === undefined) return undefined;
|
||||||
|
const objekt = grund as { code?: unknown; message?: unknown };
|
||||||
|
if (typeof objekt.code === "string") return objekt.code;
|
||||||
|
if (typeof objekt.message === "string") return objekt.message;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rufeAuf(
|
||||||
|
basis: string,
|
||||||
|
nonce: string,
|
||||||
|
secret: string,
|
||||||
|
): Promise<Abrufergebnis> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const antwort = await fetch(`${basis}/api/domain-check?nonce=${nonce}`, {
|
||||||
|
redirect: "follow",
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(ZEITGRENZE_MS),
|
||||||
|
headers: { "User-Agent": `${APP_KENNUNG}-domain-check` },
|
||||||
|
});
|
||||||
|
const dauer = Date.now() - start;
|
||||||
|
|
||||||
|
if (!antwort.ok) {
|
||||||
|
return {
|
||||||
|
erreichbar: {
|
||||||
|
name: "erreichbar",
|
||||||
|
titel: "Erreichbar",
|
||||||
|
status: "fehler",
|
||||||
|
detail: `HTTP ${antwort.status} nach ${dauer} ms`,
|
||||||
|
},
|
||||||
|
ziel: {
|
||||||
|
name: "ziel",
|
||||||
|
titel: "Ziel",
|
||||||
|
status: "fehler",
|
||||||
|
detail:
|
||||||
|
antwort.status === 404
|
||||||
|
? "Es antwortet zwar etwas, aber nicht diese Anwendung — der Proxy leitet woandershin."
|
||||||
|
: "Wegen der Fehlerantwort nicht feststellbar.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const erreichbar: Pruefschritt = {
|
||||||
|
name: "erreichbar",
|
||||||
|
titel: "Erreichbar",
|
||||||
|
status: dauer > 3000 ? "warnung" : "ok",
|
||||||
|
detail: `HTTP ${antwort.status} nach ${dauer} ms`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const koerper = (await antwort.json().catch(() => null)) as
|
||||||
|
| Partial<Domainbeweis>
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
koerper === null ||
|
||||||
|
koerper.app !== APP_KENNUNG ||
|
||||||
|
typeof koerper.proof !== "string"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
erreichbar,
|
||||||
|
ziel: {
|
||||||
|
name: "ziel",
|
||||||
|
titel: "Ziel",
|
||||||
|
status: "fehler",
|
||||||
|
detail:
|
||||||
|
"Die Antwort stammt nicht von dieser Anwendung — hinter der Domain steht etwas anderes.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
erreichbar,
|
||||||
|
ziel: gleich(koerper.proof, bildeBeweis(secret, nonce))
|
||||||
|
? {
|
||||||
|
name: "ziel",
|
||||||
|
titel: "Ziel",
|
||||||
|
status: "ok",
|
||||||
|
detail: "diese Installation",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
name: "ziel",
|
||||||
|
titel: "Ziel",
|
||||||
|
status: "fehler",
|
||||||
|
detail:
|
||||||
|
"Es antwortet eine andere Installation dieser Anwendung, nicht diese hier.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { code?: string; name?: string }).code;
|
||||||
|
const name = (error as { name?: string }).name;
|
||||||
|
const grund =
|
||||||
|
name === "TimeoutError"
|
||||||
|
? `Keine Antwort binnen ${ZEITGRENZE_MS / 1000} Sekunden.`
|
||||||
|
: code === "ECONNREFUSED"
|
||||||
|
? "Die Verbindung wurde abgelehnt — dort nimmt kein Webserver an."
|
||||||
|
: `Der Aufruf scheiterte (${code ?? ursache(error) ?? (error as Error).message}).`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
erreichbar: {
|
||||||
|
name: "erreichbar",
|
||||||
|
titel: "Erreichbar",
|
||||||
|
status: "fehler",
|
||||||
|
detail: grund,
|
||||||
|
},
|
||||||
|
ziel: {
|
||||||
|
name: "ziel",
|
||||||
|
titel: "Ziel",
|
||||||
|
status: "fehler",
|
||||||
|
detail: "Ohne Antwort nicht feststellbar.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Ganzes */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft eine registrierte Domain von diesem Server aus.
|
||||||
|
*
|
||||||
|
* Der Hostname stammt aus der Datenbank, nicht aus der Anfrage — es lässt
|
||||||
|
* sich hierüber also keine beliebige Adresse abrufen.
|
||||||
|
*/
|
||||||
|
export async function pruefeDomain(hostname: string): Promise<Pruefbericht> {
|
||||||
|
const { host, port } = trenneHost(hostname);
|
||||||
|
const secret = await getCheckSecret();
|
||||||
|
const nonce = randomBytes(16).toString("hex");
|
||||||
|
|
||||||
|
const schritte: Pruefschritt[] = [];
|
||||||
|
const dns = await pruefeDns(host);
|
||||||
|
schritte.push(dns);
|
||||||
|
|
||||||
|
// Ohne aufgelösten Namen gibt es nichts anzurufen. Die Folgeschritte
|
||||||
|
// trotzdem zu versuchen brächte drei weitere Fehlermeldungen, die alle
|
||||||
|
// dasselbe sagen — und die eine, auf die es ankommt, ginge darin unter.
|
||||||
|
if (dns.status === "fehler") {
|
||||||
|
for (const [name, titel] of [
|
||||||
|
["zertifikat", "Zertifikat"],
|
||||||
|
["erreichbar", "Erreichbar"],
|
||||||
|
["ziel", "Ziel"],
|
||||||
|
] as [SchrittName, string][]) {
|
||||||
|
schritte.push({
|
||||||
|
name,
|
||||||
|
titel,
|
||||||
|
status: "uebersprungen",
|
||||||
|
detail: "Ohne aufgelösten Namen nicht prüfbar.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { hostname, geprueft: host, gesamt: "fehler", schritte };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Eintrag mit Port meint eine Adresse ohne Reverse Proxy — etwa die
|
||||||
|
// Standard-Domain aus BASE_URL im Entwicklungsbetrieb. Dort gibt es kein
|
||||||
|
// Zertifikat zu prüfen, und der Aufruf läuft über http.
|
||||||
|
let basis: string;
|
||||||
|
if (port !== null) {
|
||||||
|
schritte.push({
|
||||||
|
name: "zertifikat",
|
||||||
|
titel: "Zertifikat",
|
||||||
|
status: "uebersprungen",
|
||||||
|
detail: `Der Eintrag nennt Port ${port} — geprüft wird ohne HTTPS.`,
|
||||||
|
});
|
||||||
|
basis = `http://${host}:${port}`;
|
||||||
|
} else {
|
||||||
|
const lage = await pruefeZertifikat(host);
|
||||||
|
schritte.push(lage.schritt);
|
||||||
|
// Ohne HTTPS lohnt der zweite Anlauf über Port 80: So steht im Bericht,
|
||||||
|
// ob überhaupt etwas antwortet — das ist der Unterschied zwischen
|
||||||
|
// „Zertifikat fehlt" und „da ist gar nichts".
|
||||||
|
basis = lage.erreichbar ? `https://${host}` : `http://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { erreichbar, ziel } = await rufeAuf(basis, nonce, secret);
|
||||||
|
schritte.push(erreichbar, ziel);
|
||||||
|
|
||||||
|
const gesamt = schritte.some((s) => s.status === "fehler")
|
||||||
|
? "fehler"
|
||||||
|
: schritte.some((s) => s.status === "warnung")
|
||||||
|
? "warnung"
|
||||||
|
: "ok";
|
||||||
|
|
||||||
|
return { hostname, geprueft: basis, gesamt, schritte };
|
||||||
|
}
|
||||||
@@ -1,21 +1,31 @@
|
|||||||
import type { EcLevel } from "@/lib/qr/design";
|
import type { EcLevel, LogoSource } from "@/lib/qr/design";
|
||||||
import type { QrTypeId } from "@/lib/qr/type-module";
|
import type { QrTypeId } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
/** Statisch oder dynamisch — bestimmt, was ein Code überhaupt kann. */
|
||||||
|
export type QrCodeMode = "static" | "dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Darstellung eines dynamischen Codes an der API-Grenze. Bewusst frei von
|
* Darstellung eines gespeicherten Codes an der API-Grenze. Bewusst frei von
|
||||||
* Server-Abhängigkeiten, damit Client-Komponenten den Typ verwenden können.
|
* Server-Abhängigkeiten, damit Client-Komponenten den Typ verwenden können.
|
||||||
*/
|
*/
|
||||||
export type QrCodeDto = {
|
export type QrCodeDto = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
/**
|
||||||
|
* Ein statischer Code trägt seinen Inhalt selbst. Er ist nur abgelegt, damit
|
||||||
|
* man ihn wiederfindet und erneut herunterladen kann — Kurz-Link, Ziel und
|
||||||
|
* Scan-Zähler bleiben deshalb leer.
|
||||||
|
*/
|
||||||
|
mode: QrCodeMode;
|
||||||
|
/** Kurz-Link. Leer bei statischen Codes. */
|
||||||
|
slug: string | null;
|
||||||
/** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */
|
/** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
type: QrTypeId;
|
type: QrTypeId;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
/** Die Kurz-URL, die im QR-Code steckt. */
|
/** Die Kurz-URL, die im QR-Code steckt. Leer bei statischen Codes. */
|
||||||
shortUrl: string;
|
shortUrl: string | null;
|
||||||
/** Aktuelles Weiterleitungsziel. */
|
/** Aktuelles Weiterleitungsziel. Leer bei statischen Codes. */
|
||||||
target: string;
|
target: string;
|
||||||
/** Lesbare Kurzfassung des Ziels. */
|
/** Lesbare Kurzfassung des Ziels. */
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -25,6 +35,9 @@ export type QrCodeDto = {
|
|||||||
size: number;
|
size: number;
|
||||||
ecLevel: EcLevel;
|
ecLevel: EcLevel;
|
||||||
margin: number;
|
margin: number;
|
||||||
|
logoSource: LogoSource | null;
|
||||||
|
logoUploadId: string | null;
|
||||||
|
logoScale: number;
|
||||||
};
|
};
|
||||||
scans: number;
|
scans: number;
|
||||||
lastScanAt: string | null;
|
lastScanAt: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { toJsonInput } from "@/lib/codes";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dublettenprüfung für statische Codes.
|
||||||
|
*
|
||||||
|
* Ein statischer Code trägt seinen Inhalt selbst — zweimal derselbe Inhalt
|
||||||
|
* ergibt zweimal exakt dasselbe Bild. Solche Einträge sind nicht falsch, aber
|
||||||
|
* sie fluten die Liste, und beim Wiederfinden weiß niemand mehr, welcher der
|
||||||
|
* beiden gemeint war.
|
||||||
|
*
|
||||||
|
* Bewusst **nur** für statische Codes: Zwei dynamische Codes mit demselben
|
||||||
|
* Ziel sind ein üblicher Fall — zwei Kampagnen, zwei Kurz-Links, getrennte
|
||||||
|
* Zähler. Da wäre eine Warnung nur lästig.
|
||||||
|
*
|
||||||
|
* Bewusst **ohne** die Gestaltung: Verglichen wird der Inhalt. Derselbe Inhalt
|
||||||
|
* einmal in Schwarz-Weiß für den Druck und einmal in Hausfarben ist zwar
|
||||||
|
* bewusst zweimal angelegt — aber eben auch zweimal derselbe Code. Die
|
||||||
|
* Warnung sagt das und lässt sich übergehen.
|
||||||
|
*/
|
||||||
|
export type StaticDuplicate = {
|
||||||
|
id: string;
|
||||||
|
label: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht einen bereits abgelegten statischen Code mit gleichem Typ und
|
||||||
|
* gleichem Inhalt in derselben Gruppe — den zuletzt angelegten.
|
||||||
|
*
|
||||||
|
* Die Gruppe grenzt bewusst ein: Ein Treffer aus einer fremden Gruppe wäre
|
||||||
|
* nicht bloß nutzlos, er verriete auch deren Bestand.
|
||||||
|
*/
|
||||||
|
export async function findStaticDuplicate(
|
||||||
|
groupId: string,
|
||||||
|
type: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<StaticDuplicate | null> {
|
||||||
|
const treffer = await prisma.qrCode.findFirst({
|
||||||
|
where: {
|
||||||
|
mode: "static",
|
||||||
|
type,
|
||||||
|
groupId,
|
||||||
|
// Postgres vergleicht jsonb nach Struktur, nicht nach Schreibweise —
|
||||||
|
// Schlüsselreihenfolge und Leerzeichen spielen keine Rolle.
|
||||||
|
data: { equals: toJsonInput(data) },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { id: true, label: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!treffer) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: treffer.id,
|
||||||
|
label: treffer.label,
|
||||||
|
createdAt: treffer.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
|
import type { Actor } from "@/lib/auth/actor";
|
||||||
|
import { visibleGroupIds } from "@/lib/auth/policy";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getQrType, qrTypeList } from "@/lib/qr/registry";
|
import { getQrType, qrTypeList } from "@/lib/qr/registry";
|
||||||
import {
|
import {
|
||||||
@@ -15,10 +17,21 @@ import {
|
|||||||
* Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann:
|
* Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann:
|
||||||
*
|
*
|
||||||
* - `implemented` — steht in der Registry und sagt, ob der Typ überhaupt
|
* - `implemented` — steht in der Registry und sagt, ob der Typ überhaupt
|
||||||
* ausprogrammiert ist. In V1 gilt das nur für „Website“.
|
* ausprogrammiert ist. Seit 1.1.0 gilt das für alle fünfzehn; die
|
||||||
|
* Unterscheidung bleibt für Typen, die später dazukommen.
|
||||||
* - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ
|
* - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ
|
||||||
* freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben.
|
* freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben.
|
||||||
*
|
*
|
||||||
|
* Die Freigabe hat zwei Ebenen. **Global** gilt für die ganze Installation
|
||||||
|
* und ist die Grundlage; **je Gruppe** lässt sich davon abweichen, damit die
|
||||||
|
* Marketingabteilung Social Media bekommt und die Werkstatt nur Website und
|
||||||
|
* PDF. Fehlt eine Abweichung, folgt die Gruppe der globalen Einstellung.
|
||||||
|
*
|
||||||
|
* **Die globale Abschaltung gewinnt immer.** Ein global abgeschalteter Typ
|
||||||
|
* lässt sich in keiner Gruppe wieder einschalten. Andersherum wäre „hier
|
||||||
|
* abgeschaltet“ keine verlässliche Aussage mehr, und wer den Schalter umlegt,
|
||||||
|
* müsste alle Gruppen durchsehen, um zu wissen, was er getan hat.
|
||||||
|
*
|
||||||
* Ein abgeschalteter Typ verschwindet aus der Oberfläche und lässt sich nicht
|
* Ein abgeschalteter Typ verschwindet aus der Oberfläche und lässt sich nicht
|
||||||
* mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren
|
* mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren
|
||||||
* weiter** — sie sind gedruckt und verteilt, und ein Schalter in der
|
* weiter** — sie sind gedruckt und verteilt, und ein Schalter in der
|
||||||
@@ -29,8 +42,15 @@ export type QrTypeAvailability = {
|
|||||||
qrType: QrTypeModule;
|
qrType: QrTypeModule;
|
||||||
/** Der Typ ist in dieser Version ausprogrammiert. */
|
/** Der Typ ist in dieser Version ausprogrammiert. */
|
||||||
implemented: boolean;
|
implemented: boolean;
|
||||||
/** Die Verwaltung hat den Typ freigegeben. */
|
/** Die Verwaltung hat den Typ freigegeben — global und in der Gruppe. */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** Die globale Freigabe für sich genommen, ohne die Gruppe. */
|
||||||
|
enabledGlobally: boolean;
|
||||||
|
/**
|
||||||
|
* Nur bei einer Abfrage für eine bestimmte Gruppe gesetzt: Ist der Typ dort
|
||||||
|
* ausdrücklich abgewählt? Ohne Abweichung `false`.
|
||||||
|
*/
|
||||||
|
disabledInGroup: boolean;
|
||||||
/** Freigegeben und ausprogrammiert — nur dann ist er benutzbar. */
|
/** Freigegeben und ausprogrammiert — nur dann ist er benutzbar. */
|
||||||
usable: boolean;
|
usable: boolean;
|
||||||
/** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */
|
/** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */
|
||||||
@@ -56,16 +76,52 @@ async function loadDisabledTypes(): Promise<ReadonlySet<string>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verfügbarkeit aller Typen in Anzeigereihenfolge. */
|
/** Abweichungen einer Gruppe; leer, wenn sie der globalen Einstellung folgt. */
|
||||||
export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> {
|
async function loadGroupDisabledTypes(
|
||||||
|
groupId: string,
|
||||||
|
): Promise<ReadonlySet<string>> {
|
||||||
|
try {
|
||||||
|
const rows = await prisma.qrTypeGroupSetting.findMany({
|
||||||
|
where: { groupId, enabled: false },
|
||||||
|
select: { type: true },
|
||||||
|
});
|
||||||
|
return new Set(rows.map((row) => row.type));
|
||||||
|
} catch (error) {
|
||||||
|
// Wie oben: im Zweifel lieber alles zeigen als eine leere Startseite.
|
||||||
|
console.error(
|
||||||
|
"Freigaben der QR-Typen je Gruppe konnten nicht geladen werden — es gilt die globale Einstellung:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verfügbarkeit aller Typen in Anzeigereihenfolge.
|
||||||
|
*
|
||||||
|
* Ohne `groupId` gilt allein die globale Freigabe — das ist die Sicht der
|
||||||
|
* Verwaltung auf die Grundeinstellung.
|
||||||
|
*/
|
||||||
|
export async function getQrTypeAvailability(
|
||||||
|
groupId?: string,
|
||||||
|
): Promise<QrTypeAvailability[]> {
|
||||||
const disabled = await loadDisabledTypes();
|
const disabled = await loadDisabledTypes();
|
||||||
|
const inGruppeAbgewaehlt =
|
||||||
|
groupId === undefined
|
||||||
|
? new Set<string>()
|
||||||
|
: await loadGroupDisabledTypes(groupId);
|
||||||
|
|
||||||
return qrTypeList.map((qrType) => {
|
return qrTypeList.map((qrType) => {
|
||||||
const enabled = !disabled.has(qrType.id);
|
const global = !disabled.has(qrType.id);
|
||||||
|
const inGruppe = inGruppeAbgewaehlt.has(qrType.id);
|
||||||
|
// Global aus gewinnt immer — eine Gruppe kann das nicht aufheben.
|
||||||
|
const enabled = global && !inGruppe;
|
||||||
return {
|
return {
|
||||||
qrType,
|
qrType,
|
||||||
implemented: qrType.implemented,
|
implemented: qrType.implemented,
|
||||||
enabled,
|
enabled,
|
||||||
|
enabledGlobally: global,
|
||||||
|
disabledInGroup: inGruppe,
|
||||||
usable: enabled && qrType.implemented,
|
usable: enabled && qrType.implemented,
|
||||||
announced: enabled && !qrType.implemented,
|
announced: enabled && !qrType.implemented,
|
||||||
};
|
};
|
||||||
@@ -73,15 +129,61 @@ export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Nur die Typen, die in der Oberfläche erscheinen sollen. */
|
/** Nur die Typen, die in der Oberfläche erscheinen sollen. */
|
||||||
export async function getVisibleQrTypes(): Promise<QrTypeAvailability[]> {
|
export async function getVisibleQrTypes(
|
||||||
return (await getQrTypeAvailability()).filter((entry) => entry.enabled);
|
groupId?: string,
|
||||||
|
): Promise<QrTypeAvailability[]> {
|
||||||
|
return (await getQrTypeAvailability(groupId)).filter((entry) => entry.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was jemand auf der Startseite sehen soll: die **Vereinigung** über seine
|
||||||
|
* Gruppen.
|
||||||
|
*
|
||||||
|
* Wer in der Werkstatt und im Marketing ist, sieht beides. Welche Gruppe
|
||||||
|
* beim Anlegen gilt, entscheidet sich erst dort — und wird dort auch geprüft.
|
||||||
|
*/
|
||||||
|
export async function getVisibleQrTypesForActor(
|
||||||
|
actor: Actor,
|
||||||
|
): Promise<QrTypeAvailability[]> {
|
||||||
|
const eigene = visibleGroupIds(actor);
|
||||||
|
|
||||||
|
// `null` heißt: alle Gruppen. Ohne Gruppen (oder ohne Anmeldung) bleibt es
|
||||||
|
// bei der globalen Einstellung.
|
||||||
|
const gruppen =
|
||||||
|
eigene === null
|
||||||
|
? (await prisma.group.findMany({ select: { id: true } })).map((g) => g.id)
|
||||||
|
: eigene;
|
||||||
|
|
||||||
|
if (gruppen.length === 0) return getVisibleQrTypes();
|
||||||
|
|
||||||
|
const je = await Promise.all(gruppen.map((id) => getVisibleQrTypes(id)));
|
||||||
|
const gesehen = new Set<string>();
|
||||||
|
const vereinigt: QrTypeAvailability[] = [];
|
||||||
|
|
||||||
|
// Über qrTypeList laufen statt über die Ergebnisse: so bleibt die
|
||||||
|
// Anzeigereihenfolge der Registry erhalten.
|
||||||
|
for (const qrType of qrTypeList) {
|
||||||
|
for (const liste of je) {
|
||||||
|
if (gesehen.has(qrType.id)) break;
|
||||||
|
const treffer = liste.find((entry) => entry.qrType.id === qrType.id);
|
||||||
|
if (treffer) {
|
||||||
|
gesehen.add(qrType.id);
|
||||||
|
vereinigt.push(treffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vereinigt;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getQrTypeState(
|
export async function getQrTypeState(
|
||||||
id: string,
|
id: string,
|
||||||
|
groupId?: string,
|
||||||
): Promise<QrTypeAvailability | undefined> {
|
): Promise<QrTypeAvailability | undefined> {
|
||||||
if (!isQrTypeId(id)) return undefined;
|
if (!isQrTypeId(id)) return undefined;
|
||||||
return (await getQrTypeAvailability()).find((entry) => entry.qrType.id === id);
|
return (await getQrTypeAvailability(groupId)).find(
|
||||||
|
(entry) => entry.qrType.id === id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,8 +192,9 @@ export async function getQrTypeState(
|
|||||||
*/
|
*/
|
||||||
export async function requireAvailableQrType(
|
export async function requireAvailableQrType(
|
||||||
id: string,
|
id: string,
|
||||||
|
groupId?: string,
|
||||||
): Promise<QrTypeModule> {
|
): Promise<QrTypeModule> {
|
||||||
const state = await getQrTypeState(id);
|
const state = await getQrTypeState(id, groupId);
|
||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
throw new QrRequestError("Unbekannter QR-Typ.", 404);
|
throw new QrRequestError("Unbekannter QR-Typ.", 404);
|
||||||
@@ -104,7 +207,9 @@ export async function requireAvailableQrType(
|
|||||||
}
|
}
|
||||||
if (!state.enabled) {
|
if (!state.enabled) {
|
||||||
throw new QrRequestError(
|
throw new QrRequestError(
|
||||||
`Der QR-Typ „${state.qrType.title}“ ist in der Verwaltung abgeschaltet.`,
|
state.enabledGlobally
|
||||||
|
? `Der QR-Typ „${state.qrType.title}“ ist für diese Gruppe abgeschaltet.`
|
||||||
|
: `Der QR-Typ „${state.qrType.title}“ ist in der Verwaltung abgeschaltet.`,
|
||||||
403,
|
403,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -128,7 +233,7 @@ export function requireImplementedQrType(id: string): QrTypeModule {
|
|||||||
return qrType;
|
return qrType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Freigabe eines Typs setzen. */
|
/** Globale Freigabe eines Typs setzen. */
|
||||||
export async function setQrTypeEnabled(
|
export async function setQrTypeEnabled(
|
||||||
id: QrTypeId,
|
id: QrTypeId,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
@@ -139,3 +244,30 @@ export async function setQrTypeEnabled(
|
|||||||
update: { enabled },
|
update: { enabled },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freigabe eines Typs für eine Gruppe setzen.
|
||||||
|
*
|
||||||
|
* `true` löscht die Abweichung, statt sie auf „an“ zu setzen: Die Gruppe
|
||||||
|
* folgt dann wieder der globalen Einstellung. Eine Zeile „an“ zu hinterlassen
|
||||||
|
* würde vortäuschen, sie könnte eine globale Abschaltung überstimmen — das
|
||||||
|
* kann sie nicht.
|
||||||
|
*/
|
||||||
|
export async function setQrTypeEnabledForGroup(
|
||||||
|
id: QrTypeId,
|
||||||
|
groupId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (enabled) {
|
||||||
|
await prisma.qrTypeGroupSetting.deleteMany({
|
||||||
|
where: { type: id, groupId },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.qrTypeGroupSetting.upsert({
|
||||||
|
where: { type_groupId: { type: id, groupId } },
|
||||||
|
create: { type: id, groupId, enabled: false },
|
||||||
|
update: { enabled: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ export const EC_LEVEL_LABELS: Record<EcLevel, string> = {
|
|||||||
export const MIN_SIZE = 256;
|
export const MIN_SIZE = 256;
|
||||||
export const MAX_SIZE = 2048;
|
export const MAX_SIZE = 2048;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logo in der Mitte des Codes.
|
||||||
|
*
|
||||||
|
* `branding` nimmt das Logo der Installation aus dem Erscheinungsbild,
|
||||||
|
* `upload` ein eigenes, das zu diesem Code gehört. Ohne Angabe kein Logo.
|
||||||
|
*/
|
||||||
|
export const LOGO_SOURCES = ["branding", "upload"] as const;
|
||||||
|
export type LogoSource = (typeof LOGO_SOURCES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kantenlänge des Logos in Prozent der Kantenlänge des Codes.
|
||||||
|
*
|
||||||
|
* Bei 20 % sind knapp 4 % der Fläche verdeckt — das steckt die
|
||||||
|
* Fehlerkorrektur H (30 %) mühelos weg. Die Obergrenze liegt bei 25 %, weil
|
||||||
|
* darüber weniger die Fehlerkorrektur das Problem ist als die Scanner-Apps:
|
||||||
|
* Manche suchen in der Mitte nach Ausrichtungsmustern.
|
||||||
|
*/
|
||||||
|
export const MIN_LOGO_SCALE = 10;
|
||||||
|
export const MAX_LOGO_SCALE = 25;
|
||||||
|
/** Ab hier ein Hinweis, dass es knapp wird. */
|
||||||
|
export const LOGO_SCALE_WARN = 22;
|
||||||
|
export const DEFAULT_LOGO_SCALE = 20;
|
||||||
|
|
||||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
const hexColor = z
|
const hexColor = z
|
||||||
@@ -39,7 +62,25 @@ export const designSchema = z.object({
|
|||||||
.int({ error: "Der Rand muss eine ganze Zahl sein." })
|
.int({ error: "Der Rand muss eine ganze Zahl sein." })
|
||||||
.min(0, { error: "Der Rand darf nicht negativ sein." })
|
.min(0, { error: "Der Rand darf nicht negativ sein." })
|
||||||
.max(16, { error: "Der Rand darf höchstens 16 Module breit sein." }),
|
.max(16, { error: "Der Rand darf höchstens 16 Module breit sein." }),
|
||||||
});
|
logoSource: z
|
||||||
|
.enum(LOGO_SOURCES, { error: "Unbekannte Herkunft des Logos." })
|
||||||
|
.nullable()
|
||||||
|
.default(null),
|
||||||
|
logoUploadId: z.string().trim().min(1).nullable().default(null),
|
||||||
|
logoScale: z.coerce
|
||||||
|
.number({ error: "Die Logogröße muss eine Zahl sein." })
|
||||||
|
.int({ error: "Die Logogröße muss eine ganze Zahl sein." })
|
||||||
|
.min(MIN_LOGO_SCALE, {
|
||||||
|
error: `Das Logo muss mindestens ${MIN_LOGO_SCALE} % breit sein.`,
|
||||||
|
})
|
||||||
|
.max(MAX_LOGO_SCALE, {
|
||||||
|
error: `Das Logo darf höchstens ${MAX_LOGO_SCALE} % breit sein — darüber wird der Code unzuverlässig.`,
|
||||||
|
})
|
||||||
|
.default(DEFAULT_LOGO_SCALE),
|
||||||
|
}).refine(
|
||||||
|
(design) => design.logoSource !== "upload" || design.logoUploadId !== null,
|
||||||
|
{ error: "Für ein eigenes Logo fehlt die hochgeladene Datei." },
|
||||||
|
);
|
||||||
|
|
||||||
export type QrDesign = z.infer<typeof designSchema>;
|
export type QrDesign = z.infer<typeof designSchema>;
|
||||||
|
|
||||||
@@ -49,8 +90,21 @@ export const defaultDesign: QrDesign = {
|
|||||||
size: 512,
|
size: 512,
|
||||||
ecLevel: "M",
|
ecLevel: "M",
|
||||||
margin: 4,
|
margin: 4,
|
||||||
|
logoSource: null,
|
||||||
|
logoUploadId: null,
|
||||||
|
logoScale: DEFAULT_LOGO_SCALE,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Logo verdeckt einen Teil des Codes. Was fehlt, muss die Fehlerkorrektur
|
||||||
|
* ausgleichen — deshalb gilt mit Logo immer **H**, unabhängig davon, was in
|
||||||
|
* der Auswahl steht. Die Oberfläche sagt das dazu; heimlich geschieht hier
|
||||||
|
* nichts.
|
||||||
|
*/
|
||||||
|
export function effectiveEcLevel(design: QrDesign): EcLevel {
|
||||||
|
return design.logoSource === null ? design.ecLevel : "H";
|
||||||
|
}
|
||||||
|
|
||||||
/** Farbvorschläge für die schnelle Auswahl in der Oberfläche. */
|
/** Farbvorschläge für die schnelle Auswahl in der Oberfläche. */
|
||||||
export const COLOR_PRESETS: ReadonlyArray<{
|
export const COLOR_PRESETS: ReadonlyArray<{
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { getBranding } from "@/lib/branding";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { QrDesign } from "@/lib/qr/design";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
/** Das Bild, das in die Mitte des Codes kommt. */
|
||||||
|
export type QrLogo = {
|
||||||
|
bytes: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt das Logo, das die Gestaltung verlangt — oder `null`.
|
||||||
|
*
|
||||||
|
* Zwei Quellen: das Logo der Installation aus dem Erscheinungsbild, oder eine
|
||||||
|
* Datei, die zu diesem Code hochgeladen wurde. Beides liegt als Upload in der
|
||||||
|
* Datenbank.
|
||||||
|
*
|
||||||
|
* Fehlt die Datei, ist das kein stiller Ausfall: Ein Code, der ohne Logo
|
||||||
|
* herauskommt, obwohl eines eingestellt war, fiele erst nach dem Druck auf.
|
||||||
|
*/
|
||||||
|
export async function ladeLogo(design: QrDesign): Promise<QrLogo | null> {
|
||||||
|
if (design.logoSource === null) return null;
|
||||||
|
|
||||||
|
const id =
|
||||||
|
design.logoSource === "branding"
|
||||||
|
? (await getBranding()).logoUploadId
|
||||||
|
: design.logoUploadId;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
design.logoSource === "branding"
|
||||||
|
? "Für diese Installation ist kein Logo hinterlegt — unter Verwaltung → Erscheinungsbild lässt sich eines hochladen."
|
||||||
|
: "Das hochgeladene Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const datei = await prisma.upload.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { data: true, mimeType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!datei) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Das Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bytes: Buffer.from(datei.data), mimeType: datei.mimeType };
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
|
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
import type { QrDesign } from "@/lib/qr/design";
|
import { effectiveEcLevel, type QrDesign } from "@/lib/qr/design";
|
||||||
|
import type { QrLogo } from "@/lib/qr/logo";
|
||||||
import { UserFacingError } from "@/lib/qr/type-module";
|
import { UserFacingError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
/** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */
|
/** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */
|
||||||
@@ -10,7 +12,8 @@ export class QrRenderError extends UserFacingError {}
|
|||||||
|
|
||||||
function toOptions(design: QrDesign): QRCodeRenderersOptions {
|
function toOptions(design: QrDesign): QRCodeRenderersOptions {
|
||||||
return {
|
return {
|
||||||
errorCorrectionLevel: design.ecLevel,
|
// Mit Logo gilt immer H — siehe `effectiveEcLevel`.
|
||||||
|
errorCorrectionLevel: effectiveEcLevel(design),
|
||||||
margin: design.margin,
|
margin: design.margin,
|
||||||
width: design.size,
|
width: design.size,
|
||||||
color: {
|
color: {
|
||||||
@@ -30,12 +33,103 @@ function wrap(error: unknown): never {
|
|||||||
throw new QrRenderError(`Der QR-Code konnte nicht erzeugt werden: ${detail}`);
|
throw new QrRenderError(`Der QR-Code konnte nicht erzeugt werden: ${detail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- Logo */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie das Logo in den Code kommt.
|
||||||
|
*
|
||||||
|
* Hinter dem Bild liegt eine Fläche in der Hintergrundfarbe — ohne sie stünde
|
||||||
|
* das Logo auf den Modulen und ginge optisch unter. Der Rand dieser Fläche
|
||||||
|
* ist ein Zehntel der Logobreite; das reicht, um es abzusetzen, ohne mehr
|
||||||
|
* Code zu verdecken als nötig.
|
||||||
|
*
|
||||||
|
* Das Bild selbst wird eingepasst, nicht beschnitten (`contain`): Ein
|
||||||
|
* Firmenlogo im Querformat soll als Ganzes zu sehen sein.
|
||||||
|
*/
|
||||||
|
const PAD_ANTEIL = 0.1;
|
||||||
|
|
||||||
|
/** Kantenlänge von Bild und Fläche, in Bildpunkten. */
|
||||||
|
function logoMasse(gesamt: number, prozent: number) {
|
||||||
|
const bild = Math.round((gesamt * prozent) / 100);
|
||||||
|
const flaeche = Math.round(bild * (1 + 2 * PAD_ANTEIL));
|
||||||
|
return { bild, flaeche, versatz: Math.round((gesamt - flaeche) / 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function legeLogoAuf(
|
||||||
|
qrPng: Buffer,
|
||||||
|
design: QrDesign,
|
||||||
|
logo: QrLogo,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const { bild, flaeche, versatz } = logoMasse(design.size, design.logoScale);
|
||||||
|
|
||||||
|
// Erst das Bild auf die Fläche einpassen, dann die Fläche auf den Code.
|
||||||
|
// Zwei Schritte, weil `extend` den Rand in der Hintergrundfarbe zieht — das
|
||||||
|
// ergibt die Fläche, ohne eine zweite Zeichnung zu brauchen.
|
||||||
|
const rand = Math.round((flaeche - bild) / 2);
|
||||||
|
const kachel = await sharp(logo.bytes)
|
||||||
|
.resize(bild, bild, { fit: "contain", background: design.bgColor })
|
||||||
|
.extend({
|
||||||
|
top: rand,
|
||||||
|
bottom: flaeche - bild - rand,
|
||||||
|
left: rand,
|
||||||
|
right: flaeche - bild - rand,
|
||||||
|
background: design.bgColor,
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
return sharp(qrPng)
|
||||||
|
.composite([{ input: kachel, top: versatz, left: versatz }])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt das Logo in ein fertiges QR-SVG.
|
||||||
|
*
|
||||||
|
* Das SVG von `qrcode` hat ein `viewBox` in Modulen — gerechnet wird deshalb
|
||||||
|
* in Modulen und nicht in Bildpunkten. Eingefügt wird direkt vor `</svg>`,
|
||||||
|
* damit Fläche und Bild über den Modulen liegen.
|
||||||
|
*/
|
||||||
|
function legeLogoInSvg(svg: string, design: QrDesign, logo: QrLogo): string {
|
||||||
|
const treffer = /viewBox="0 0 (\d+(?:\.\d+)?) /.exec(svg);
|
||||||
|
if (!treffer) {
|
||||||
|
// Ohne viewBox ließe sich nichts sauber platzieren. Lieber ohne Logo
|
||||||
|
// ausliefern als an einer falschen Stelle.
|
||||||
|
console.error("QR-SVG ohne viewBox — Logo wird ausgelassen.");
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kante = Number(treffer[1]);
|
||||||
|
const bild = (kante * design.logoScale) / 100;
|
||||||
|
const flaeche = bild * (1 + 2 * PAD_ANTEIL);
|
||||||
|
const versatz = (kante - flaeche) / 2;
|
||||||
|
const bildVersatz = (kante - bild) / 2;
|
||||||
|
|
||||||
|
const daten = `data:${logo.mimeType};base64,${logo.bytes.toString("base64")}`;
|
||||||
|
const rund = (wert: number) => Number(wert.toFixed(3));
|
||||||
|
|
||||||
|
const einsatz =
|
||||||
|
`<rect x="${rund(versatz)}" y="${rund(versatz)}" width="${rund(flaeche)}" height="${rund(flaeche)}" fill="${design.bgColor}"/>` +
|
||||||
|
`<image x="${rund(bildVersatz)}" y="${rund(bildVersatz)}" width="${rund(bild)}" height="${rund(bild)}" ` +
|
||||||
|
`preserveAspectRatio="xMidYMid meet" href="${daten}"/>`;
|
||||||
|
|
||||||
|
return svg.replace("</svg>", `${einsatz}</svg>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Ausgabe */
|
||||||
|
|
||||||
export async function renderSvg(
|
export async function renderSvg(
|
||||||
content: string,
|
content: string,
|
||||||
design: QrDesign,
|
design: QrDesign,
|
||||||
|
logo: QrLogo | null = null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return await QRCode.toString(content, { ...toOptions(design), type: "svg" });
|
const svg = await QRCode.toString(content, {
|
||||||
|
...toOptions(design),
|
||||||
|
type: "svg",
|
||||||
|
});
|
||||||
|
return logo === null ? svg : legeLogoInSvg(svg, design, logo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
wrap(error);
|
wrap(error);
|
||||||
}
|
}
|
||||||
@@ -44,9 +138,14 @@ export async function renderSvg(
|
|||||||
export async function renderPng(
|
export async function renderPng(
|
||||||
content: string,
|
content: string,
|
||||||
design: QrDesign,
|
design: QrDesign,
|
||||||
|
logo: QrLogo | null = null,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
try {
|
try {
|
||||||
return await QRCode.toBuffer(content, { ...toOptions(design), type: "png" });
|
const png = await QRCode.toBuffer(content, {
|
||||||
|
...toOptions(design),
|
||||||
|
type: "png",
|
||||||
|
});
|
||||||
|
return logo === null ? png : await legeLogoAuf(png, design, logo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
wrap(error);
|
wrap(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export const qrRequestSchema = z.object({
|
|||||||
slug: z.string().trim().optional(),
|
slug: z.string().trim().optional(),
|
||||||
/** Domain, unter der die Kurz-URL entsteht. */
|
/** Domain, unter der die Kurz-URL entsteht. */
|
||||||
domainId: z.string().trim().optional(),
|
domainId: z.string().trim().optional(),
|
||||||
|
/** Name des Codes — bestimmt den Dateinamen beim Herunterladen. */
|
||||||
|
label: z.string().trim().max(120).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type QrRequest = z.infer<typeof qrRequestSchema>;
|
export type QrRequest = z.infer<typeof qrRequestSchema>;
|
||||||
@@ -137,16 +139,9 @@ export async function resolveQrRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */
|
/** Macht aus beliebigem Text einen brauchbaren Dateinamen-Bestandteil. */
|
||||||
export function downloadFilename(
|
function alsDateiname(text: string): string {
|
||||||
qrType: QrTypeModule,
|
return text
|
||||||
data: unknown,
|
|
||||||
extension: string,
|
|
||||||
): string {
|
|
||||||
let base = "qr-code";
|
|
||||||
try {
|
|
||||||
const summary = qrType
|
|
||||||
.summary(data)
|
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replaceAll("ä", "ae")
|
.replaceAll("ä", "ae")
|
||||||
.replaceAll("ö", "oe")
|
.replaceAll("ö", "oe")
|
||||||
@@ -155,7 +150,30 @@ export function downloadFilename(
|
|||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/^-+|-+$/g, "")
|
.replace(/^-+|-+$/g, "")
|
||||||
.slice(0, 60);
|
.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 {
|
} catch {
|
||||||
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
|
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ export const UPLOAD_KINDS = {
|
|||||||
// das dann bei jedem Seitenaufruf mitgeht.
|
// das dann bei jedem Seitenaufruf mitgeht.
|
||||||
maxBytes: 2 * 1024 * 1024,
|
maxBytes: 2 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
|
qrlogo: {
|
||||||
|
label: "Logo im Code",
|
||||||
|
// Ohne GIF: Ein bewegtes Bild wird beim Einsetzen ohnehin zum Standbild,
|
||||||
|
// und die Auswahl soll nicht mehr versprechen, als hinten herauskommt.
|
||||||
|
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/avif"],
|
||||||
|
accept: "image/png,image/jpeg,image/webp,image/avif",
|
||||||
|
// Das Logo landet auf wenigen hundert Bildpunkten in der Mitte des Codes.
|
||||||
|
// Mehr als zwei Megabyte bringen dort nichts.
|
||||||
|
maxBytes: 2 * 1024 * 1024,
|
||||||
|
},
|
||||||
favicon: {
|
favicon: {
|
||||||
label: "Favicon",
|
label: "Favicon",
|
||||||
// Zusätzlich ICO — viele haben genau das noch herumliegen.
|
// Zusätzlich ICO — viele haben genau das noch herumliegen.
|
||||||
|
|||||||
@@ -189,6 +189,36 @@ export async function attachUploads(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hängt **eine** Datei an einen Code, ohne die übrigen anzurühren.
|
||||||
|
*
|
||||||
|
* Gebraucht für das Logo aus der Gestaltung: Ändert sich nur das Aussehen
|
||||||
|
* eines Codes, sollen die Dateien seines Inhalts unberührt bleiben.
|
||||||
|
*/
|
||||||
|
export async function attachUpload(
|
||||||
|
qrCodeId: string,
|
||||||
|
uploadId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.upload.updateMany({
|
||||||
|
// Dieselbe Bedingung wie oben: keine fremde Datei an sich ziehen.
|
||||||
|
where: { id: uploadId, OR: [{ qrCodeId: null }, { qrCodeId }] },
|
||||||
|
data: { qrCodeId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst eine Datei wieder vom Code — sie wird danach als herrenlos weggeräumt.
|
||||||
|
*/
|
||||||
|
export async function detachUpload(
|
||||||
|
qrCodeId: string,
|
||||||
|
uploadId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.upload.updateMany({
|
||||||
|
where: { id: uploadId, qrCodeId },
|
||||||
|
data: { qrCodeId: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */
|
/** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */
|
||||||
const ORPHAN_HOURS = 24;
|
const ORPHAN_HOURS = 24;
|
||||||
|
|
||||||
|
|||||||