From 1cf1df54df9bc3e818e885e915b6e429bcfde04f Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 21 Aug 2026 02:36:18 +0200 Subject: [PATCH 1/5] Kurzhilfe erklaert das Hinzufuegen einer Domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Domain zu registrieren genuegt nicht — DNS, Reverse Proxy und Zertifikat muessen dazu passen. Bislang stand das nur im README, also genau dort nicht, wo man es braucht. Auf der Domain-Seite oeffnet ein Knopf die Erklaerung in sechs Schritten, mit den drei noetigen Kopfzeilen und einem Pruefbefehl. Der Knopf selbst ist als HelpButton allgemein gehalten, damit andere Verwaltungsseiten spaeter dieselbe Form bekommen koennen. Refs #4 --- src/components/domain-admin.tsx | 10 ++- src/components/domain-hilfe.tsx | 128 ++++++++++++++++++++++++++++++++ src/components/help-button.tsx | 94 +++++++++++++++++++++++ src/components/icons.tsx | 7 ++ src/components/modal.tsx | 7 +- 5 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 src/components/domain-hilfe.tsx create mode 100644 src/components/help-button.tsx diff --git a/src/components/domain-admin.tsx b/src/components/domain-admin.tsx index e10e403..9868d87 100644 --- a/src/components/domain-admin.tsx +++ b/src/components/domain-admin.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from "react"; +import { DomainHilfe } from "@/components/domain-hilfe"; +import { HelpButton } from "@/components/help-button"; import { Icon } from "@/components/icons"; import { Modal } from "@/components/modal"; import { @@ -134,10 +136,14 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) { Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung weiterleitet. Die Weiterleitung erkennt sie am{" "} Host-Header und prüft ihn - gegen diese Liste — ein unbekannter Hostname endet mit 404. + gegen diese Liste — ein unbekannter Hostname endet mit 404. Was dafür + einzurichten ist, steht in der Kurzhilfe. -
+
+ + + 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}

    From 142536140cfc8ceb4105008f42d8feb30a5f7901 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 21 Aug 2026 02:43:13 +0200 Subject: [PATCH 2/5] Meine Codes: Liste nach Art einschraenken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seit statische Codes ablegbar sind, mischen sich zwei Arten in derselben Liste. Eine Auswahl schraenkt sie auf dynamische oder statische ein — und erscheint nur, wenn ueberhaupt beide vorkommen. Zeigt die Liste nur statische Codes, faellt die Scan-Spalte weg und die Sortierung nach Scans mit ihr: Statische zaehlen keine Scans, die Spalte waere eine Reihe aus Strichen. Stand die Sortierung darauf, springt sie auf "Neueste zuerst". Die Beschreibung der Seite behauptete noch, statische Codes wuerden nicht gespeichert. Das stimmt seit #2 nicht mehr. Refs #5 --- README.md | 9 ++- src/app/(app)/meine-codes/page.tsx | 6 +- src/components/codes-list.tsx | 100 +++++++++++++++++++++++------ 3 files changed, 91 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 86826ca..e6e6d2c 100644 --- a/README.md +++ b/README.md @@ -618,13 +618,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/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/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.{" "}

    ) : ( - +
    + {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 +532,7 @@ export function QrGenerator({ {hasUnsavedChanges ? ( +
    + + {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/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 }; +} From c06f87e6d900e96ab6d3e9cd6ec795a2264d5536 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 21 Aug 2026 03:22:20 +0200 Subject: [PATCH 5/5] Logo in der Mitte des QR-Codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 + docs/funktionen.md | 23 +++ package-lock.json | 75 +++++++- package.json | 1 + .../20260821020000_logo_im_code/migration.sql | 11 ++ prisma/schema.prisma | 8 + src/app/(app)/erstellen/[type]/page.tsx | 13 +- src/app/api/codes/[id]/qr/route.ts | 9 +- src/app/api/codes/[id]/route.ts | 27 ++- src/app/api/codes/route.ts | 22 ++- src/app/api/qr/download/route.ts | 6 +- src/app/api/qr/preview/route.ts | 4 +- src/components/design-panel.tsx | 164 +++++++++++++++++- src/components/qr-generator.tsx | 9 +- src/lib/codes.ts | 5 +- src/lib/dto.ts | 5 +- src/lib/qr/design.ts | 56 +++++- src/lib/qr/logo.ts | 52 ++++++ src/lib/qr/render.ts | 107 +++++++++++- src/lib/uploads.shared.ts | 10 ++ src/lib/uploads.ts | 30 ++++ 21 files changed, 611 insertions(+), 28 deletions(-) create mode 100644 prisma/migrations/20260821020000_logo_im_code/migration.sql create mode 100644 src/lib/qr/logo.ts diff --git a/README.md b/README.md index 86826ca..43d5ddd 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. diff --git a/docs/funktionen.md b/docs/funktionen.md index 1662174..c135543 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -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 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/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 898c2c5..182c735 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/src/app/(app)/erstellen/[type]/page.tsx b/src/app/(app)/erstellen/[type]/page.tsx index 41df18a..61e1aa9 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"; @@ -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 ; + // Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur + // der eigene Upload. + const { logoUploadId } = await getBranding(); + + return ( + + ); } 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 239279e..4f5ba8d 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -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 { 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 { }); // 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/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/design-panel.tsx b/src/components/design-panel.tsx index 3b7e150..bd43072 100644 --- a/src/components/design-panel.tsx +++ b/src/components/design-panel.tsx @@ -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) => onChange({ ...design, ...changes }); const contrast = evaluateContrast(design.fgColor, design.bgColor); @@ -165,7 +173,11 @@ export function DesignPanel({
    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" /> + +
    ); } + +/* -------------------------------------------------------------------- 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) => 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 ( +
    + + Logo in der Mitte + + + + + {an ? ( +
    + {hasBrandingLogo ? ( +
    + {( + [ + ["branding", "Logo dieser Installation"], + ["upload", "Eigenes Logo hochladen"], + ] as [LogoSource, string][] + ).map(([quelle, text]) => ( + + ))} +
    + ) : null} + + {design.logoSource === "upload" ? ( +
    + patch({ logoUploadId: datei.fileId })} + /> + {design.logoUploadId ? null : ( +

    + Noch keine Datei gewählt — bis dahin bleibt die Mitte frei. +

    + )} +
    + ) : null} + + + patch({ logoScale: Number(event.target.value) })} + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600" + /> + + + {design.logoScale > LOGO_SCALE_WARN ? ( + + 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. + + ) : null} + +

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

    +
    + ) : null} +
    + ); +} diff --git a/src/components/qr-generator.tsx b/src/components/qr-generator.tsx index 5f1e9f3..0b0548e 100644 --- a/src/components/qr-generator.tsx +++ b/src/components/qr-generator.tsx @@ -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({ Gestaltung - +
    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/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/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;