diff --git a/README.md b/README.md index 4f3c1d5..32a6d5d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -353,6 +355,30 @@ prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit 404 — über einen gefälschten Header lässt sich also keine fremde Domain ansprechen. +### Nachprüfen, ob eine Domain wirklich hierher führt + +Auf der Domain-Seite steht bei jedem Eintrag ein Knopf **Prüfen**. Er geht den +Weg der Scanner nach und meldet vier Dinge: + +| Schritt | Was geprüft wird | +| --- | --- | +| DNS | Löst der Name auf? Auf welche Adressen? | +| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? | +| Erreichbar | Antwortet dort ein Webserver, und wie schnell? | +| Ziel | Steht dahinter **diese** Installation? | + +Der letzte Schritt ist der eigentliche Wert. Die Anwendung ruft dazu unter der +Domain eine eigene Route auf und lässt sich einen HMAC über eine frische +Zufallszahl zurückgeben; das Geheimnis dafür steht in der Datenbank und +verlässt den Server nie. Nur so unterscheidet sich „die Domain zeigt +irgendwohin“ von „die Domain zeigt hierher“. + +> **Grenze der Aussage:** Der Aufruf startet dort, wo der Container läuft. +> Wer internes DNS oder Hairpin-NAT hat, bekommt womöglich einen Fehler, +> obwohl von außen alles stimmt. Umgekehrt gilt: Meldet die Prüfung von innen +> Erfolg, heißt das noch nicht, dass die Domain auch von außen erreichbar ist. +> Dafür bleibt der Blick von einem fremden Netz aus. + Zwei Dinge, die daraus folgen: - Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende @@ -638,13 +664,18 @@ Dazu kommen - ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ, - eine **Sortierung** nach Datum, Name oder Scan-Anzahl, -- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen. +- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen, +- ein **Filter nach Art**, sobald dynamische und statische Codes + nebeneinander liegen. Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die Kartenansicht. -Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden. +Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein +Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Steht der +Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach +Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts. Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare. diff --git a/docs/funktionen.md b/docs/funktionen.md index b20af91..88387e5 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -122,6 +122,40 @@ 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 Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne diff --git a/docs/sicherheit.md b/docs/sicherheit.md index 62aa2b5..366ab25 100644 --- a/docs/sicherheit.md +++ b/docs/sicherheit.md @@ -191,6 +191,29 @@ Konto nur bei **bestätigter** E-Mail-Adresse. --- +## Der Domain-Check und seine Grenzen + +Auf der Domain-Seite ruft der Knopf **Prüfen** die eingetragene Domain vom +Server aus auf. Drei Dinge grenzen das ein: + +- Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer + aus der Anfrage. Sonst wäre die Route ein Werkzeug, mit dem sich vom Server + aus beliebige Adressen abklopfen ließen. +- Auslösen darf sie nur, wer `instance:manage` hat — dieselbe Berechtigung, + die auch Domains anlegt. +- Die Route, die den Beweis liefert (`/api/domain-check`), ist **ohne + Anmeldung** erreichbar; der Aufruf kommt ja von außen. Sie gibt nichts + preis: Die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Ohne + das Geheimnis lässt er sich nicht bilden, und aus ihm folgt nichts über das + Geheimnis. Es entsteht beim ersten Check und verlässt den Server nie. + +Beim Zertifikat läuft die Verbindung bewusst mit `rejectUnauthorized: false`. +Nur so lässt sich ein schlechtes Zertifikat **beschreiben** — abgelaufen, +selbst ausgestellt, falscher Name — statt bloß daran zu scheitern. +Übertragen wird über diese Verbindung nichts. + +--- + ## Offen Bewusst nicht umgesetzt, mit Begründung. diff --git a/package-lock.json b/package-lock.json index 039f195..c4ba013 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index 7423cb9..e840a46 100644 --- a/package.json +++ b/package.json @@ -24,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": { diff --git a/prisma/migrations/20260821010000_domain_check/migration.sql b/prisma/migrations/20260821010000_domain_check/migration.sql new file mode 100644 index 0000000..9645111 --- /dev/null +++ b/prisma/migrations/20260821010000_domain_check/migration.sql @@ -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; diff --git a/prisma/migrations/20260821020000_logo_im_code/migration.sql b/prisma/migrations/20260821020000_logo_im_code/migration.sql new file mode 100644 index 0000000..53b87ff --- /dev/null +++ b/prisma/migrations/20260821020000_logo_im_code/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4c5ef39..23c6f4e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -208,6 +208,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()) @@ -288,6 +296,14 @@ model InstanceSetting { /// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die /// jemand anderes gebraucht hätte. Administratoren können das freigeben. editorsMaySetSlug Boolean @default(false) + /// Geheimnis fuer den Domain-Check. + /// + /// Die Pruefung ruft die Domain von aussen auf und laesst sich einen HMAC + /// ueber eine Zufallszahl zurueckgeben. Nur wer dieses Geheimnis kennt, + /// kann ihn bilden — so steht fest, dass wirklich diese Installation + /// geantwortet hat und nicht irgendein anderer Server. Es verlaesst den + /// Server nie und entsteht beim ersten Check. + checkSecret String? updatedAt DateTime @updatedAt } diff --git a/src/app/(app)/erstellen/[type]/page.tsx b/src/app/(app)/erstellen/[type]/page.tsx index 2710a0e..9ee68df 100644 --- a/src/app/(app)/erstellen/[type]/page.tsx +++ b/src/app/(app)/erstellen/[type]/page.tsx @@ -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"; @@ -96,5 +97,15 @@ export default async function CreatePage({ params }: Props) { editorsMaySetSlug, }); - return ; + // Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur + // der eigene Upload. + const { logoUploadId } = await getBranding(); + + return ( + + ); } diff --git a/src/app/(app)/meine-codes/page.tsx b/src/app/(app)/meine-codes/page.tsx index 26bcf7d..12357a9 100644 --- a/src/app/(app)/meine-codes/page.tsx +++ b/src/app/(app)/meine-codes/page.tsx @@ -27,9 +27,9 @@ export default async function MyCodesPage() { Meine Codes

- Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der - Scans. Statische Codes erscheinen hier nicht — sie werden nicht - gespeichert. + Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans, + dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“ + lässt sich die Liste auf eine der beiden Arten einschränken.

diff --git a/src/app/api/admin/domains/[id]/check/route.ts b/src/app/api/admin/domains/[id]/check/route.ts new file mode 100644 index 0000000..950af1e --- /dev/null +++ b/src/app/api/admin/domains/[id]/check/route.ts @@ -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 { + 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); + } +} diff --git a/src/app/api/codes/[id]/qr/route.ts b/src/app/api/codes/[id]/qr/route.ts index 39d9997..948da8a 100644 --- a/src/app/api/codes/[id]/qr/route.ts +++ b/src/app/api/codes/[id]/qr/route.ts @@ -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 = { "Content-Type": CONTENT_TYPES[format], diff --git a/src/app/api/codes/[id]/route.ts b/src/app/api/codes/[id]/route.ts index 9aaafe7..98f94e6 100644 --- a/src/app/api/codes/[id]/route.ts +++ b/src/app/api/codes/[id]/route.ts @@ -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) }); diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts index 926f53c..8647edf 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -7,6 +7,7 @@ import { requirePermission, visibleGroupIds } from "@/lib/auth/policy"; import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { prisma } from "@/lib/db"; import { resolveTargetDomain } from "@/lib/domains"; +import { findStaticDuplicate } from "@/lib/duplicates"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { requireAvailableQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; @@ -43,8 +44,32 @@ const createSchema = z.object({ groupId: z.string().trim().min(1).optional(), /** Unter welcher Domain die Kurz-URL entsteht. */ domainId: z.string().trim().min(1).optional(), + /** + * Eine gemeldete Dublette bewusst in Kauf nehmen. Ohne dieses Häkchen + * antwortet das Anlegen mit 409, sobald derselbe Inhalt schon als + * statischer Code in der Gruppe liegt. + */ + allowDuplicate: z.boolean().default(false), }); +/** + * 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" && @@ -141,6 +166,24 @@ export async function POST(request: NextRequest): Promise { 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: { @@ -161,7 +204,7 @@ export async function POST(request: NextRequest): Promise { 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 }); } @@ -188,7 +231,7 @@ export async function POST(request: NextRequest): Promise { }); // 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) { diff --git a/src/app/api/domain-check/route.ts b/src/app/api/domain-check/route.ts new file mode 100644 index 0000000..70c5fcb --- /dev/null +++ b/src/app/api/domain-check/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/qr/download/route.ts b/src/app/api/qr/download/route.ts index 21f8c49..3128e66 100644 --- a/src/app/api/qr/download/route.ts +++ b/src/app/api/qr/download/route.ts @@ -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 { 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: { diff --git a/src/app/api/qr/preview/route.ts b/src/app/api/qr/preview/route.ts index 5b8f24c..6778644 100644 --- a/src/app/api/qr/preview/route.ts +++ b/src/app/api/qr/preview/route.ts @@ -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 { } 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, diff --git a/src/components/codes-list.tsx b/src/components/codes-list.tsx index 5ab8b16..b7e94b9 100644 --- a/src/components/codes-list.tsx +++ b/src/components/codes-list.tsx @@ -43,6 +43,8 @@ function formatShortDate(value: string | null): string { type View = "list" | "cards"; type SortKey = "neueste" | "aelteste" | "name" | "scans"; +/** Betriebsart, auf die die Liste eingeschränkt ist. */ +type ModeFilter = "alle" | "dynamic" | "static"; const SORT_LABELS: Record = { neueste: "Neueste zuerst", @@ -51,6 +53,12 @@ const SORT_LABELS: Record = { scans: "Meiste Scans", }; +const MODE_LABELS: Record = { + alle: "Alle Arten", + dynamic: "Nur dynamische", + static: "Nur statische", +}; + /** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */ const VIEW_STORAGE_KEY = "qrgen.codes.view"; @@ -94,6 +102,7 @@ export function CodesList({ const [query, setQuery] = useState(""); const [sort, setSort] = useState("neueste"); const [groupFilter, setGroupFilter] = useState("alle"); + const [modeFilter, setModeFilter] = useState("alle"); // Erst nach dem Einhängen lesen — auf dem Server gibt es keinen // localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern. @@ -107,6 +116,14 @@ export function CodesList({ window.localStorage.setItem(VIEW_STORAGE_KEY, next); }; + const changeModeFilter = (next: ModeFilter) => { + setModeFilter(next); + // Statische Codes zählen keine Scans. Bleibt die Sortierung danach + // stehen, sortiert die Liste nach lauter Nullen — und die Auswahl bietet + // eine Reihenfolge an, die es dort gar nicht gibt. + if (next === "static" && sort === "scans") setSort("neueste"); + }; + const load = useCallback(async () => { try { const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes"); @@ -241,11 +258,25 @@ export function CodesList({ ); }, [codes]); + // Welche Betriebsarten überhaupt vorkommen. Ein Filter, der nichts zu + // filtern hat, steht nur im Weg — deshalb erscheint er erst, wenn beide + // Arten in der Liste stehen. + const kinds = useMemo(() => { + let dynamisch = false; + let statisch = false; + for (const code of codes ?? []) { + if (code.mode === "static") statisch = true; + else dynamisch = true; + } + return { dynamisch, statisch, beide: dynamisch && statisch }; + }, [codes]); + const visible = useMemo(() => { const needle = query.trim().toLowerCase(); const filtered = (codes ?? []).filter((code) => { if (groupFilter !== "alle" && code.groupId !== groupFilter) return false; + if (modeFilter !== "alle" && code.mode !== modeFilter) return false; if (!needle) return true; return [ displayName(code), @@ -273,7 +304,7 @@ export function CodesList({ } }); return sorted; - }, [codes, query, sort, groupFilter]); + }, [codes, query, sort, groupFilter, modeFilter]); /* ---------------------------------------------------------------- View */ @@ -287,6 +318,10 @@ export function CodesList({ const svgHref = (code: QrCodeDto) => `/api/codes/${code.id}/qr?format=svg&download=1`; + // Zeigt die Liste ausschließlich statische Codes, hat die Scan-Spalte + // nichts zu sagen — dann fällt sie weg, samt der Sortierung danach. + const nurStatische = modeFilter === "static" || (kinds.statisch && !kinds.dynamisch); + const toolbar = codes.length > 0 && (
@@ -320,17 +355,36 @@ export function CodesList({ ) : null} + {kinds.beide ? ( + + ) : null} +
0 && visible.length === 0 ? (

- Kein Code passt zur Suche.{" "} + Kein Code passt zu Suche und Filtern.{" "} @@ -192,6 +224,16 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) { Gruppen zuteilen +

+ + {reports[domain.id] ? ( + + ) : null}
diff --git a/src/components/domain-bericht.tsx b/src/components/domain-bericht.tsx new file mode 100644 index 0000000..9a92d50 --- /dev/null +++ b/src/components/domain-bericht.tsx @@ -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 = { + 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 = { + 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 = { + 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 ( +
+

+ {FAZIT[bericht.gesamt]} +

+ +
    + {bericht.schritte.map((schritt) => { + const zeichen = SYMBOLE[schritt.status]; + return ( +
  • + + {zeichen.wort}: + + {schritt.titel} + + + {schritt.detail} + +
  • + ); + })} +
+ +

+ Aufgerufen wurde{" "} + {bericht.geprueft} — von dort, wo + dieser Container läuft. Bei internem DNS oder Hairpin-NAT kann die + Prüfung scheitern, obwohl von außen alles stimmt. +

+
+ ); +} diff --git a/src/components/domain-hilfe.tsx b/src/components/domain-hilfe.tsx new file mode 100644 index 0000000..11c19e7 --- /dev/null +++ b/src/components/domain-hilfe.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { HelpCode, HelpStep } from "@/components/help-button"; + +/** + * Der Inhalt der Kurzhilfe auf der Domain-Seite. + * + * Verdichtet aus dem README-Abschnitt „Hinter einem Reverse Proxy“. Bewusst + * nur die Schritte, die diese Anwendung betreffen — wie man im eigenen Proxy + * einen Eintrag anlegt, steht in dessen Handbuch besser. + */ +export function DomainHilfe({ baseUrlHostname }: { baseUrlHostname: string }) { + const beispiel = baseUrlHostname || "qr.example.com"; + + return ( +
+

+ 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. +

+ +
    + +

    + Beim Anbieter der Domain einen A-Record (bei IPv6 + zusätzlich AAAA) auf die öffentliche Adresse des + Reverse Proxy setzen. Ein CNAME auf einen bestehenden Namen tut es + auch. +

    +

    + Steht der Name nur im internen DNS oder ist er nur über VPN + erreichbar, funktionieren gedruckte Codes{" "} + auch nur dort. Für Aushänge, Flyer und + Verpackungen muss er öffentlich auflösbar sein. +

    +
    + + +

    + Für die Domain einen eigenen Eintrag anlegen, der auf diese + Anwendung zeigt — Ziel ist der Docker-Host auf Port{" "} + 3000, oder im selben + Docker-Netz der Servicename{" "} + app. +

    +

    + 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: +

    + {`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;`} +

    + Ohne X-Forwarded-Host{" "} + landet jeder Aufruf unter der Standard-Domain, und Kurz-URLs der + Zweitdomain werden nicht gefunden. +

    +
    + + +

    + HTTPS erledigt der Proxy. Im Nginx Proxy Manager ist das der Reiter{" "} + SSL mit Force SSL; Traefik und Caddy holen das + Zertifikat selbst, sobald der DNS-Eintrag steht. +

    +

    + 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. +

    +
    + + +

    + Über Domain registrieren den Hostnamen eintragen — + genau so, wie er im Browser steht, ohne{" "} + https:// und ohne Pfad: +

    + {beispiel} +

    + 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. +

    +
    + + +

    + Eine frisch registrierte Domain gehört noch niemandem. Erst über{" "} + Gruppen zuteilen können Mitglieder dieser Gruppe + dort Codes anlegen. Die als Voreinstellung markierte + Domain ist die, die beim Anlegen vorausgewählt ist. +

    +
    + + +

    Von außerhalb des eigenen Netzes:

    + {`curl -I https://${beispiel}/`} +

    + Kommt eine Antwort dieser Anwendung, stimmen DNS, Proxy und + Zertifikat. Danach einen Code der zugeteilten Gruppe anlegen, die + Domain auswählen und den fertigen Code einmal mit dem Handy + scannen — das ist die einzige Prüfung, die wirklich zählt. +

    +
    +
+ +
+

+ Was BASE_URL damit zu tun hat +

+

+ Die Umgebungsvariable{" "} + BASE_URL ist die + Standard-Adresse: Sie greift bei Aufrufen über eine nicht + registrierte Adresse und bestimmt, ob Kurz-URLs mit{" "} + http oder{" "} + https gebildet werden. + Weitere Domains werden hier registriert, nicht dort eingetragen. +

+
+
+ ); +} diff --git a/src/components/help-button.tsx b/src/components/help-button.tsx new file mode 100644 index 0000000..fd9cdb6 --- /dev/null +++ b/src/components/help-button.tsx @@ -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 ( + <> + + + setOpen(false)} + footer={ + + } + > +
+ {children} +
+
+ + ); +} + +/* ------------------------------------------------- Bausteine für den Inhalt */ + +/** Ein nummerierter Schritt mit Überschrift. */ +export function HelpStep({ + n, + title, + children, +}: { + n: number; + title: string; + children: ReactNode; +}) { + return ( +
  • + + {n} + +
    +

    {title}

    +
    {children}
    +
    +
  • + ); +} + +/** Ein Befehl oder Wert zum Abtippen — auf schmalen Schirmen scrollbar. */ +export function HelpCode({ children }: { children: ReactNode }) { + return ( +
    +      {children}
    +    
    + ); +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 7ab0f02..91d02c8 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -238,6 +238,13 @@ const PATHS = { ), + help: ( + <> + + + + + ), } as const; export type IconName = keyof typeof PATHS; diff --git a/src/components/modal.tsx b/src/components/modal.tsx index d72150e..63caed7 100644 --- a/src/components/modal.tsx +++ b/src/components/modal.tsx @@ -9,12 +9,15 @@ export function Modal({ onClose, children, footer, + size = "md", }: { open: boolean; title: string; onClose: () => void; children: ReactNode; footer?: ReactNode; + /** `lg` für längere Texte wie eine Kurzhilfe. */ + size?: "md" | "lg"; }) { const ref = useRef(null); @@ -39,7 +42,9 @@ export function Modal({ // Klick auf die Hintergrundfläche des Dialogs schließt ihn. if (event.target === ref.current) onClose(); }} - className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50" + className={`m-auto w-[calc(100%-2rem)] ${ + size === "lg" ? "max-w-2xl" : "max-w-lg" + } rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50`} >

    {title}

    diff --git a/src/components/qr-generator.tsx b/src/components/qr-generator.tsx index 5f1e9f3..34ea221 100644 --- a/src/components/qr-generator.tsx +++ b/src/components/qr-generator.tsx @@ -43,13 +43,31 @@ function sameData(a: unknown, b: unknown): boolean { return stableStringify(a) === stableStringify(b); } +/** Der bereits abgelegte Code, den die Dublettenprüfung gemeldet hat. */ +type Dublette = { id: string; label: string | null; createdAt: string }; + +/** Liest die Meldung aus einer 409-Antwort — oder gibt auf. */ +function leseDublette(details: unknown): Dublette | null { + if (details === null || typeof details !== "object") return null; + const feld = (details as { duplicate?: unknown }).duplicate; + if (feld === null || typeof feld !== "object") return null; + const { id, label, createdAt } = feld as Record; + if (typeof id !== "string" || typeof createdAt !== "string") return null; + return { id, label: typeof label === "string" ? label : null, createdAt }; +} + +const dublettenDatum = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }); + export function QrGenerator({ typeId, /** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */ 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]; @@ -65,6 +83,14 @@ export function QrGenerator({ const [saved, setSaved] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + /** + * Gemeldete Dublette samt dem Inhalt, für den sie gilt. Ändert sich der + * Inhalt, ist die Warnung überholt und verschwindet von selbst — ohne + * einen Effekt, der sie hinterherräumt. + */ + const [duplicate, setDuplicate] = useState< + { treffer: Dublette; inhalt: string } | null + >(null); const [copied, setCopied] = useState(false); const [preview, setPreview] = useState(null); @@ -187,10 +213,18 @@ export function QrGenerator({ !sameData(saved.design, design) || (saved.label ?? "") !== label.trim()); - const handleSave = useCallback(async () => { + // Nur zeigen, solange der Inhalt derselbe ist, für den die Meldung kam. + const dublette = + duplicate !== null && + duplicate.inhalt === stableStringify(validation.ok ? validation.data : data) + ? duplicate.treffer + : null; + + const handleSave = useCallback(async (trotzDublette = false) => { if (!validation.ok || !designValid || slugProblem) return; setSaving(true); setSaveError(null); + setDuplicate(null); try { const body = { type: typeId, @@ -200,6 +234,8 @@ export function QrGenerator({ label: label.trim() || undefined, // Ein statischer Code hat keinen Kurz-Link. slug: isDynamic ? wunschSlug.trim() || undefined : undefined, + // Nur beim zweiten Anlauf, nachdem die Warnung dastand. + allowDuplicate: trotzDublette, }; const result = saved ? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, { @@ -212,11 +248,20 @@ export function QrGenerator({ }); setSaved(result.code); } catch (error) { - setSaveError( - error instanceof ApiError - ? error.message - : "Der Code konnte nicht gespeichert werden.", - ); + // Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage. + const gemeldet = + error instanceof ApiError && error.status === 409 + ? leseDublette(error.details) + : null; + if (gemeldet) { + setDuplicate({ treffer: gemeldet, inhalt: stableStringify(validation.data) }); + } else { + setSaveError( + error instanceof ApiError + ? error.message + : "Der Code konnte nicht gespeichert werden.", + ); + } } finally { setSaving(false); } @@ -232,6 +277,7 @@ export function QrGenerator({ setIsDynamic(dynamisch); setSaved(null); setSaveError(null); + setDuplicate(null); }, []); const handleCopy = useCallback(async () => { @@ -423,14 +469,43 @@ export function QrGenerator({
    ) : ( - +
    + {dublette ? ( + + + Diesen Inhalt hast du schon abgelegt + {" "} + — {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "} + vom {dublettenDatum.format(new Date(dublette.createdAt))}. + Zweimal derselbe Inhalt ergibt zweimal denselben Code. + + Verglichen wird nur der Inhalt, nicht die Gestaltung — + derselbe Code in anderen Farben gilt hier als Dublette. + + + + Den vorhandenen ansehen + + + + + ) : null} + +
    ) ) : saved ? (
    @@ -460,7 +535,7 @@ export function QrGenerator({ {hasUnsavedChanges ? (
    diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index a3738dc..c150996 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,23 +1,42 @@ /** Kleine Hilfen für Aufrufe der eigenen API aus Client-Komponenten. */ -async function readError(response: Response): Promise { +async function readError(response: Response): Promise { + let body: unknown = null; try { - const body: unknown = await response.json(); - if ( - body !== null && - typeof body === "object" && - "error" in body && - typeof (body as { error: unknown }).error === "string" - ) { - return (body as { error: string }).error; - } + body = await response.json(); } catch { // Antwort ohne JSON-Körper — unten kommt die allgemeine Meldung. } - return `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`; + + const message = + body !== null && + typeof body === "object" && + "error" in body && + typeof (body as { error: unknown }).error === "string" + ? (body as { error: string }).error + : `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`; + + return new ApiError(message, response.status, body); } -export class ApiError extends Error {} +/** + * Fehler einer eigenen API-Antwort. + * + * Trägt neben der Meldung auch den Status und den Antwortkörper: Manche + * Fehler sind keine Sackgasse, sondern eine Rückfrage — etwa die gemeldete + * Dublette beim Ablegen, zu der die Oberfläche den vorhandenen Eintrag + * anzeigen will. + */ +export class ApiError extends Error { + readonly status: number; + readonly details: unknown; + + constructor(message: string, status = 0, details: unknown = null) { + super(message); + this.status = status; + this.details = details; + } +} export async function apiJson( url: string, @@ -34,7 +53,7 @@ export async function apiJson( }); if (!response.ok) { - throw new ApiError(await readError(response)); + throw await readError(response); } if (response.status === 204) { return undefined as T; @@ -55,7 +74,7 @@ export async function downloadFile( }); if (!response.ok) { - throw new ApiError(await readError(response)); + throw await readError(response); } const disposition = response.headers.get("Content-Disposition") ?? ""; diff --git a/src/lib/codes.ts b/src/lib/codes.ts index a11efdf..5386b0a 100644 --- a/src/lib/codes.ts +++ b/src/lib/codes.ts @@ -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, diff --git a/src/lib/domain-check.ts b/src/lib/domain-check.ts new file mode 100644 index 0000000..406c222 --- /dev/null +++ b/src/lib/domain-check.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + | 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 { + 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 }; +} diff --git a/src/lib/dto.ts b/src/lib/dto.ts index c3bf3ed..df53a51 100644 --- a/src/lib/dto.ts +++ b/src/lib/dto.ts @@ -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; diff --git a/src/lib/duplicates.ts b/src/lib/duplicates.ts new file mode 100644 index 0000000..af4f1b3 --- /dev/null +++ b/src/lib/duplicates.ts @@ -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, +): Promise { + 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(), + }; +} diff --git a/src/lib/qr/design.ts b/src/lib/qr/design.ts index 2b2db02..73b70e0 100644 --- a/src/lib/qr/design.ts +++ b/src/lib/qr/design.ts @@ -13,6 +13,29 @@ export const EC_LEVEL_LABELS: Record = { 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; @@ -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; diff --git a/src/lib/qr/logo.ts b/src/lib/qr/logo.ts new file mode 100644 index 0000000..da569b7 --- /dev/null +++ b/src/lib/qr/logo.ts @@ -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 { + 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 }; +} diff --git a/src/lib/qr/render.ts b/src/lib/qr/render.ts index 055f254..a86d895 100644 --- a/src/lib/qr/render.ts +++ b/src/lib/qr/render.ts @@ -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 { + 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 ``, + * 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 = + `` + + ``; + + return svg.replace("", `${einsatz}`); +} + +/* ---------------------------------------------------------------- Ausgabe */ + export async function renderSvg( content: string, design: QrDesign, + logo: QrLogo | null = null, ): Promise { 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 { 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); } diff --git a/src/lib/uploads.shared.ts b/src/lib/uploads.shared.ts index a13dc80..6d2c564 100644 --- a/src/lib/uploads.shared.ts +++ b/src/lib/uploads.shared.ts @@ -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. diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts index 87d5ff8..ce49cb3 100644 --- a/src/lib/uploads.ts +++ b/src/lib/uploads.ts @@ -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 { + 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 { + 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;