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

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

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

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

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

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

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

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

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

Refs #9
2026-08-21 01:58:24 +02:00
27 changed files with 1023 additions and 120 deletions
+74
View File
@@ -0,0 +1,74 @@
# Änderungen
Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der
Anwendung unter **Version und Änderungen**, erreichbar über die
Versionsnummer in der Fußzeile.
> Diese Datei wird erzeugt: `npm run changelog`.
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
## 1.6.0 — 21.08.2026
Sichtbare Version mit Änderungsverlauf.
- Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.
- Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.
- Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.
## 1.5.0 — 20.08.2026
Statische Codes lassen sich benennen und ablegen.
- Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.
- Der Name bestimmt den Dateinamen beim Herunterladen.
- Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.
## 1.4.0 — 17.08.2026
Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.
- Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.
- Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.
- Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.
- Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.
- Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.
## 1.3.0 — 16.08.2026
Eigenes Erscheinungsbild und ein Dark Mode.
- Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.
- Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.
- Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.
- Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.
## 1.2.0 — 15.08.2026
Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.
- Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.
- Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.
- Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.
- Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.
## 1.1.0 — 14.08.2026
Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.
- Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.
- Landingpages als eigene Gattung für Typen ohne fremdes Ziel.
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.
- Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.
- Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.
- „Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.
- Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.
## 1.0.0 — 13.08.2026
Erste lauffähige Fassung.
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.
- QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.
- Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.
- Download als PNG und SVG, serverseitig gerendert.
- Update-Skript für Linux, macOS und Windows.
+6 -1
View File
@@ -18,6 +18,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen
ablegen, um sie wiederzufinden.
@@ -34,6 +36,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
eigene Wahl.
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
nachträglich änderbar — die vorherige Adresse bleibt gültig.
- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt
zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
von selbst; alles Nötige steht in einer `.env`.
@@ -48,7 +52,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
Rollen und der Weg zurück
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
- **[Roadmap](docs/roadmap.md)** — was gebaut ist und was kommen könnte
- **[Änderungen](CHANGELOG.md)** — was in welcher Version dazugekommen ist
- **[Roadmap](docs/roadmap.md)** — was als Nächstes kommen könnte
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
Härtungen und was bewusst offen bleibt
+38
View File
@@ -122,6 +122,29 @@ Liste steht dort „statisch“ und ein Strich.
Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten —
der vorherige Eintrag bleibt als eigener Schnappschuss bestehen.
### Ein Logo in der Mitte
Ein Bild in die Mitte des Codes setzen, wie man es von Firmen-Codes kennt.
Zur Wahl stehen zwei Quellen:
- das **Logo dieser Installation** aus dem Erscheinungsbild — ein Haken
genügt, kein Upload je Code;
- ein **eigenes Logo** je Code, für Kundenprojekte mit fremdem Zeichen.
Die Größe ist zwischen 10 und 25 % der Kantenlänge einstellbar; voreingestellt
sind 20 %, das entspricht rund 4 % der Fläche. Hinter dem Bild liegt eine
Fläche in der Hintergrundfarbe, damit es sich absetzt.
**Die Fehlerkorrektur wird dabei auf H erzwungen** — was das Bild verdeckt,
muss sie ausgleichen. Die Auswahl steht dann fest auf H und sagt auch warum;
heimlich geschieht das nicht. Ab 22 % erscheint eine Warnung: Die
Fehlerkorrektur trägt das zwar, aber manche Scanner-Apps suchen in der Mitte
nach Ausrichtungsmustern.
Jeder erzeugte Code wird in der Prüfung wieder eingelesen — über mehrere
Größen, Prozentsätze und Inhaltslängen. Das ersetzt trotzdem nicht den einen
Scan mit dem eigenen Handy, bevor etwas in den Druck geht.
### Der Kurz-Link
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
@@ -500,6 +523,21 @@ alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
---
## Version und Änderungen
Die Fußzeile nennt die laufende Version und führt auf eine Seite mit dem
vollständigen Änderungsverlauf. Nach einem Update ist das die schnellste
Antwort auf die Frage, ob der Container wirklich mit dem neuen Abbild läuft:
Steht dort noch die alte Nummer, ist er es nicht.
Der Verlauf wird an **einer** Stelle gepflegt (`src/lib/changelog.ts`).
Daraus entstehen sowohl die Seite in der Anwendung als auch die
[CHANGELOG.md](../CHANGELOG.md) im Projekt (`npm run changelog`); eine
Prüfung wacht darüber, dass beides zusammenpasst. Zu jeder Version gibt es
einen Git-Tag.
---
## Schnittstelle
Alles, was die Oberfläche tut, geht auch über HTTP:
+9 -87
View File
@@ -7,94 +7,16 @@ Aufwände sind grobe Einschätzungen, keine Zusagen.
## Gebaut
### Version 1 — Grundgerüst (13.08.2026)
Der vollständige Verlauf mit Versionsnummern steht im
[Changelog](../CHANGELOG.md) — und in der Anwendung selbst unter
**Version und Änderungen**, erreichbar über die Versionsnummer in der
Fußzeile.
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, ohne Reverse
Proxy, Migrationen beim Start
- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein
Formular, ohne Eingriff in den übrigen Code
- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und
Scan-Zähler
- Gestaltung: Farben mit Kontrastwarnung, Größe, Fehlerkorrektur, Rand
- Download als PNG und SVG, serverseitig gerendert
- „Meine Codes“ mit Ziel ändern und Löschen
- Deutsche Oberfläche, deutsche Fehlermeldungen, Validierung mit Zod
- Update-Skript für Linux/macOS und Windows
### Typen einzeln freigeben (14.08.2026)
Ausblenden nicht gebrauchter Typen in der Verwaltung. Bereits erstellte Codes
eines abgeschalteten Typs funktionieren weiter.
### Phase A — acht weitere Typen (14.08.2026)
vCard, App-Link, WhatsApp, E-Mail, SMS, Standort, Zahlung (GiroCode und
Zahlungslink), WLAN.
### Phase B1 — Anmeldung, Gruppen, Rollen (14.08.2026)
Abschaltbare Anmeldung, Benutzerverwaltung, Gruppen mit den Rollen
Administrator, Bearbeiter und Betrachter, Sitzungen in der Datenbank,
scrypt-Passwörter, Sperre nach Fehlversuchen.
### Phase B2 — mehrere Domains (14.08.2026)
Domains für Kurz-URLs, den Gruppen zugeteilt. Slug je Domain eindeutig,
Host-Header gegen die registrierten Domains geprüft.
### Listenansicht (14.08.2026)
„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter — gedacht für
viele Projekte nebeneinander. Karten bleiben als zweite Ansicht.
### Phase C — die letzten sechs Typen (14.08.2026)
Mehrere Links, Social Media, Video, PDF, Bild, Feedback. Dazu:
- Landingpages als eigene Gattung (`/s/{slug}`), ohne App-Navigation
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen
- Feedback mit anonymer Erhebung und Auswertung
Damit sind **alle 15 vorgesehenen Typen** gebaut.
### Single Sign-on (15.08.2026)
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
ID-Token.
### Erscheinungsbild (16.08.2026)
Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar. Die Farbe
wirkt über die Farbvariablen, die Tailwind ohnehin benutzt — deshalb ohne
Umbau der rund 60 eingefärbten Stellen.
### Dark Mode (16.08.2026)
Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System.
Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne
Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl.
### Sprechende Kurz-Links (17.08.2026)
Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die
vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde
jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren
vorbehalten, per Einstellung für Bearbeiter freigebbar.
Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1).
### Statische Codes benennen und ablegen (20.08.2026)
Das Namensfeld gilt in beiden Betriebsarten. Es bestimmt den Dateinamen beim
Herunterladen und erlaubt, einen statischen Code in „Meine Codes" abzulegen —
als Schnappschuss, dessen Inhalt sich nicht mehr ändert.
Schließt [Issue #2](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/2).
### Dokumentation (15.08.2026)
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes
und 1.6.0 diesen Versionshinweis.
---
+69 -6
View File
@@ -1,12 +1,12 @@
{
"name": "qrcode-generator",
"version": "1.1.0",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qrcode-generator",
"version": "1.1.0",
"version": "1.6.0",
"dependencies": {
"@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1",
@@ -17,6 +17,7 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -566,7 +567,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -2065,6 +2065,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -4039,7 +4105,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -7795,7 +7860,6 @@
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
@@ -7845,7 +7909,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
+3 -1
View File
@@ -1,11 +1,12 @@
{
"name": "qrcode-generator",
"version": "1.1.0",
"version": "1.6.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"changelog": "node scripts/changelog.mjs",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
@@ -23,6 +24,7 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -0,0 +1,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;
+8
View File
@@ -182,6 +182,14 @@ model QrCode {
size Int @default(512)
ecLevel String @default("M")
margin Int @default(4)
/// Logo in der Mitte: "branding" nimmt das Logo der Installation,
/// "upload" eine eigene Datei. Leer heisst: kein Logo.
logoSource String?
/// Die eigene Datei, wenn `logoSource` = "upload". Sie haengt ueber
/// `Upload.qrCodeId` am Code und verschwindet mit ihm.
logoUploadId String?
/// Kantenlaenge des Logos in Prozent der Kantenlaenge des Codes.
logoScale Int @default(20)
scans Int @default(0)
lastScanAt DateTime?
createdAt DateTime @default(now())
+84
View File
@@ -0,0 +1,84 @@
/**
* Schreibt CHANGELOG.md aus src/lib/changelog.ts.
*
* node scripts/changelog.mjs erzeugen
* node scripts/changelog.mjs --check nur prüfen, ob die Datei aktuell ist
*
* Warum erzeugt und nicht von Hand gepflegt: Die Anwendung zeigt denselben
* Verlauf unter /version an. Zwei gepflegte Listen liefen unweigerlich
* auseinander — und ein Changelog, der nicht stimmt, ist schlimmer als keiner.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const wurzel = join(dirname(fileURLToPath(import.meta.url)), "..");
const ziel = join(wurzel, "CHANGELOG.md");
/**
* Die Quelle ist TypeScript, dieses Skript aber schlichtes Node. Statt einen
* Übersetzer dazuzunehmen, wird der Datenteil gelesen und ausgewertet — er
* besteht ausschließlich aus Zeichenketten und Listen.
*/
function ladeVerlauf() {
const quelle = readFileSync(join(wurzel, "src", "lib", "changelog.ts"), "utf8");
const anfang = quelle.indexOf("export const CHANGELOG");
if (anfang === -1) throw new Error("CHANGELOG-Liste nicht gefunden.");
const start = quelle.indexOf("[", anfang);
const ende = quelle.indexOf("\n];", start);
if (start === -1 || ende === -1) throw new Error("Liste nicht abgrenzbar.");
const literal = quelle.slice(start, ende + 2);
return Function(`"use strict"; return (${literal});`)();
}
function formatiereDatum(iso) {
const [jahr, monat, tag] = iso.split("-");
return `${tag}.${monat}.${jahr}`;
}
function baue(verlauf) {
const zeilen = [
"# Änderungen",
"",
"Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der",
"Anwendung unter **Version und Änderungen**, erreichbar über die",
"Versionsnummer in der Fußzeile.",
"",
"> Diese Datei wird erzeugt: `npm run changelog`.",
"> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.",
"",
];
for (const eintrag of verlauf) {
zeilen.push(`## ${eintrag.version} — ${formatiereDatum(eintrag.date)}`, "");
zeilen.push(eintrag.summary, "");
for (const änderung of eintrag.changes) zeilen.push(`- ${änderung}`);
zeilen.push("");
}
return zeilen.join("\n");
}
const inhalt = baue(ladeVerlauf());
if (process.argv.includes("--check")) {
let vorhanden = "";
try {
vorhanden = readFileSync(ziel, "utf8");
} catch {
console.error("CHANGELOG.md fehlt. Erzeugen mit: npm run changelog");
process.exit(1);
}
if (vorhanden !== inhalt) {
console.error(
"CHANGELOG.md passt nicht mehr zu src/lib/changelog.ts. Neu erzeugen mit: npm run changelog",
);
process.exit(1);
}
console.log("CHANGELOG.md ist aktuell.");
} else {
writeFileSync(ziel, inhalt, "utf8");
console.log("CHANGELOG.md geschrieben.");
}
+12 -1
View File
@@ -5,6 +5,7 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { getBranding } from "@/lib/branding";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard";
@@ -85,5 +86,15 @@ export default async function CreatePage({ params }: Props) {
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
// der eigene Upload.
const { logoUploadId } = await getBranding();
return (
<QrGenerator
typeId={state.qrType.id}
maySetSlug={maySetSlug}
hasBrandingLogo={logoUploadId !== null}
/>
);
}
+15 -3
View File
@@ -6,6 +6,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
import { can } from "@/lib/auth/policy";
import { getBranding } from "@/lib/branding";
import { APP_VERSION } from "@/lib/changelog";
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
// werden.
@@ -128,9 +129,20 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
<main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-surface">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
{branding.appName}
{branding.footerText ? ` · ${branding.footerText}` : null}
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-4 py-6 text-sm text-slate-500 sm:px-6">
<span>
{branding.appName}
{branding.footerText ? ` · ${branding.footerText}` : null}
</span>
{/* Welche Fassung hier läuft — nach einem Update die schnellste
Antwort auf die Frage, ob das Update auch angekommen ist. */}
<Link
href="/version"
title="Version und Änderungen"
className="rounded font-mono text-xs tabular-nums transition-colors hover:text-slate-900"
>
v{APP_VERSION}
</Link>
</div>
</footer>
</div>
+83
View File
@@ -0,0 +1,83 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge, Card } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard";
import {
APP_VERSION,
CHANGELOG,
formatChangelogDate,
} from "@/lib/changelog";
export const metadata: Metadata = { title: "Version und Änderungen" };
export const dynamic = "force-dynamic";
export default async function VersionPage() {
await requirePageActor();
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Typ-Auswahl
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Version und Änderungen
</h1>
<p className="mt-2 text-slate-600">
Hier läuft Version{" "}
<span className="font-semibold text-slate-900">{APP_VERSION}</span>.
Darunter steht, was in welcher Fassung dazugekommen ist.
</p>
<p className="mt-2 text-sm text-slate-500">
Nach einem Update lohnt ein Blick hierher: Steht oben noch die alte
Nummer, läuft der Container noch mit dem alten Abbild.
</p>
</div>
<ol className="space-y-4">
{CHANGELOG.map((eintrag) => {
const laeuft = eintrag.version === APP_VERSION;
return (
<li key={eintrag.version}>
<Card>
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 className="text-lg font-semibold text-slate-900">
{eintrag.version}
</h2>
<span className="text-sm text-slate-500 tabular-nums">
{formatChangelogDate(eintrag.date)}
</span>
{laeuft ? (
<Badge tone="accent">läuft hier</Badge>
) : null}
</div>
<p className="mb-3 text-slate-600">{eintrag.summary}</p>
<ul className="space-y-1.5 text-sm text-slate-700">
{eintrag.changes.map((zeile) => (
<li key={zeile} className="flex gap-2.5">
<span
aria-hidden="true"
className="mt-2 size-1.5 shrink-0 rounded-full bg-slate-300"
/>
<span>{zeile}</span>
</li>
))}
</ul>
</Card>
</li>
);
})}
</ol>
</div>
);
}
+7 -2
View File
@@ -8,6 +8,7 @@ import { requireImplementedQrType } from "@/lib/qr/availability";
import { errorResponse } from "@/lib/http";
import { designSchema } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry";
import { ladeLogo } from "@/lib/qr/logo";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
@@ -48,7 +49,11 @@ export async function GET(
size: code.size,
ecLevel: code.ecLevel,
margin: code.margin,
logoSource: code.logoSource,
logoUploadId: code.logoUploadId,
logoScale: code.logoScale,
});
const logo = await ladeLogo(design);
// Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
@@ -58,8 +63,8 @@ export async function GET(
: shortUrl(code.slug, code.domain.hostname);
const body =
format === "png"
? new Uint8Array(await renderPng(content, design))
: await renderSvg(content, design);
? new Uint8Array(await renderPng(content, design, logo))
: await renderSvg(content, design, logo);
const headers: Record<string, string> = {
"Content-Type": CONTENT_TYPES[format],
+25 -2
View File
@@ -13,7 +13,7 @@ import { assertDynamicPossible } from "@/lib/qr/request";
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
import { assertSlugAvailable } from "@/lib/slug-resolve";
import { getSettings } from "@/lib/settings";
import { attachUploads } from "@/lib/uploads";
import { attachUpload, attachUploads, detachUpload } from "@/lib/uploads";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
@@ -133,6 +133,18 @@ export async function PATCH(
Object.assign(update, parsed.data.design);
}
// Ein eigenes Logo gehört zur Gestaltung, nicht zum Inhalt. Es muss
// trotzdem an den Code gehängt werden — sonst liegt die Datei herrenlos
// herum und wird nach einem Tag weggeräumt, mitsamt dem Logo im Code.
const logoVorher =
existing.logoSource === "upload" ? existing.logoUploadId : null;
const logoNachher =
parsed.data.design === undefined
? logoVorher
: parsed.data.design.logoSource === "upload"
? parsed.data.design.logoUploadId
: null;
/* ------------------------------------------------------- Kurz-Link */
const neuerSlug = parsed.data.slug?.trim();
@@ -225,7 +237,18 @@ export async function PATCH(
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
// später als verwaist weggeräumt.
if (neueDateien !== null) {
await attachUploads(updated.id, neueDateien);
await attachUploads(
updated.id,
logoNachher ? [...neueDateien, logoNachher] : neueDateien,
);
} else if (logoNachher && logoNachher !== logoVorher) {
await attachUpload(updated.id, logoNachher);
}
// Ein abgelegtes oder ausgetauschtes Logo verliert seinen Bezug und wird
// später weggeräumt.
if (logoVorher && logoVorher !== logoNachher) {
await detachUpload(updated.id, logoVorher);
}
return Response.json({ code: toQrCodeDto(updated) });
+20 -2
View File
@@ -45,6 +45,24 @@ const createSchema = z.object({
domainId: z.string().trim().min(1).optional(),
});
/**
* Alle Dateien, die zu einem Code gehören.
*
* Das sind die des Typ-Moduls — ein PDF, ein Bild — **und** ein eigenes Logo
* aus der Gestaltung. Wird das vergessen, löst `attachUploads` das Logo beim
* nächsten Speichern wieder vom Code und räumt es später als herrenlos weg.
*/
function dateienDesCodes(
qrType: { uploadIds: (data: never) => string[] },
data: unknown,
design: { logoSource: string | null; logoUploadId: string | null },
): string[] {
const ids = qrType.uploadIds(data as never);
return design.logoSource === "upload" && design.logoUploadId
? [...ids, design.logoUploadId]
: ids;
}
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === "object" &&
@@ -160,7 +178,7 @@ export async function POST(request: NextRequest): Promise<Response> {
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
},
});
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 });
}
@@ -187,7 +205,7 @@ export async function POST(request: NextRequest): Promise<Response> {
});
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
// vorher lagen sie ohne Bezug herum.
await attachUploads(created.id, qrType.uploadIds(validated.data));
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) {
+4 -2
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { ladeLogo } from "@/lib/qr/logo";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import {
downloadFilename,
@@ -38,10 +39,11 @@ export async function POST(request: NextRequest): Promise<Response> {
const { qrType, data, content } = await resolveQrRequest(parsed.data);
const logo = await ladeLogo(design);
const body =
format === "png"
? new Uint8Array(await renderPng(content, design))
: await renderSvg(content, design);
? new Uint8Array(await renderPng(content, design, logo))
: await renderSvg(content, design, logo);
return new Response(body, {
headers: {
+3 -1
View File
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { ladeLogo } from "@/lib/qr/logo";
import { renderSvg } from "@/lib/qr/render";
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
@@ -21,7 +22,8 @@ export async function POST(request: NextRequest): Promise<Response> {
}
const { content } = await resolveQrRequest(parsed.data);
const svg = await renderSvg(content, parsed.data.design);
const logo = await ladeLogo(parsed.data.design);
const svg = await renderSvg(content, parsed.data.design, logo);
return Response.json({
content,
+160 -4
View File
@@ -1,15 +1,20 @@
"use client";
import { Icon } from "@/components/icons";
import { FileField } from "@/components/qr-forms/file-field";
import { Field, Notice, TextInput, cx } from "@/components/ui";
import { evaluateContrast } from "@/lib/qr/contrast";
import {
COLOR_PRESETS,
EC_LEVELS,
EC_LEVEL_LABELS,
LOGO_SCALE_WARN,
MAX_LOGO_SCALE,
MAX_SIZE,
MIN_LOGO_SCALE,
MIN_SIZE,
type EcLevel,
type LogoSource,
type QrDesign,
} from "@/lib/qr/design";
@@ -62,9 +67,12 @@ function ColorField({
export function DesignPanel({
design,
onChange,
/** Ist unter „Erscheinungsbild“ ein Logo hinterlegt? Sonst entfällt die Wahl. */
hasBrandingLogo = false,
}: {
design: QrDesign;
onChange: (design: QrDesign) => void;
hasBrandingLogo?: boolean;
}) {
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
const contrast = evaluateContrast(design.fgColor, design.bgColor);
@@ -165,7 +173,11 @@ export function DesignPanel({
<Field
label="Fehlerkorrektur"
hint={EC_LEVEL_LABELS[design.ecLevel]}
hint={
design.logoSource === null
? EC_LEVEL_LABELS[design.ecLevel]
: "Mit Logo gilt H — was das Bild verdeckt, muss die Fehlerkorrektur ausgleichen."
}
>
<div
role="radiogroup"
@@ -177,11 +189,14 @@ export function DesignPanel({
key={level}
type="button"
role="radio"
aria-checked={design.ecLevel === level}
aria-checked={
design.logoSource === null ? design.ecLevel === level : level === "H"
}
disabled={design.logoSource !== null}
onClick={() => patch({ ecLevel: level })}
className={cx(
"rounded-lg border py-2 text-sm font-medium transition-colors",
design.ecLevel === level
"rounded-lg border py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
(design.logoSource === null ? design.ecLevel : "H") === level
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
)}
@@ -208,6 +223,147 @@ export function DesignPanel({
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
</Field>
<LogoAuswahl
design={design}
patch={patch}
hasBrandingLogo={hasBrandingLogo}
/>
</div>
);
}
/* -------------------------------------------------------------------- Logo */
/**
* Das Bild in der Mitte des Codes.
*
* Bewusst ein eigener Abschnitt und nicht bloß ein Feld: Ein Logo zieht drei
* Entscheidungen nach sich — woher es kommt, wie groß es wird, und dass die
* Fehlerkorrektur dafür hochgesetzt wird. Das gehört sichtbar beieinander.
*/
function LogoAuswahl({
design,
patch,
hasBrandingLogo,
}: {
design: QrDesign;
patch: (changes: Partial<QrDesign>) => void;
hasBrandingLogo: boolean;
}) {
const an = design.logoSource !== null;
const waehle = (quelle: LogoSource | null) => {
if (quelle === null) {
patch({ logoSource: null, logoUploadId: null });
} else if (quelle === "branding") {
patch({ logoSource: "branding", logoUploadId: null });
} else {
patch({ logoSource: "upload" });
}
};
return (
<fieldset className="border-t border-slate-200 pt-4">
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
Logo in der Mitte
</legend>
<label className="mt-3 flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={an}
onChange={(event) =>
waehle(
event.target.checked ? (hasBrandingLogo ? "branding" : "upload") : null,
)
}
className="mt-0.5 size-4 shrink-0 rounded border-slate-300 accent-indigo-600"
/>
<span className="text-sm text-slate-700">
Ein Logo in die Mitte des Codes setzen
</span>
</label>
{an ? (
<div className="mt-4 space-y-4">
{hasBrandingLogo ? (
<div
role="radiogroup"
aria-label="Herkunft des Logos"
className="space-y-2"
>
{(
[
["branding", "Logo dieser Installation"],
["upload", "Eigenes Logo hochladen"],
] as [LogoSource, string][]
).map(([quelle, text]) => (
<label
key={quelle}
className="flex cursor-pointer items-center gap-2.5"
>
<input
type="radio"
name="logo-quelle"
checked={design.logoSource === quelle}
onChange={() => waehle(quelle)}
className="size-4 shrink-0 accent-indigo-600"
/>
<span className="text-sm text-slate-700">{text}</span>
</label>
))}
</div>
) : null}
{design.logoSource === "upload" ? (
<div className="space-y-2">
<FileField
kind="qrlogo"
label={design.logoUploadId ? "Anderes Logo wählen" : "Logo wählen"}
onUploaded={(datei) => patch({ logoUploadId: datei.fileId })}
/>
{design.logoUploadId ? null : (
<p className="text-sm text-amber-700">
Noch keine Datei gewählt — bis dahin bleibt die Mitte frei.
</p>
)}
</div>
) : null}
<Field
label={`Größe: ${design.logoScale} % der Kantenlänge`}
htmlFor="qr-logo-scale"
hint="Bezogen auf die Breite des Codes. Bei 20 % sind rund 4 % der Fläche verdeckt."
>
<input
id="qr-logo-scale"
type="range"
min={MIN_LOGO_SCALE}
max={MAX_LOGO_SCALE}
step={1}
value={design.logoScale}
onChange={(event) => patch({ logoScale: Number(event.target.value) })}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
</Field>
{design.logoScale > LOGO_SCALE_WARN ? (
<Notice tone="warning">
Ab {LOGO_SCALE_WARN} % wird es knapp. Die Fehlerkorrektur trägt das
zwar, aber manche Scanner-Apps suchen in der Mitte nach
Ausrichtungsmustern. Prüfe den fertigen Code mit mehr als einer App,
bevor er in den Druck geht.
</Notice>
) : null}
<p className="text-sm text-slate-500">
Hinter dem Logo liegt eine Fläche in der Hintergrundfarbe, damit es
sich absetzt. Ein Code mit Logo gehört vor dem Druck einmal
eingescannt — das ist die einzige Prüfung, die wirklich zählt.
</p>
</div>
) : null}
</fieldset>
);
}
+8 -1
View File
@@ -47,9 +47,12 @@ export function QrGenerator({
typeId,
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
maySetSlug = true,
/** Ist ein Logo der Installation hinterlegt? Sonst nur eigener Upload. */
hasBrandingLogo = false,
}: {
typeId: QrTypeId;
maySetSlug?: boolean;
hasBrandingLogo?: boolean;
}) {
const qrType = qrTypes[typeId];
const Form = qrForms[typeId];
@@ -510,7 +513,11 @@ export function QrGenerator({
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
Gestaltung
</CardTitle>
<DesignPanel design={design} onChange={setDesign} />
<DesignPanel
design={design}
onChange={setDesign}
hasBrandingLogo={hasBrandingLogo}
/>
</Card>
</div>
</div>
+126
View File
@@ -0,0 +1,126 @@
import pkg from "../../package.json";
/**
* Änderungsverlauf der Anwendung — die einzige Quelle dafür.
*
* Hieraus entsteht beides: die Seite unter `/version` und die `CHANGELOG.md`
* im Wurzelverzeichnis (`npm run changelog`). Zwei gepflegte Listen liefen
* unweigerlich auseinander; eine Prüfung wacht darüber, dass die erzeugte
* Datei zu dieser Quelle passt.
*
* Die Einträge sind aus dem Abschnitt „Gebaut“ der Roadmap und der
* Git-Historie zusammengetragen. Versionsnummern gab es dabei nie — es
* existieren keine Tags —, deshalb ist je **Erscheinungstag** eine Version
* gebildet. Das ist nachträglich vergeben, aber die Daten selbst stimmen.
*/
export type ChangelogEntry = {
version: string;
/** ISO-Datum, an dem der Stand entstanden ist. */
date: string;
/** Eine Zeile zur Einordnung — was diese Version ausmacht. */
summary: string;
changes: string[];
};
export const CHANGELOG: readonly ChangelogEntry[] = [
{
version: "1.6.0",
date: "2026-08-21",
summary: "Sichtbare Version mit Änderungsverlauf.",
changes: [
"Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.",
"Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.",
"Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.",
],
},
{
version: "1.5.0",
date: "2026-08-20",
summary: "Statische Codes lassen sich benennen und ablegen.",
changes: [
"Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.",
"Der Name bestimmt den Dateinamen beim Herunterladen.",
"Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.",
],
},
{
version: "1.4.0",
date: "2026-08-17",
summary: "Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.",
changes: [
"Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.",
"Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.",
"Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.",
"Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.",
"Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.",
],
},
{
version: "1.3.0",
date: "2026-08-16",
summary: "Eigenes Erscheinungsbild und ein Dark Mode.",
changes: [
"Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.",
"Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.",
"Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.",
"Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.",
],
},
{
version: "1.2.0",
date: "2026-08-15",
summary: "Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.",
changes: [
"Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.",
"Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.",
"Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.",
"Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.",
],
},
{
version: "1.1.0",
date: "2026-08-14",
summary: "Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.",
changes: [
"Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.",
"Landingpages als eigene Gattung für Typen ohne fremdes Ziel.",
"Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.",
"Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.",
"Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.",
"„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.",
"Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.",
],
},
{
version: "1.0.0",
date: "2026-08-13",
summary: "Erste lauffähige Fassung.",
changes: [
"Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.",
"QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.",
"Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.",
"Download als PNG und SVG, serverseitig gerendert.",
"Update-Skript für Linux, macOS und Windows.",
],
},
];
/**
* Die laufende Version.
*
* Aus der `package.json`, damit es genau eine Stelle gibt, an der sie steht.
* Der Wert wird beim Bauen eingesetzt — im Container liegt kein Git, aus dem
* sich sonst etwas ableiten ließe.
*/
export const APP_VERSION: string = pkg.version;
/** Der Eintrag zur laufenden Version, falls er im Verlauf steht. */
export function currentEntry(): ChangelogEntry | undefined {
return CHANGELOG.find((eintrag) => eintrag.version === APP_VERSION);
}
/** Datum als „21.08.2026“ — ohne Abhängigkeit von der Zeitzone des Servers. */
export function formatChangelogDate(iso: string): string {
const [jahr, monat, tag] = iso.split("-");
return `${tag}.${monat}.${jahr}`;
}
+4 -1
View File
@@ -3,7 +3,7 @@ import "server-only";
import type { Prisma, QrCode } from "@/generated/prisma/client";
import type { QrCodeDto } from "@/lib/dto";
import { landingUrl, shortUrl } from "@/lib/env";
import type { EcLevel } from "@/lib/qr/design";
import type { EcLevel, LogoSource } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry";
import type { QrTypeId } from "@/lib/qr/type-module";
@@ -83,6 +83,9 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
size: code.size,
ecLevel: code.ecLevel as EcLevel,
margin: code.margin,
logoSource: code.logoSource as LogoSource | null,
logoUploadId: code.logoUploadId,
logoScale: code.logoScale,
},
scans: code.scans,
lastScanAt: code.lastScanAt?.toISOString() ?? null,
+4 -1
View File
@@ -1,4 +1,4 @@
import type { EcLevel } from "@/lib/qr/design";
import type { EcLevel, LogoSource } from "@/lib/qr/design";
import type { QrTypeId } from "@/lib/qr/type-module";
/** Statisch oder dynamisch — bestimmt, was ein Code überhaupt kann. */
@@ -35,6 +35,9 @@ export type QrCodeDto = {
size: number;
ecLevel: EcLevel;
margin: number;
logoSource: LogoSource | null;
logoUploadId: string | null;
logoScale: number;
};
scans: number;
lastScanAt: string | null;
+55 -1
View File
@@ -13,6 +13,29 @@ export const EC_LEVEL_LABELS: Record<EcLevel, string> = {
export const MIN_SIZE = 256;
export const MAX_SIZE = 2048;
/**
* Logo in der Mitte des Codes.
*
* `branding` nimmt das Logo der Installation aus dem Erscheinungsbild,
* `upload` ein eigenes, das zu diesem Code gehört. Ohne Angabe kein Logo.
*/
export const LOGO_SOURCES = ["branding", "upload"] as const;
export type LogoSource = (typeof LOGO_SOURCES)[number];
/**
* Kantenlänge des Logos in Prozent der Kantenlänge des Codes.
*
* Bei 20 % sind knapp 4 % der Fläche verdeckt — das steckt die
* Fehlerkorrektur H (30 %) mühelos weg. Die Obergrenze liegt bei 25 %, weil
* darüber weniger die Fehlerkorrektur das Problem ist als die Scanner-Apps:
* Manche suchen in der Mitte nach Ausrichtungsmustern.
*/
export const MIN_LOGO_SCALE = 10;
export const MAX_LOGO_SCALE = 25;
/** Ab hier ein Hinweis, dass es knapp wird. */
export const LOGO_SCALE_WARN = 22;
export const DEFAULT_LOGO_SCALE = 20;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const hexColor = z
@@ -39,7 +62,25 @@ export const designSchema = z.object({
.int({ error: "Der Rand muss eine ganze Zahl sein." })
.min(0, { error: "Der Rand darf nicht negativ sein." })
.max(16, { error: "Der Rand darf höchstens 16 Module breit sein." }),
});
logoSource: z
.enum(LOGO_SOURCES, { error: "Unbekannte Herkunft des Logos." })
.nullable()
.default(null),
logoUploadId: z.string().trim().min(1).nullable().default(null),
logoScale: z.coerce
.number({ error: "Die Logogröße muss eine Zahl sein." })
.int({ error: "Die Logogröße muss eine ganze Zahl sein." })
.min(MIN_LOGO_SCALE, {
error: `Das Logo muss mindestens ${MIN_LOGO_SCALE} % breit sein.`,
})
.max(MAX_LOGO_SCALE, {
error: `Das Logo darf höchstens ${MAX_LOGO_SCALE} % breit sein — darüber wird der Code unzuverlässig.`,
})
.default(DEFAULT_LOGO_SCALE),
}).refine(
(design) => design.logoSource !== "upload" || design.logoUploadId !== null,
{ error: "Für ein eigenes Logo fehlt die hochgeladene Datei." },
);
export type QrDesign = z.infer<typeof designSchema>;
@@ -49,8 +90,21 @@ export const defaultDesign: QrDesign = {
size: 512,
ecLevel: "M",
margin: 4,
logoSource: null,
logoUploadId: null,
logoScale: DEFAULT_LOGO_SCALE,
};
/**
* Ein Logo verdeckt einen Teil des Codes. Was fehlt, muss die Fehlerkorrektur
* ausgleichen — deshalb gilt mit Logo immer **H**, unabhängig davon, was in
* der Auswahl steht. Die Oberfläche sagt das dazu; heimlich geschieht hier
* nichts.
*/
export function effectiveEcLevel(design: QrDesign): EcLevel {
return design.logoSource === null ? design.ecLevel : "H";
}
/** Farbvorschläge für die schnelle Auswahl in der Oberfläche. */
export const COLOR_PRESETS: ReadonlyArray<{
label: string;
+52
View File
@@ -0,0 +1,52 @@
import "server-only";
import { getBranding } from "@/lib/branding";
import { prisma } from "@/lib/db";
import type { QrDesign } from "@/lib/qr/design";
import { QrRequestError } from "@/lib/qr/type-module";
/** Das Bild, das in die Mitte des Codes kommt. */
export type QrLogo = {
bytes: Buffer;
mimeType: string;
};
/**
* Holt das Logo, das die Gestaltung verlangt — oder `null`.
*
* Zwei Quellen: das Logo der Installation aus dem Erscheinungsbild, oder eine
* Datei, die zu diesem Code hochgeladen wurde. Beides liegt als Upload in der
* Datenbank.
*
* Fehlt die Datei, ist das kein stiller Ausfall: Ein Code, der ohne Logo
* herauskommt, obwohl eines eingestellt war, fiele erst nach dem Druck auf.
*/
export async function ladeLogo(design: QrDesign): Promise<QrLogo | null> {
if (design.logoSource === null) return null;
const id =
design.logoSource === "branding"
? (await getBranding()).logoUploadId
: design.logoUploadId;
if (!id) {
throw new QrRequestError(
design.logoSource === "branding"
? "Für diese Installation ist kein Logo hinterlegt — unter Verwaltung → Erscheinungsbild lässt sich eines hochladen."
: "Das hochgeladene Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
);
}
const datei = await prisma.upload.findUnique({
where: { id },
select: { data: true, mimeType: true },
});
if (!datei) {
throw new QrRequestError(
"Das Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
);
}
return { bytes: Buffer.from(datei.data), mimeType: datei.mimeType };
}
+103 -4
View File
@@ -1,8 +1,10 @@
import "server-only";
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
import sharp from "sharp";
import type { QrDesign } from "@/lib/qr/design";
import { effectiveEcLevel, type QrDesign } from "@/lib/qr/design";
import type { QrLogo } from "@/lib/qr/logo";
import { UserFacingError } from "@/lib/qr/type-module";
/** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */
@@ -10,7 +12,8 @@ export class QrRenderError extends UserFacingError {}
function toOptions(design: QrDesign): QRCodeRenderersOptions {
return {
errorCorrectionLevel: design.ecLevel,
// Mit Logo gilt immer H — siehe `effectiveEcLevel`.
errorCorrectionLevel: effectiveEcLevel(design),
margin: design.margin,
width: design.size,
color: {
@@ -30,12 +33,103 @@ function wrap(error: unknown): never {
throw new QrRenderError(`Der QR-Code konnte nicht erzeugt werden: ${detail}`);
}
/* ------------------------------------------------------------------- Logo */
/**
* Wie das Logo in den Code kommt.
*
* Hinter dem Bild liegt eine Fläche in der Hintergrundfarbe — ohne sie stünde
* das Logo auf den Modulen und ginge optisch unter. Der Rand dieser Fläche
* ist ein Zehntel der Logobreite; das reicht, um es abzusetzen, ohne mehr
* Code zu verdecken als nötig.
*
* Das Bild selbst wird eingepasst, nicht beschnitten (`contain`): Ein
* Firmenlogo im Querformat soll als Ganzes zu sehen sein.
*/
const PAD_ANTEIL = 0.1;
/** Kantenlänge von Bild und Fläche, in Bildpunkten. */
function logoMasse(gesamt: number, prozent: number) {
const bild = Math.round((gesamt * prozent) / 100);
const flaeche = Math.round(bild * (1 + 2 * PAD_ANTEIL));
return { bild, flaeche, versatz: Math.round((gesamt - flaeche) / 2) };
}
async function legeLogoAuf(
qrPng: Buffer,
design: QrDesign,
logo: QrLogo,
): Promise<Buffer> {
const { bild, flaeche, versatz } = logoMasse(design.size, design.logoScale);
// Erst das Bild auf die Fläche einpassen, dann die Fläche auf den Code.
// Zwei Schritte, weil `extend` den Rand in der Hintergrundfarbe zieht — das
// ergibt die Fläche, ohne eine zweite Zeichnung zu brauchen.
const rand = Math.round((flaeche - bild) / 2);
const kachel = await sharp(logo.bytes)
.resize(bild, bild, { fit: "contain", background: design.bgColor })
.extend({
top: rand,
bottom: flaeche - bild - rand,
left: rand,
right: flaeche - bild - rand,
background: design.bgColor,
})
.png()
.toBuffer();
return sharp(qrPng)
.composite([{ input: kachel, top: versatz, left: versatz }])
.png()
.toBuffer();
}
/**
* Setzt das Logo in ein fertiges QR-SVG.
*
* Das SVG von `qrcode` hat ein `viewBox` in Modulen — gerechnet wird deshalb
* in Modulen und nicht in Bildpunkten. Eingefügt wird direkt vor `</svg>`,
* damit Fläche und Bild über den Modulen liegen.
*/
function legeLogoInSvg(svg: string, design: QrDesign, logo: QrLogo): string {
const treffer = /viewBox="0 0 (\d+(?:\.\d+)?) /.exec(svg);
if (!treffer) {
// Ohne viewBox ließe sich nichts sauber platzieren. Lieber ohne Logo
// ausliefern als an einer falschen Stelle.
console.error("QR-SVG ohne viewBox — Logo wird ausgelassen.");
return svg;
}
const kante = Number(treffer[1]);
const bild = (kante * design.logoScale) / 100;
const flaeche = bild * (1 + 2 * PAD_ANTEIL);
const versatz = (kante - flaeche) / 2;
const bildVersatz = (kante - bild) / 2;
const daten = `data:${logo.mimeType};base64,${logo.bytes.toString("base64")}`;
const rund = (wert: number) => Number(wert.toFixed(3));
const einsatz =
`<rect x="${rund(versatz)}" y="${rund(versatz)}" width="${rund(flaeche)}" height="${rund(flaeche)}" fill="${design.bgColor}"/>` +
`<image x="${rund(bildVersatz)}" y="${rund(bildVersatz)}" width="${rund(bild)}" height="${rund(bild)}" ` +
`preserveAspectRatio="xMidYMid meet" href="${daten}"/>`;
return svg.replace("</svg>", `${einsatz}</svg>`);
}
/* ---------------------------------------------------------------- Ausgabe */
export async function renderSvg(
content: string,
design: QrDesign,
logo: QrLogo | null = null,
): Promise<string> {
try {
return await QRCode.toString(content, { ...toOptions(design), type: "svg" });
const svg = await QRCode.toString(content, {
...toOptions(design),
type: "svg",
});
return logo === null ? svg : legeLogoInSvg(svg, design, logo);
} catch (error) {
wrap(error);
}
@@ -44,9 +138,14 @@ export async function renderSvg(
export async function renderPng(
content: string,
design: QrDesign,
logo: QrLogo | null = null,
): Promise<Buffer> {
try {
return await QRCode.toBuffer(content, { ...toOptions(design), type: "png" });
const png = await QRCode.toBuffer(content, {
...toOptions(design),
type: "png",
});
return logo === null ? png : await legeLogoAuf(png, design, logo);
} catch (error) {
wrap(error);
}
+10
View File
@@ -35,6 +35,16 @@ export const UPLOAD_KINDS = {
// das dann bei jedem Seitenaufruf mitgeht.
maxBytes: 2 * 1024 * 1024,
},
qrlogo: {
label: "Logo im Code",
// Ohne GIF: Ein bewegtes Bild wird beim Einsetzen ohnehin zum Standbild,
// und die Auswahl soll nicht mehr versprechen, als hinten herauskommt.
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/avif"],
accept: "image/png,image/jpeg,image/webp,image/avif",
// Das Logo landet auf wenigen hundert Bildpunkten in der Mitte des Codes.
// Mehr als zwei Megabyte bringen dort nichts.
maxBytes: 2 * 1024 * 1024,
},
favicon: {
label: "Favicon",
// Zusätzlich ICO — viele haben genau das noch herumliegen.
+30
View File
@@ -189,6 +189,36 @@ export async function attachUploads(
});
}
/**
* Hängt **eine** Datei an einen Code, ohne die übrigen anzurühren.
*
* Gebraucht für das Logo aus der Gestaltung: Ändert sich nur das Aussehen
* eines Codes, sollen die Dateien seines Inhalts unberührt bleiben.
*/
export async function attachUpload(
qrCodeId: string,
uploadId: string,
): Promise<void> {
await prisma.upload.updateMany({
// Dieselbe Bedingung wie oben: keine fremde Datei an sich ziehen.
where: { id: uploadId, OR: [{ qrCodeId: null }, { qrCodeId }] },
data: { qrCodeId },
});
}
/**
* Löst eine Datei wieder vom Code — sie wird danach als herrenlos weggeräumt.
*/
export async function detachUpload(
qrCodeId: string,
uploadId: string,
): Promise<void> {
await prisma.upload.updateMany({
where: { id: uploadId, qrCodeId },
data: { qrCodeId: null },
});
}
/** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */
const ORPHAN_HOURS = 24;