Merge branch 'main' into feat/11-typen-je-gruppe

This commit is contained in:
Florian Wilde
2026-08-21 10:58:08 +02:00
36 changed files with 1896 additions and 85 deletions
+33 -14
View File
@@ -1,23 +1,42 @@
/** Kleine Hilfen für Aufrufe der eigenen API aus Client-Komponenten. */
async function readError(response: Response): Promise<string> {
async function readError(response: Response): Promise<ApiError> {
let body: unknown = null;
try {
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<T>(
url: string,
@@ -34,7 +53,7 @@ export async function apiJson<T>(
});
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") ?? "";
+4 -1
View File
@@ -3,7 +3,7 @@ import "server-only";
import type { Prisma, QrCode } from "@/generated/prisma/client";
import type { QrCodeDto } from "@/lib/dto";
import { landingUrl, shortUrl } from "@/lib/env";
import type { EcLevel } from "@/lib/qr/design";
import type { EcLevel, LogoSource } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry";
import type { QrTypeId } from "@/lib/qr/type-module";
@@ -83,6 +83,9 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
size: code.size,
ecLevel: code.ecLevel as EcLevel,
margin: code.margin,
logoSource: code.logoSource as LogoSource | null,
logoUploadId: code.logoUploadId,
logoScale: code.logoScale,
},
scans: code.scans,
lastScanAt: code.lastScanAt?.toISOString() ?? null,
+478
View File
@@ -0,0 +1,478 @@
import "server-only";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { lookup } from "node:dns/promises";
import { connect, type PeerCertificate } from "node:tls";
import { prisma } from "@/lib/db";
/**
* Domain-Check: Führt die Domain wirklich hierher?
*
* Eine Domain zu registrieren ist der kleinste Teil der Arbeit — DNS, Reverse
* Proxy und Zertifikat liegen außerhalb dieser Anwendung, und wenn dort etwas
* fehlt, merkt man es erst am gedruckten Code, der nicht aufgeht.
*
* Die Prüfung geht deshalb den Weg der Scanner nach: Namen auflösen, das
* Zertifikat ansehen, die Adresse aufrufen — und sich beweisen lassen, dass
* am anderen Ende **diese** Installation steht. Der Beweis ist ein HMAC über
* eine frische Zufallszahl; das Geheimnis dafür verlässt den Server nie.
*
* Die Grenze der Aussage steht in der Meldung selbst: Der Aufruf startet dort,
* wo dieser Container läuft. Wer internes DNS oder Hairpin-NAT hat, bekommt
* hier womöglich einen Fehler, obwohl von außen alles stimmt.
*/
export type SchrittName = "dns" | "erreichbar" | "zertifikat" | "ziel";
export type SchrittStatus = "ok" | "warnung" | "fehler" | "uebersprungen";
export type Pruefschritt = {
name: SchrittName;
titel: string;
status: SchrittStatus;
detail: string;
};
export type Pruefbericht = {
hostname: string;
/** Die Adresse, die tatsächlich aufgerufen wurde. */
geprueft: string;
gesamt: "ok" | "warnung" | "fehler";
schritte: Pruefschritt[];
};
/** Antwort der öffentlichen Beweis-Route. */
export type Domainbeweis = { app: string; proof: string };
const APP_KENNUNG = "qrcode-generator";
const ZEITGRENZE_MS = 8000;
/* ------------------------------------------------------------- Geheimnis */
/**
* Das Geheimnis für den Beweis — beim ersten Aufruf erzeugt.
*
* Bewusst in der Datenbank und nicht im Arbeitsspeicher: Die prüfende Route
* und die antwortende Route sind zwei getrennte Bündel und teilen sich keinen
* Modulzustand. Ein Wert im Speicher wäre auf beiden Seiten ein anderer.
*/
export async function getCheckSecret(): Promise<string> {
const vorhanden = await prisma.instanceSetting.findUnique({
where: { id: "instance" },
select: { checkSecret: true },
});
if (vorhanden?.checkSecret) return vorhanden.checkSecret;
const neu = randomBytes(32).toString("hex");
// `updateMany` mit der Bedingung „noch leer“ statt eines schlichten
// `update`: Zwei gleichzeitige Prüfungen sollen nicht zwei verschiedene
// Geheimnisse hinterlassen, von denen eines sofort wieder ungültig ist.
await prisma.instanceSetting.upsert({
where: { id: "instance" },
create: { id: "instance", checkSecret: neu },
update: {},
});
await prisma.instanceSetting.updateMany({
where: { id: "instance", checkSecret: null },
data: { checkSecret: neu },
});
const danach = await prisma.instanceSetting.findUnique({
where: { id: "instance" },
select: { checkSecret: true },
});
return danach?.checkSecret ?? neu;
}
export function bildeBeweis(secret: string, nonce: string): string {
return createHmac("sha256", secret).update(nonce).digest("hex");
}
function gleich(a: string, b: string): boolean {
const links = Buffer.from(a, "utf8");
const rechts = Buffer.from(b, "utf8");
if (links.length !== rechts.length) return false;
return timingSafeEqual(links, rechts);
}
/* ------------------------------------------------------------------ DNS */
function trenneHost(hostname: string): { host: string; port: number | null } {
const treffer = /^(.+):(\d{1,5})$/.exec(hostname);
if (!treffer) return { host: hostname, port: null };
return { host: treffer[1], port: Number(treffer[2]) };
}
async function pruefeDns(host: string): Promise<Pruefschritt> {
try {
const adressen = await lookup(host, { all: true });
if (adressen.length === 0) {
return {
name: "dns",
titel: "DNS",
status: "fehler",
detail: `Für ${host} ist keine Adresse hinterlegt.`,
};
}
return {
name: "dns",
titel: "DNS",
status: "ok",
detail: `löst auf ${adressen.map((a) => a.address).join(", ")}`,
};
} catch (error) {
const code = (error as { code?: string }).code;
return {
name: "dns",
titel: "DNS",
status: "fehler",
detail:
code === "ENOTFOUND"
? `Der Name ${host} ist unbekannt — es fehlt der A- oder AAAA-Eintrag.`
: `Der Name ${host} ließ sich nicht auflösen (${code ?? "unbekannter Fehler"}).`,
};
}
}
/* ----------------------------------------------------------- Zertifikat */
type Zertifikatslage =
| { erreichbar: true; schritt: Pruefschritt }
| { erreichbar: false; schritt: Pruefschritt };
const datumsformat = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
function beschreibeZertifikat(
zertifikat: PeerCertificate,
authorized: boolean,
fehler: string | undefined,
): Pruefschritt {
const bis = new Date(zertifikat.valid_to);
const tage = Math.floor((bis.getTime() - Date.now()) / 86_400_000);
// Die Felder eines Zertifikats dürfen mehrfach vorkommen; Node liefert sie
// dann als Liste.
const ersteZeile = (wert: string | string[] | undefined): string | undefined =>
Array.isArray(wert) ? wert[0] : wert;
const aussteller =
ersteZeile(zertifikat.issuer?.O) ??
ersteZeile(zertifikat.issuer?.CN) ??
"unbekannt";
if (!authorized) {
return {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail: uebersetzeTlsFehler(fehler, aussteller, bis),
};
}
if (tage < 0) {
return {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail: `abgelaufen am ${datumsformat.format(bis)}`,
};
}
return {
name: "zertifikat",
titel: "Zertifikat",
status: tage <= 14 ? "warnung" : "ok",
detail:
tage <= 14
? `läuft in ${tage} Tagen ab (${datumsformat.format(bis)}), ausgestellt von ${aussteller}`
: `gültig bis ${datumsformat.format(bis)}, ausgestellt von ${aussteller}`,
};
}
function uebersetzeTlsFehler(
fehler: string | undefined,
aussteller: string,
bis: Date,
): string {
switch (fehler) {
case "CERT_HAS_EXPIRED":
return `abgelaufen am ${datumsformat.format(bis)}`;
case "DEPTH_ZERO_SELF_SIGNED_CERT":
case "SELF_SIGNED_CERT_IN_CHAIN":
return "selbst ausgestellt — Scanner-Apps brechen darauf ohne Erklärung ab";
case "ERR_TLS_CERT_ALTNAME_INVALID":
return "gilt für einen anderen Namen — im Proxy ist die falsche Domain hinterlegt";
case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
return `Zwischenzertifikat fehlt — der Proxy liefert die Kette unvollständig aus (${aussteller})`;
default:
return `nicht gültig (${fehler ?? "unbekannter Grund"})`;
}
}
/**
* Der Grund, aus dem ein Zertifikat durchfiel.
*
* Node liefert ihn je nach Fassung als Fehlerobjekt mit `code`, als
* Fehlerobjekt ohne `code` oder schlicht als Zeichenkette.
*/
function tlsGrund(fehler: unknown): string | undefined {
if (fehler === null || fehler === undefined) return undefined;
if (typeof fehler === "string") return fehler;
const objekt = fehler as { code?: unknown; message?: unknown };
if (typeof objekt.code === "string") return objekt.code;
if (typeof objekt.message === "string") return objekt.message;
return undefined;
}
async function pruefeZertifikat(host: string): Promise<Zertifikatslage> {
return new Promise((aufloesen) => {
// `rejectUnauthorized: false`, um ein schlechtes Zertifikat **beschreiben**
// zu können statt nur an ihm zu scheitern. Übertragen wird hierüber nichts.
const socket = connect({
host,
port: 443,
servername: host,
rejectUnauthorized: false,
timeout: ZEITGRENZE_MS,
});
const fertig = (lage: Zertifikatslage) => {
socket.removeAllListeners();
socket.destroy();
aufloesen(lage);
};
socket.once("secureConnect", () => {
fertig({
erreichbar: true,
schritt: beschreibeZertifikat(
socket.getPeerCertificate(),
socket.authorized,
tlsGrund(socket.authorizationError),
),
});
});
socket.once("timeout", () => {
fertig({
erreichbar: false,
schritt: {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail: "Auf Port 443 kam keine Antwort — läuft dort ein Webserver?",
},
});
});
socket.once("error", (error: NodeJS.ErrnoException) => {
fertig({
erreichbar: false,
schritt: {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail:
error.code === "ECONNREFUSED"
? "Port 443 nimmt keine Verbindung an — es gibt hier kein HTTPS."
: `Die TLS-Verbindung scheiterte (${error.code ?? error.message}).`,
},
});
});
});
}
/* --------------------------------------------------------------- Aufruf */
type Abrufergebnis = {
erreichbar: Pruefschritt;
ziel: Pruefschritt;
};
/**
* Der eigentliche Grund hinter einem `fetch failed`.
*
* Node verpackt Netzwerkfehler; die Meldung an der Oberfläche lautet sonst
* schlicht „fetch failed" und hilft niemandem weiter.
*/
function ursache(error: unknown): string | undefined {
const grund = (error as { cause?: unknown }).cause;
if (grund === null || grund === undefined) return undefined;
const objekt = grund as { code?: unknown; message?: unknown };
if (typeof objekt.code === "string") return objekt.code;
if (typeof objekt.message === "string") return objekt.message;
return undefined;
}
async function rufeAuf(
basis: string,
nonce: string,
secret: string,
): Promise<Abrufergebnis> {
const start = Date.now();
try {
const antwort = await fetch(`${basis}/api/domain-check?nonce=${nonce}`, {
redirect: "follow",
cache: "no-store",
signal: AbortSignal.timeout(ZEITGRENZE_MS),
headers: { "User-Agent": `${APP_KENNUNG}-domain-check` },
});
const dauer = Date.now() - start;
if (!antwort.ok) {
return {
erreichbar: {
name: "erreichbar",
titel: "Erreichbar",
status: "fehler",
detail: `HTTP ${antwort.status} nach ${dauer} ms`,
},
ziel: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail:
antwort.status === 404
? "Es antwortet zwar etwas, aber nicht diese Anwendung — der Proxy leitet woandershin."
: "Wegen der Fehlerantwort nicht feststellbar.",
},
};
}
const erreichbar: Pruefschritt = {
name: "erreichbar",
titel: "Erreichbar",
status: dauer > 3000 ? "warnung" : "ok",
detail: `HTTP ${antwort.status} nach ${dauer} ms`,
};
const koerper = (await antwort.json().catch(() => null)) as
| Partial<Domainbeweis>
| null;
if (
koerper === null ||
koerper.app !== APP_KENNUNG ||
typeof koerper.proof !== "string"
) {
return {
erreichbar,
ziel: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail:
"Die Antwort stammt nicht von dieser Anwendung — hinter der Domain steht etwas anderes.",
},
};
}
return {
erreichbar,
ziel: gleich(koerper.proof, bildeBeweis(secret, nonce))
? {
name: "ziel",
titel: "Ziel",
status: "ok",
detail: "diese Installation",
}
: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail:
"Es antwortet eine andere Installation dieser Anwendung, nicht diese hier.",
},
};
} catch (error) {
const code = (error as { code?: string; name?: string }).code;
const name = (error as { name?: string }).name;
const grund =
name === "TimeoutError"
? `Keine Antwort binnen ${ZEITGRENZE_MS / 1000} Sekunden.`
: code === "ECONNREFUSED"
? "Die Verbindung wurde abgelehnt — dort nimmt kein Webserver an."
: `Der Aufruf scheiterte (${code ?? ursache(error) ?? (error as Error).message}).`;
return {
erreichbar: {
name: "erreichbar",
titel: "Erreichbar",
status: "fehler",
detail: grund,
},
ziel: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail: "Ohne Antwort nicht feststellbar.",
},
};
}
}
/* ---------------------------------------------------------------- Ganzes */
/**
* Prüft eine registrierte Domain von diesem Server aus.
*
* Der Hostname stammt aus der Datenbank, nicht aus der Anfrage — es lässt
* sich hierüber also keine beliebige Adresse abrufen.
*/
export async function pruefeDomain(hostname: string): Promise<Pruefbericht> {
const { host, port } = trenneHost(hostname);
const secret = await getCheckSecret();
const nonce = randomBytes(16).toString("hex");
const schritte: Pruefschritt[] = [];
const dns = await pruefeDns(host);
schritte.push(dns);
// Ohne aufgelösten Namen gibt es nichts anzurufen. Die Folgeschritte
// trotzdem zu versuchen brächte drei weitere Fehlermeldungen, die alle
// dasselbe sagen — und die eine, auf die es ankommt, ginge darin unter.
if (dns.status === "fehler") {
for (const [name, titel] of [
["zertifikat", "Zertifikat"],
["erreichbar", "Erreichbar"],
["ziel", "Ziel"],
] as [SchrittName, string][]) {
schritte.push({
name,
titel,
status: "uebersprungen",
detail: "Ohne aufgelösten Namen nicht prüfbar.",
});
}
return { hostname, geprueft: host, gesamt: "fehler", schritte };
}
// Ein Eintrag mit Port meint eine Adresse ohne Reverse Proxy — etwa die
// Standard-Domain aus BASE_URL im Entwicklungsbetrieb. Dort gibt es kein
// Zertifikat zu prüfen, und der Aufruf läuft über http.
let basis: string;
if (port !== null) {
schritte.push({
name: "zertifikat",
titel: "Zertifikat",
status: "uebersprungen",
detail: `Der Eintrag nennt Port ${port} — geprüft wird ohne HTTPS.`,
});
basis = `http://${host}:${port}`;
} else {
const lage = await pruefeZertifikat(host);
schritte.push(lage.schritt);
// Ohne HTTPS lohnt der zweite Anlauf über Port 80: So steht im Bericht,
// ob überhaupt etwas antwortet — das ist der Unterschied zwischen
// „Zertifikat fehlt" und „da ist gar nichts".
basis = lage.erreichbar ? `https://${host}` : `http://${host}`;
}
const { erreichbar, ziel } = await rufeAuf(basis, nonce, secret);
schritte.push(erreichbar, ziel);
const gesamt = schritte.some((s) => s.status === "fehler")
? "fehler"
: schritte.some((s) => s.status === "warnung")
? "warnung"
: "ok";
return { hostname, geprueft: basis, gesamt, schritte };
}
+4 -1
View File
@@ -1,4 +1,4 @@
import type { EcLevel } from "@/lib/qr/design";
import type { EcLevel, LogoSource } from "@/lib/qr/design";
import type { QrTypeId } from "@/lib/qr/type-module";
/** Statisch oder dynamisch — bestimmt, was ein Code überhaupt kann. */
@@ -35,6 +35,9 @@ export type QrCodeDto = {
size: number;
ecLevel: EcLevel;
margin: number;
logoSource: LogoSource | null;
logoUploadId: string | null;
logoScale: number;
};
scans: number;
lastScanAt: string | null;
+61
View File
@@ -0,0 +1,61 @@
import "server-only";
import { toJsonInput } from "@/lib/codes";
import { prisma } from "@/lib/db";
/**
* Dublettenprüfung für statische Codes.
*
* Ein statischer Code trägt seinen Inhalt selbst — zweimal derselbe Inhalt
* ergibt zweimal exakt dasselbe Bild. Solche Einträge sind nicht falsch, aber
* sie fluten die Liste, und beim Wiederfinden weiß niemand mehr, welcher der
* beiden gemeint war.
*
* Bewusst **nur** für statische Codes: Zwei dynamische Codes mit demselben
* Ziel sind ein üblicher Fall — zwei Kampagnen, zwei Kurz-Links, getrennte
* Zähler. Da wäre eine Warnung nur lästig.
*
* Bewusst **ohne** die Gestaltung: Verglichen wird der Inhalt. Derselbe Inhalt
* einmal in Schwarz-Weiß für den Druck und einmal in Hausfarben ist zwar
* bewusst zweimal angelegt — aber eben auch zweimal derselbe Code. Die
* Warnung sagt das und lässt sich übergehen.
*/
export type StaticDuplicate = {
id: string;
label: string | null;
createdAt: string;
};
/**
* Sucht einen bereits abgelegten statischen Code mit gleichem Typ und
* gleichem Inhalt in derselben Gruppe — den zuletzt angelegten.
*
* Die Gruppe grenzt bewusst ein: Ein Treffer aus einer fremden Gruppe wäre
* nicht bloß nutzlos, er verriete auch deren Bestand.
*/
export async function findStaticDuplicate(
groupId: string,
type: string,
data: Record<string, unknown>,
): Promise<StaticDuplicate | null> {
const treffer = await prisma.qrCode.findFirst({
where: {
mode: "static",
type,
groupId,
// Postgres vergleicht jsonb nach Struktur, nicht nach Schreibweise —
// Schlüsselreihenfolge und Leerzeichen spielen keine Rolle.
data: { equals: toJsonInput(data) },
},
orderBy: { createdAt: "desc" },
select: { id: true, label: true, createdAt: true },
});
if (!treffer) return null;
return {
id: treffer.id,
label: treffer.label,
createdAt: treffer.createdAt.toISOString(),
};
}
+55 -1
View File
@@ -13,6 +13,29 @@ export const EC_LEVEL_LABELS: Record<EcLevel, string> = {
export const MIN_SIZE = 256;
export const MAX_SIZE = 2048;
/**
* Logo in der Mitte des Codes.
*
* `branding` nimmt das Logo der Installation aus dem Erscheinungsbild,
* `upload` ein eigenes, das zu diesem Code gehört. Ohne Angabe kein Logo.
*/
export const LOGO_SOURCES = ["branding", "upload"] as const;
export type LogoSource = (typeof LOGO_SOURCES)[number];
/**
* Kantenlänge des Logos in Prozent der Kantenlänge des Codes.
*
* Bei 20 % sind knapp 4 % der Fläche verdeckt — das steckt die
* Fehlerkorrektur H (30 %) mühelos weg. Die Obergrenze liegt bei 25 %, weil
* darüber weniger die Fehlerkorrektur das Problem ist als die Scanner-Apps:
* Manche suchen in der Mitte nach Ausrichtungsmustern.
*/
export const MIN_LOGO_SCALE = 10;
export const MAX_LOGO_SCALE = 25;
/** Ab hier ein Hinweis, dass es knapp wird. */
export const LOGO_SCALE_WARN = 22;
export const DEFAULT_LOGO_SCALE = 20;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const hexColor = z
@@ -39,7 +62,25 @@ export const designSchema = z.object({
.int({ error: "Der Rand muss eine ganze Zahl sein." })
.min(0, { error: "Der Rand darf nicht negativ sein." })
.max(16, { error: "Der Rand darf höchstens 16 Module breit sein." }),
});
logoSource: z
.enum(LOGO_SOURCES, { error: "Unbekannte Herkunft des Logos." })
.nullable()
.default(null),
logoUploadId: z.string().trim().min(1).nullable().default(null),
logoScale: z.coerce
.number({ error: "Die Logogröße muss eine Zahl sein." })
.int({ error: "Die Logogröße muss eine ganze Zahl sein." })
.min(MIN_LOGO_SCALE, {
error: `Das Logo muss mindestens ${MIN_LOGO_SCALE} % breit sein.`,
})
.max(MAX_LOGO_SCALE, {
error: `Das Logo darf höchstens ${MAX_LOGO_SCALE} % breit sein — darüber wird der Code unzuverlässig.`,
})
.default(DEFAULT_LOGO_SCALE),
}).refine(
(design) => design.logoSource !== "upload" || design.logoUploadId !== null,
{ error: "Für ein eigenes Logo fehlt die hochgeladene Datei." },
);
export type QrDesign = z.infer<typeof designSchema>;
@@ -49,8 +90,21 @@ export const defaultDesign: QrDesign = {
size: 512,
ecLevel: "M",
margin: 4,
logoSource: null,
logoUploadId: null,
logoScale: DEFAULT_LOGO_SCALE,
};
/**
* Ein Logo verdeckt einen Teil des Codes. Was fehlt, muss die Fehlerkorrektur
* ausgleichen — deshalb gilt mit Logo immer **H**, unabhängig davon, was in
* der Auswahl steht. Die Oberfläche sagt das dazu; heimlich geschieht hier
* nichts.
*/
export function effectiveEcLevel(design: QrDesign): EcLevel {
return design.logoSource === null ? design.ecLevel : "H";
}
/** Farbvorschläge für die schnelle Auswahl in der Oberfläche. */
export const COLOR_PRESETS: ReadonlyArray<{
label: string;
+52
View File
@@ -0,0 +1,52 @@
import "server-only";
import { getBranding } from "@/lib/branding";
import { prisma } from "@/lib/db";
import type { QrDesign } from "@/lib/qr/design";
import { QrRequestError } from "@/lib/qr/type-module";
/** Das Bild, das in die Mitte des Codes kommt. */
export type QrLogo = {
bytes: Buffer;
mimeType: string;
};
/**
* Holt das Logo, das die Gestaltung verlangt — oder `null`.
*
* Zwei Quellen: das Logo der Installation aus dem Erscheinungsbild, oder eine
* Datei, die zu diesem Code hochgeladen wurde. Beides liegt als Upload in der
* Datenbank.
*
* Fehlt die Datei, ist das kein stiller Ausfall: Ein Code, der ohne Logo
* herauskommt, obwohl eines eingestellt war, fiele erst nach dem Druck auf.
*/
export async function ladeLogo(design: QrDesign): Promise<QrLogo | null> {
if (design.logoSource === null) return null;
const id =
design.logoSource === "branding"
? (await getBranding()).logoUploadId
: design.logoUploadId;
if (!id) {
throw new QrRequestError(
design.logoSource === "branding"
? "Für diese Installation ist kein Logo hinterlegt — unter Verwaltung → Erscheinungsbild lässt sich eines hochladen."
: "Das hochgeladene Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
);
}
const datei = await prisma.upload.findUnique({
where: { id },
select: { data: true, mimeType: true },
});
if (!datei) {
throw new QrRequestError(
"Das Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
);
}
return { bytes: Buffer.from(datei.data), mimeType: datei.mimeType };
}
+103 -4
View File
@@ -1,8 +1,10 @@
import "server-only";
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
import sharp from "sharp";
import type { QrDesign } from "@/lib/qr/design";
import { effectiveEcLevel, type QrDesign } from "@/lib/qr/design";
import type { QrLogo } from "@/lib/qr/logo";
import { UserFacingError } from "@/lib/qr/type-module";
/** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */
@@ -10,7 +12,8 @@ export class QrRenderError extends UserFacingError {}
function toOptions(design: QrDesign): QRCodeRenderersOptions {
return {
errorCorrectionLevel: design.ecLevel,
// Mit Logo gilt immer H — siehe `effectiveEcLevel`.
errorCorrectionLevel: effectiveEcLevel(design),
margin: design.margin,
width: design.size,
color: {
@@ -30,12 +33,103 @@ function wrap(error: unknown): never {
throw new QrRenderError(`Der QR-Code konnte nicht erzeugt werden: ${detail}`);
}
/* ------------------------------------------------------------------- Logo */
/**
* Wie das Logo in den Code kommt.
*
* Hinter dem Bild liegt eine Fläche in der Hintergrundfarbe — ohne sie stünde
* das Logo auf den Modulen und ginge optisch unter. Der Rand dieser Fläche
* ist ein Zehntel der Logobreite; das reicht, um es abzusetzen, ohne mehr
* Code zu verdecken als nötig.
*
* Das Bild selbst wird eingepasst, nicht beschnitten (`contain`): Ein
* Firmenlogo im Querformat soll als Ganzes zu sehen sein.
*/
const PAD_ANTEIL = 0.1;
/** Kantenlänge von Bild und Fläche, in Bildpunkten. */
function logoMasse(gesamt: number, prozent: number) {
const bild = Math.round((gesamt * prozent) / 100);
const flaeche = Math.round(bild * (1 + 2 * PAD_ANTEIL));
return { bild, flaeche, versatz: Math.round((gesamt - flaeche) / 2) };
}
async function legeLogoAuf(
qrPng: Buffer,
design: QrDesign,
logo: QrLogo,
): Promise<Buffer> {
const { bild, flaeche, versatz } = logoMasse(design.size, design.logoScale);
// Erst das Bild auf die Fläche einpassen, dann die Fläche auf den Code.
// Zwei Schritte, weil `extend` den Rand in der Hintergrundfarbe zieht — das
// ergibt die Fläche, ohne eine zweite Zeichnung zu brauchen.
const rand = Math.round((flaeche - bild) / 2);
const kachel = await sharp(logo.bytes)
.resize(bild, bild, { fit: "contain", background: design.bgColor })
.extend({
top: rand,
bottom: flaeche - bild - rand,
left: rand,
right: flaeche - bild - rand,
background: design.bgColor,
})
.png()
.toBuffer();
return sharp(qrPng)
.composite([{ input: kachel, top: versatz, left: versatz }])
.png()
.toBuffer();
}
/**
* Setzt das Logo in ein fertiges QR-SVG.
*
* Das SVG von `qrcode` hat ein `viewBox` in Modulen — gerechnet wird deshalb
* in Modulen und nicht in Bildpunkten. Eingefügt wird direkt vor `</svg>`,
* damit Fläche und Bild über den Modulen liegen.
*/
function legeLogoInSvg(svg: string, design: QrDesign, logo: QrLogo): string {
const treffer = /viewBox="0 0 (\d+(?:\.\d+)?) /.exec(svg);
if (!treffer) {
// Ohne viewBox ließe sich nichts sauber platzieren. Lieber ohne Logo
// ausliefern als an einer falschen Stelle.
console.error("QR-SVG ohne viewBox — Logo wird ausgelassen.");
return svg;
}
const kante = Number(treffer[1]);
const bild = (kante * design.logoScale) / 100;
const flaeche = bild * (1 + 2 * PAD_ANTEIL);
const versatz = (kante - flaeche) / 2;
const bildVersatz = (kante - bild) / 2;
const daten = `data:${logo.mimeType};base64,${logo.bytes.toString("base64")}`;
const rund = (wert: number) => Number(wert.toFixed(3));
const einsatz =
`<rect x="${rund(versatz)}" y="${rund(versatz)}" width="${rund(flaeche)}" height="${rund(flaeche)}" fill="${design.bgColor}"/>` +
`<image x="${rund(bildVersatz)}" y="${rund(bildVersatz)}" width="${rund(bild)}" height="${rund(bild)}" ` +
`preserveAspectRatio="xMidYMid meet" href="${daten}"/>`;
return svg.replace("</svg>", `${einsatz}</svg>`);
}
/* ---------------------------------------------------------------- Ausgabe */
export async function renderSvg(
content: string,
design: QrDesign,
logo: QrLogo | null = null,
): Promise<string> {
try {
return await QRCode.toString(content, { ...toOptions(design), type: "svg" });
const svg = await QRCode.toString(content, {
...toOptions(design),
type: "svg",
});
return logo === null ? svg : legeLogoInSvg(svg, design, logo);
} catch (error) {
wrap(error);
}
@@ -44,9 +138,14 @@ export async function renderSvg(
export async function renderPng(
content: string,
design: QrDesign,
logo: QrLogo | null = null,
): Promise<Buffer> {
try {
return await QRCode.toBuffer(content, { ...toOptions(design), type: "png" });
const png = await QRCode.toBuffer(content, {
...toOptions(design),
type: "png",
});
return logo === null ? png : await legeLogoAuf(png, design, logo);
} catch (error) {
wrap(error);
}
+10
View File
@@ -35,6 +35,16 @@ export const UPLOAD_KINDS = {
// das dann bei jedem Seitenaufruf mitgeht.
maxBytes: 2 * 1024 * 1024,
},
qrlogo: {
label: "Logo im Code",
// Ohne GIF: Ein bewegtes Bild wird beim Einsetzen ohnehin zum Standbild,
// und die Auswahl soll nicht mehr versprechen, als hinten herauskommt.
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/avif"],
accept: "image/png,image/jpeg,image/webp,image/avif",
// Das Logo landet auf wenigen hundert Bildpunkten in der Mitte des Codes.
// Mehr als zwei Megabyte bringen dort nichts.
maxBytes: 2 * 1024 * 1024,
},
favicon: {
label: "Favicon",
// Zusätzlich ICO — viele haben genau das noch herumliegen.
+30
View File
@@ -189,6 +189,36 @@ export async function attachUploads(
});
}
/**
* Hängt **eine** Datei an einen Code, ohne die übrigen anzurühren.
*
* Gebraucht für das Logo aus der Gestaltung: Ändert sich nur das Aussehen
* eines Codes, sollen die Dateien seines Inhalts unberührt bleiben.
*/
export async function attachUpload(
qrCodeId: string,
uploadId: string,
): Promise<void> {
await prisma.upload.updateMany({
// Dieselbe Bedingung wie oben: keine fremde Datei an sich ziehen.
where: { id: uploadId, OR: [{ qrCodeId: null }, { qrCodeId }] },
data: { qrCodeId },
});
}
/**
* Löst eine Datei wieder vom Code — sie wird danach als herrenlos weggeräumt.
*/
export async function detachUpload(
qrCodeId: string,
uploadId: string,
): Promise<void> {
await prisma.upload.updateMany({
where: { id: uploadId, qrCodeId },
data: { qrCodeId: null },
});
}
/** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */
const ORPHAN_HOURS = 24;