Merge branch 'main' into feat/11-typen-je-gruppe
This commit is contained in:
@@ -5,6 +5,7 @@ import { notFound } from "next/navigation";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { QrGenerator } from "@/components/qr-generator";
|
||||
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
@@ -96,5 +97,15 @@ export default async function CreatePage({ params }: Props) {
|
||||
editorsMaySetSlug,
|
||||
});
|
||||
|
||||
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
|
||||
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
|
||||
// der eigene Upload.
|
||||
const { logoUploadId } = await getBranding();
|
||||
|
||||
return (
|
||||
<QrGenerator
|
||||
typeId={state.qrType.id}
|
||||
maySetSlug={maySetSlug}
|
||||
hasBrandingLogo={logoUploadId !== null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,9 +27,9 @@ export default async function MyCodesPage() {
|
||||
Meine Codes
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { pruefeDomain } from "@/lib/domain-check";
|
||||
import { errorResponse } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Context = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* Prüft eine registrierte Domain: DNS, Zertifikat, Erreichbarkeit — und ob
|
||||
* dahinter wirklich diese Installation steht.
|
||||
*
|
||||
* `POST`, obwohl nichts gespeichert wird: Der Aufruf stößt eine Verbindung
|
||||
* nach außen an und ist nichts, was sich vorsorglich abrufen lässt.
|
||||
*
|
||||
* Geprüft wird ausschließlich der Hostname aus der Datenbank. Ein Hostname
|
||||
* aus der Anfrage würde diese Route zum Werkzeug machen, mit dem sich vom
|
||||
* Server aus beliebige Adressen abklopfen lassen.
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
context: Context,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
const { id } = await context.params;
|
||||
|
||||
const domain = await prisma.domain.findUnique({
|
||||
where: { id },
|
||||
select: { hostname: true },
|
||||
});
|
||||
if (!domain) {
|
||||
throw new QrRequestError("Diese Domain gibt es nicht.", 404);
|
||||
}
|
||||
|
||||
return Response.json({ report: await pruefeDomain(domain.hostname) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||
import { errorResponse } from "@/lib/http";
|
||||
import { designSchema } from "@/lib/qr/design";
|
||||
import { getQrType } from "@/lib/qr/registry";
|
||||
import { ladeLogo } from "@/lib/qr/logo";
|
||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
|
||||
|
||||
@@ -48,7 +49,11 @@ export async function GET(
|
||||
size: code.size,
|
||||
ecLevel: code.ecLevel,
|
||||
margin: code.margin,
|
||||
logoSource: code.logoSource,
|
||||
logoUploadId: code.logoUploadId,
|
||||
logoScale: code.logoScale,
|
||||
});
|
||||
const logo = await ladeLogo(design);
|
||||
|
||||
// Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
|
||||
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
|
||||
@@ -58,8 +63,8 @@ export async function GET(
|
||||
: shortUrl(code.slug, code.domain.hostname);
|
||||
const body =
|
||||
format === "png"
|
||||
? new Uint8Array(await renderPng(content, design))
|
||||
: await renderSvg(content, design);
|
||||
? new Uint8Array(await renderPng(content, design, logo))
|
||||
: await renderSvg(content, design, logo);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": CONTENT_TYPES[format],
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -7,6 +7,7 @@ import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
||||
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveTargetDomain } from "@/lib/domains";
|
||||
import { findStaticDuplicate } from "@/lib/duplicates";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { requireAvailableQrType } from "@/lib/qr/availability";
|
||||
import { designSchema } from "@/lib/qr/design";
|
||||
@@ -43,8 +44,32 @@ const createSchema = z.object({
|
||||
groupId: z.string().trim().min(1).optional(),
|
||||
/** Unter welcher Domain die Kurz-URL entsteht. */
|
||||
domainId: z.string().trim().min(1).optional(),
|
||||
/**
|
||||
* Eine gemeldete Dublette bewusst in Kauf nehmen. Ohne dieses Häkchen
|
||||
* antwortet das Anlegen mit 409, sobald derselbe Inhalt schon als
|
||||
* statischer Code in der Gruppe liegt.
|
||||
*/
|
||||
allowDuplicate: z.boolean().default(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* Alle Dateien, die zu einem Code gehören.
|
||||
*
|
||||
* Das sind die des Typ-Moduls — ein PDF, ein Bild — **und** ein eigenes Logo
|
||||
* aus der Gestaltung. Wird das vergessen, löst `attachUploads` das Logo beim
|
||||
* nächsten Speichern wieder vom Code und räumt es später als herrenlos weg.
|
||||
*/
|
||||
function dateienDesCodes(
|
||||
qrType: { uploadIds: (data: never) => string[] },
|
||||
data: unknown,
|
||||
design: { logoSource: string | null; logoUploadId: string | null },
|
||||
): string[] {
|
||||
const ids = qrType.uploadIds(data as never);
|
||||
return design.logoSource === "upload" && design.logoUploadId
|
||||
? [...ids, design.logoUploadId]
|
||||
: ids;
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
@@ -141,6 +166,24 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
await assertSlugAvailable(domainId, gewuenscht);
|
||||
}
|
||||
|
||||
// Zweimal derselbe statische Inhalt ergibt zweimal dasselbe Bild. Das ist
|
||||
// erlaubt, aber selten gewollt — deshalb ein Halt mit Rückfrage statt
|
||||
// eines stillen zweiten Eintrags.
|
||||
if (istStatisch && !parsed.data.allowDuplicate) {
|
||||
const dublette = await findStaticDuplicate(groupId, qrType.id, validated.data);
|
||||
if (dublette) {
|
||||
return Response.json(
|
||||
{
|
||||
error: dublette.label
|
||||
? `Diesen Inhalt hast du schon abgelegt — als „${dublette.label}“.`
|
||||
: "Diesen Inhalt hast du schon abgelegt.",
|
||||
duplicate: dublette,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (istStatisch) {
|
||||
const created = await prisma.qrCode.create({
|
||||
data: {
|
||||
@@ -161,7 +204,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
||||
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
|
||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -188,7 +231,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
||||
// vorher lagen sie ohne Bezug herum.
|
||||
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
||||
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
|
||||
|
||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { bildeBeweis, getCheckSecret } from "@/lib/domain-check";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Beweis-Route für den Domain-Check.
|
||||
*
|
||||
* Bewusst **ohne Anmeldung**: Der Aufruf kommt von außen über die zu prüfende
|
||||
* Domain und trägt keine Sitzung mit sich. Preisgegeben wird dabei nichts —
|
||||
* die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Wer das
|
||||
* Geheimnis nicht kennt, kann ihn nicht bilden; wer ihn sieht, lernt daraus
|
||||
* nichts über das Geheimnis.
|
||||
*
|
||||
* Dass es diese Anwendung gibt, verrät ohnehin schon jede Landingpage.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
const nonce = request.nextUrl.searchParams.get("nonce") ?? "";
|
||||
|
||||
// Eine Grenze nach oben, damit die Route nicht als Rechenknecht für fremde
|
||||
// Daten taugt, und eine nach unten, damit sie nicht auf Zuruf antwortet.
|
||||
if (!/^[a-f0-9]{16,64}$/.test(nonce)) {
|
||||
return Response.json(
|
||||
{ error: "Es fehlt eine gültige Zufallszahl." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const proof = bildeBeweis(await getCheckSecret(), nonce);
|
||||
return Response.json(
|
||||
{ app: "qrcode-generator", proof },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Domain-Check konnte keinen Beweis bilden:", error);
|
||||
return Response.json({ error: "Nicht möglich." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
|
||||
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { ladeLogo } from "@/lib/qr/logo";
|
||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||
import {
|
||||
downloadFilename,
|
||||
@@ -38,10 +39,11 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
|
||||
const { qrType, data, content } = await resolveQrRequest(parsed.data);
|
||||
|
||||
const logo = await ladeLogo(design);
|
||||
const body =
|
||||
format === "png"
|
||||
? new Uint8Array(await renderPng(content, design))
|
||||
: await renderSvg(content, design);
|
||||
? new Uint8Array(await renderPng(content, design, logo))
|
||||
: await renderSvg(content, design, logo);
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
|
||||
|
||||
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { ladeLogo } from "@/lib/qr/logo";
|
||||
import { renderSvg } from "@/lib/qr/render";
|
||||
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
||||
|
||||
@@ -21,7 +22,8 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
|
||||
const { content } = await resolveQrRequest(parsed.data);
|
||||
const svg = await renderSvg(content, parsed.data.design);
|
||||
const logo = await ladeLogo(parsed.data.design);
|
||||
const svg = await renderSvg(content, parsed.data.design, logo);
|
||||
|
||||
return Response.json({
|
||||
content,
|
||||
|
||||
@@ -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<SortKey, string> = {
|
||||
neueste: "Neueste zuerst",
|
||||
@@ -51,6 +53,12 @@ const SORT_LABELS: Record<SortKey, string> = {
|
||||
scans: "Meiste Scans",
|
||||
};
|
||||
|
||||
const MODE_LABELS: Record<ModeFilter, string> = {
|
||||
alle: "Alle Arten",
|
||||
dynamic: "Nur dynamische",
|
||||
static: "Nur statische",
|
||||
};
|
||||
|
||||
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
|
||||
const VIEW_STORAGE_KEY = "qrgen.codes.view";
|
||||
|
||||
@@ -94,6 +102,7 @@ export function CodesList({
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<SortKey>("neueste");
|
||||
const [groupFilter, setGroupFilter] = useState("alle");
|
||||
const [modeFilter, setModeFilter] = useState<ModeFilter>("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 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1 basis-56">
|
||||
@@ -320,17 +355,36 @@ export function CodesList({
|
||||
</Select>
|
||||
) : null}
|
||||
|
||||
{kinds.beide ? (
|
||||
<Select
|
||||
aria-label="Nach Art filtern"
|
||||
className="!w-auto"
|
||||
value={modeFilter}
|
||||
onChange={(event) =>
|
||||
changeModeFilter(event.target.value as ModeFilter)
|
||||
}
|
||||
>
|
||||
{(Object.keys(MODE_LABELS) as ModeFilter[]).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{MODE_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : null}
|
||||
|
||||
<Select
|
||||
aria-label="Sortierung"
|
||||
className="!w-auto"
|
||||
value={sort}
|
||||
onChange={(event) => setSort(event.target.value as SortKey)}
|
||||
>
|
||||
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{SORT_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
{(Object.keys(SORT_LABELS) as SortKey[])
|
||||
.filter((key) => key !== "scans" || !nurStatische)
|
||||
.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{SORT_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<div
|
||||
@@ -406,12 +460,13 @@ export function CodesList({
|
||||
{codes.length > 0 && visible.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<p className="text-sm text-slate-600">
|
||||
Kein Code passt zur Suche.{" "}
|
||||
Kein Code passt zu Suche und Filtern.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setGroupFilter("alle");
|
||||
setModeFilter("alle");
|
||||
}}
|
||||
className="font-medium text-accent-text underline"
|
||||
>
|
||||
@@ -446,9 +501,11 @@ export function CodesList({
|
||||
Gruppe
|
||||
</th>
|
||||
) : null}
|
||||
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
||||
Scans
|
||||
</th>
|
||||
{nurStatische ? null : (
|
||||
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
||||
Scans
|
||||
</th>
|
||||
)}
|
||||
<th
|
||||
scope="col"
|
||||
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
|
||||
@@ -571,15 +628,20 @@ export function CodesList({
|
||||
</td>
|
||||
) : null}
|
||||
|
||||
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
||||
{code.mode === "static" ? (
|
||||
<span className="font-normal text-slate-400" title="Statische Codes werden nicht gezählt">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
code.scans
|
||||
)}
|
||||
</td>
|
||||
{nurStatische ? null : (
|
||||
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
||||
{code.mode === "static" ? (
|
||||
<span
|
||||
className="font-normal text-slate-400"
|
||||
title="Statische Codes werden nicht gezählt"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
code.scans
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td
|
||||
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { FileField } from "@/components/qr-forms/file-field";
|
||||
import { Field, Notice, TextInput, cx } from "@/components/ui";
|
||||
import { evaluateContrast } from "@/lib/qr/contrast";
|
||||
import {
|
||||
COLOR_PRESETS,
|
||||
EC_LEVELS,
|
||||
EC_LEVEL_LABELS,
|
||||
LOGO_SCALE_WARN,
|
||||
MAX_LOGO_SCALE,
|
||||
MAX_SIZE,
|
||||
MIN_LOGO_SCALE,
|
||||
MIN_SIZE,
|
||||
type EcLevel,
|
||||
type LogoSource,
|
||||
type QrDesign,
|
||||
} from "@/lib/qr/design";
|
||||
|
||||
@@ -62,9 +67,12 @@ function ColorField({
|
||||
export function DesignPanel({
|
||||
design,
|
||||
onChange,
|
||||
/** Ist unter „Erscheinungsbild“ ein Logo hinterlegt? Sonst entfällt die Wahl. */
|
||||
hasBrandingLogo = false,
|
||||
}: {
|
||||
design: QrDesign;
|
||||
onChange: (design: QrDesign) => void;
|
||||
hasBrandingLogo?: boolean;
|
||||
}) {
|
||||
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
|
||||
const contrast = evaluateContrast(design.fgColor, design.bgColor);
|
||||
@@ -165,7 +173,11 @@ export function DesignPanel({
|
||||
|
||||
<Field
|
||||
label="Fehlerkorrektur"
|
||||
hint={EC_LEVEL_LABELS[design.ecLevel]}
|
||||
hint={
|
||||
design.logoSource === null
|
||||
? EC_LEVEL_LABELS[design.ecLevel]
|
||||
: "Mit Logo gilt H — was das Bild verdeckt, muss die Fehlerkorrektur ausgleichen."
|
||||
}
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
@@ -177,11 +189,14 @@ export function DesignPanel({
|
||||
key={level}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={design.ecLevel === level}
|
||||
aria-checked={
|
||||
design.logoSource === null ? design.ecLevel === level : level === "H"
|
||||
}
|
||||
disabled={design.logoSource !== null}
|
||||
onClick={() => patch({ ecLevel: level })}
|
||||
className={cx(
|
||||
"rounded-lg border py-2 text-sm font-medium transition-colors",
|
||||
design.ecLevel === level
|
||||
"rounded-lg border py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
(design.logoSource === null ? design.ecLevel : "H") === level
|
||||
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
|
||||
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
|
||||
)}
|
||||
@@ -208,6 +223,147 @@ export function DesignPanel({
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<LogoAuswahl
|
||||
design={design}
|
||||
patch={patch}
|
||||
hasBrandingLogo={hasBrandingLogo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- Logo */
|
||||
|
||||
/**
|
||||
* Das Bild in der Mitte des Codes.
|
||||
*
|
||||
* Bewusst ein eigener Abschnitt und nicht bloß ein Feld: Ein Logo zieht drei
|
||||
* Entscheidungen nach sich — woher es kommt, wie groß es wird, und dass die
|
||||
* Fehlerkorrektur dafür hochgesetzt wird. Das gehört sichtbar beieinander.
|
||||
*/
|
||||
function LogoAuswahl({
|
||||
design,
|
||||
patch,
|
||||
hasBrandingLogo,
|
||||
}: {
|
||||
design: QrDesign;
|
||||
patch: (changes: Partial<QrDesign>) => void;
|
||||
hasBrandingLogo: boolean;
|
||||
}) {
|
||||
const an = design.logoSource !== null;
|
||||
|
||||
const waehle = (quelle: LogoSource | null) => {
|
||||
if (quelle === null) {
|
||||
patch({ logoSource: null, logoUploadId: null });
|
||||
} else if (quelle === "branding") {
|
||||
patch({ logoSource: "branding", logoUploadId: null });
|
||||
} else {
|
||||
patch({ logoSource: "upload" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className="border-t border-slate-200 pt-4">
|
||||
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Logo in der Mitte
|
||||
</legend>
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={an}
|
||||
onChange={(event) =>
|
||||
waehle(
|
||||
event.target.checked ? (hasBrandingLogo ? "branding" : "upload") : null,
|
||||
)
|
||||
}
|
||||
className="mt-0.5 size-4 shrink-0 rounded border-slate-300 accent-indigo-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
Ein Logo in die Mitte des Codes setzen
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{an ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
{hasBrandingLogo ? (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Herkunft des Logos"
|
||||
className="space-y-2"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["branding", "Logo dieser Installation"],
|
||||
["upload", "Eigenes Logo hochladen"],
|
||||
] as [LogoSource, string][]
|
||||
).map(([quelle, text]) => (
|
||||
<label
|
||||
key={quelle}
|
||||
className="flex cursor-pointer items-center gap-2.5"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="logo-quelle"
|
||||
checked={design.logoSource === quelle}
|
||||
onChange={() => waehle(quelle)}
|
||||
className="size-4 shrink-0 accent-indigo-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">{text}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{design.logoSource === "upload" ? (
|
||||
<div className="space-y-2">
|
||||
<FileField
|
||||
kind="qrlogo"
|
||||
label={design.logoUploadId ? "Anderes Logo wählen" : "Logo wählen"}
|
||||
onUploaded={(datei) => patch({ logoUploadId: datei.fileId })}
|
||||
/>
|
||||
{design.logoUploadId ? null : (
|
||||
<p className="text-sm text-amber-700">
|
||||
Noch keine Datei gewählt — bis dahin bleibt die Mitte frei.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label={`Größe: ${design.logoScale} % der Kantenlänge`}
|
||||
htmlFor="qr-logo-scale"
|
||||
hint="Bezogen auf die Breite des Codes. Bei 20 % sind rund 4 % der Fläche verdeckt."
|
||||
>
|
||||
<input
|
||||
id="qr-logo-scale"
|
||||
type="range"
|
||||
min={MIN_LOGO_SCALE}
|
||||
max={MAX_LOGO_SCALE}
|
||||
step={1}
|
||||
value={design.logoScale}
|
||||
onChange={(event) => patch({ logoScale: Number(event.target.value) })}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{design.logoScale > LOGO_SCALE_WARN ? (
|
||||
<Notice tone="warning">
|
||||
Ab {LOGO_SCALE_WARN} % wird es knapp. Die Fehlerkorrektur trägt das
|
||||
zwar, aber manche Scanner-Apps suchen in der Mitte nach
|
||||
Ausrichtungsmustern. Prüfe den fertigen Code mit mehr als einer App,
|
||||
bevor er in den Druck geht.
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<p className="text-sm text-slate-500">
|
||||
Hinter dem Logo liegt eine Fläche in der Hintergrundfarbe, damit es
|
||||
sich absetzt. Ein Code mit Logo gehört vor dem Druck einmal
|
||||
eingescannt — das ist die einzige Prüfung, die wirklich zählt.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { DomainBericht } from "@/components/domain-bericht";
|
||||
import { DomainHilfe } from "@/components/domain-hilfe";
|
||||
import { HelpButton } from "@/components/help-button";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Modal } from "@/components/modal";
|
||||
import {
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
TextInput,
|
||||
} from "@/components/ui";
|
||||
import { ApiError, apiJson } from "@/lib/api-client";
|
||||
import type { Pruefbericht } from "@/lib/domain-check";
|
||||
|
||||
type Grant = { groupId: string; groupName: string; isDefault: boolean };
|
||||
|
||||
@@ -44,6 +48,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
|
||||
const [deleting, setDeleting] = useState<AdminDomain | null>(null);
|
||||
|
||||
/** Prüfberichte je Domain — bleiben stehen, bis erneut geprüft wird. */
|
||||
const [reports, setReports] = useState<Record<string, Pruefbericht>>({});
|
||||
const [checking, setChecking] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await apiJson<{
|
||||
@@ -122,6 +130,26 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const check = async (domain: AdminDomain) => {
|
||||
setChecking(domain.id);
|
||||
setError(null);
|
||||
try {
|
||||
const { report } = await apiJson<{ report: Pruefbericht }>(
|
||||
`/api/admin/domains/${domain.id}/check`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
setReports((bisher) => ({ ...bisher, [domain.id]: report }));
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof ApiError
|
||||
? caught.message
|
||||
: "Die Prüfung ließ sich nicht ausführen.",
|
||||
);
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (domains === null) {
|
||||
return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
|
||||
}
|
||||
@@ -134,10 +162,14 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
|
||||
weiterleitet. Die Weiterleitung erkennt sie am{" "}
|
||||
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
|
||||
gegen diese Liste — ein unbekannter Hostname endet mit 404.
|
||||
gegen diese Liste — ein unbekannter Hostname endet mit 404. Was dafür
|
||||
einzurichten ist, steht in der Kurzhilfe.
|
||||
</Notice>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<HelpButton title="Eine Domain hinzufügen" label="Kurzhilfe">
|
||||
<DomainHilfe baseUrlHostname={baseUrlHostname} />
|
||||
</HelpButton>
|
||||
<Button type="button" onClick={() => setCreating(true)}>
|
||||
Domain registrieren
|
||||
</Button>
|
||||
@@ -192,6 +224,16 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
<Icon name="pencil" className="size-3.5" />
|
||||
Gruppen zuteilen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={checking !== null}
|
||||
onClick={() => void check(domain)}
|
||||
>
|
||||
<Icon name="scan" className="size-3.5" />
|
||||
{checking === domain.id ? "Prüft …" : "Prüfen"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@@ -213,6 +255,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{reports[domain.id] ? (
|
||||
<DomainBericht bericht={reports[domain.id]} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { cx } from "@/components/ui";
|
||||
import type { Pruefbericht, SchrittStatus } from "@/lib/domain-check";
|
||||
|
||||
/**
|
||||
* Das Ergebnis eines Domain-Checks.
|
||||
*
|
||||
* Vier Zeilen, eine je Schritt, mit dem Grund im Klartext daneben. Bewusst
|
||||
* ohne Ampelfarbe als einziges Merkmal: Jede Zeile trägt ein eigenes Symbol
|
||||
* und einen eigenen Text, damit sie auch ohne Farbe lesbar bleibt.
|
||||
*/
|
||||
|
||||
const SYMBOLE: Record<SchrittStatus, { icon: "check" | "warning" | "eyeOff"; farbe: string; wort: string }> = {
|
||||
ok: { icon: "check", farbe: "text-emerald-600", wort: "in Ordnung" },
|
||||
warnung: { icon: "warning", farbe: "text-amber-600", wort: "Achtung" },
|
||||
fehler: { icon: "warning", farbe: "text-red-600", wort: "Fehler" },
|
||||
uebersprungen: { icon: "eyeOff", farbe: "text-slate-400", wort: "übersprungen" },
|
||||
};
|
||||
|
||||
const RAHMEN: Record<Pruefbericht["gesamt"], string> = {
|
||||
ok: "border-emerald-300 bg-emerald-50/60",
|
||||
warnung: "border-amber-300 bg-amber-50/60",
|
||||
fehler: "border-red-300 bg-red-50/60",
|
||||
};
|
||||
|
||||
const FAZIT: Record<Pruefbericht["gesamt"], string> = {
|
||||
ok: "Die Domain führt hierher.",
|
||||
warnung: "Es läuft, aber etwas will beachtet werden.",
|
||||
fehler: "So kommt niemand hier an.",
|
||||
};
|
||||
|
||||
export function DomainBericht({ bericht }: { bericht: Pruefbericht }) {
|
||||
return (
|
||||
<div className={cx("mt-4 rounded-xl border p-4", RAHMEN[bericht.gesamt])}>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{FAZIT[bericht.gesamt]}
|
||||
</p>
|
||||
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{bericht.schritte.map((schritt) => {
|
||||
const zeichen = SYMBOLE[schritt.status];
|
||||
return (
|
||||
<li key={schritt.name} className="flex items-start gap-2 text-sm">
|
||||
<Icon
|
||||
name={zeichen.icon}
|
||||
className={cx("mt-0.5 size-4 shrink-0", zeichen.farbe)}
|
||||
/>
|
||||
<span className="sr-only">{zeichen.wort}: </span>
|
||||
<span className="w-24 shrink-0 font-medium text-slate-700">
|
||||
{schritt.titel}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-slate-600">
|
||||
{schritt.detail}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
Aufgerufen wurde{" "}
|
||||
<code className="font-mono">{bericht.geprueft}</code> — von dort, wo
|
||||
dieser Container läuft. Bei internem DNS oder Hairpin-NAT kann die
|
||||
Prüfung scheitern, obwohl von außen alles stimmt.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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 (
|
||||
<div className="space-y-5">
|
||||
<p>
|
||||
Eine Domain hier zu registrieren genügt nicht — sie muss den Browser
|
||||
auch hierher führen. Dazu gehören drei Dinge außerhalb dieser
|
||||
Anwendung: ein DNS-Eintrag, ein Eintrag im Reverse Proxy und ein
|
||||
Zertifikat.
|
||||
</p>
|
||||
|
||||
<ol className="space-y-4">
|
||||
<HelpStep n={1} title="DNS-Eintrag anlegen">
|
||||
<p>
|
||||
Beim Anbieter der Domain einen <strong>A-Record</strong> (bei IPv6
|
||||
zusätzlich <strong>AAAA</strong>) auf die öffentliche Adresse des
|
||||
Reverse Proxy setzen. Ein CNAME auf einen bestehenden Namen tut es
|
||||
auch.
|
||||
</p>
|
||||
<p>
|
||||
Steht der Name nur im internen DNS oder ist er nur über VPN
|
||||
erreichbar, funktionieren gedruckte Codes{" "}
|
||||
<strong>auch nur dort</strong>. Für Aushänge, Flyer und
|
||||
Verpackungen muss er öffentlich auflösbar sein.
|
||||
</p>
|
||||
</HelpStep>
|
||||
|
||||
<HelpStep n={2} title="Im Reverse Proxy weiterleiten">
|
||||
<p>
|
||||
Für die Domain einen eigenen Eintrag anlegen, der auf diese
|
||||
Anwendung zeigt — Ziel ist der Docker-Host auf Port{" "}
|
||||
<code className="font-mono text-xs">3000</code>, oder im selben
|
||||
Docker-Netz der Servicename{" "}
|
||||
<code className="font-mono text-xs">app</code>.
|
||||
</p>
|
||||
<p>
|
||||
Diese drei Kopfzeilen muss der Proxy mitschicken. Nginx Proxy
|
||||
Manager, Traefik und Caddy tun das von sich aus; ein blankes nginx
|
||||
braucht sie im Serverblock:
|
||||
</p>
|
||||
<HelpCode>{`proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`}</HelpCode>
|
||||
<p>
|
||||
Ohne <code className="font-mono text-xs">X-Forwarded-Host</code>{" "}
|
||||
landet jeder Aufruf unter der Standard-Domain, und Kurz-URLs der
|
||||
Zweitdomain werden nicht gefunden.
|
||||
</p>
|
||||
</HelpStep>
|
||||
|
||||
<HelpStep n={3} title="Zertifikat anfordern">
|
||||
<p>
|
||||
HTTPS erledigt der Proxy. Im Nginx Proxy Manager ist das der Reiter{" "}
|
||||
<em>SSL</em> mit <em>Force SSL</em>; Traefik und Caddy holen das
|
||||
Zertifikat selbst, sobald der DNS-Eintrag steht.
|
||||
</p>
|
||||
<p>
|
||||
Ohne gültiges Zertifikat brechen viele Scanner-Apps den Aufruf ohne
|
||||
Erklärung ab — der Code sieht dann für die Nutzer schlicht kaputt
|
||||
aus.
|
||||
</p>
|
||||
</HelpStep>
|
||||
|
||||
<HelpStep n={4} title="Domain hier registrieren">
|
||||
<p>
|
||||
Über <strong>Domain registrieren</strong> den Hostnamen eintragen —
|
||||
genau so, wie er im Browser steht, ohne{" "}
|
||||
<code className="font-mono text-xs">https://</code> und ohne Pfad:
|
||||
</p>
|
||||
<HelpCode>{beispiel}</HelpCode>
|
||||
<p>
|
||||
Die Weiterleitung prüft den Hostnamen gegen diese Liste. Ein
|
||||
unbekannter Name endet mit 404 — über eine gefälschte Kopfzeile
|
||||
lässt sich also keine fremde Domain ansprechen.
|
||||
</p>
|
||||
</HelpStep>
|
||||
|
||||
<HelpStep n={5} title="Einer Gruppe zuteilen">
|
||||
<p>
|
||||
Eine frisch registrierte Domain gehört noch niemandem. Erst über{" "}
|
||||
<strong>Gruppen zuteilen</strong> können Mitglieder dieser Gruppe
|
||||
dort Codes anlegen. Die als <em>Voreinstellung</em> markierte
|
||||
Domain ist die, die beim Anlegen vorausgewählt ist.
|
||||
</p>
|
||||
</HelpStep>
|
||||
|
||||
<HelpStep n={6} title="Nachprüfen">
|
||||
<p>Von außerhalb des eigenen Netzes:</p>
|
||||
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</HelpStep>
|
||||
</ol>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<h3 className="font-semibold text-slate-900">
|
||||
Was <code className="font-mono text-xs">BASE_URL</code> damit zu tun hat
|
||||
</h3>
|
||||
<p className="mt-1 text-slate-600">
|
||||
Die Umgebungsvariable{" "}
|
||||
<code className="font-mono text-xs">BASE_URL</code> ist die
|
||||
Standard-Adresse: Sie greift bei Aufrufen über eine nicht
|
||||
registrierte Adresse und bestimmt, ob Kurz-URLs mit{" "}
|
||||
<code className="font-mono text-xs">http</code> oder{" "}
|
||||
<code className="font-mono text-xs">https</code> gebildet werden.
|
||||
Weitere Domains werden hier registriert, nicht dort eingetragen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Kurzhilfe: ein Fragezeichen neben einer Überschrift, das eine Erklärung
|
||||
* aufklappt.
|
||||
*
|
||||
* Bewusst ein Dialog und kein Dauertext auf der Seite — die Erklärung braucht
|
||||
* man einmal beim Einrichten und danach nie wieder. Sie ständig zu zeigen
|
||||
* würde die Seite für alle anderen Aufrufe zumüllen.
|
||||
*/
|
||||
export function HelpButton({
|
||||
title,
|
||||
label = "Hilfe",
|
||||
children,
|
||||
}: {
|
||||
/** Überschrift des Dialogs. */
|
||||
title: string;
|
||||
/** Beschriftung des Knopfes. */
|
||||
label?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Icon name="help" className="size-4" />
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
size="lg"
|
||||
title={title}
|
||||
onClose={() => setOpen(false)}
|
||||
footer={
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
Verstanden
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="max-h-[65vh] overflow-y-auto pr-1 text-sm text-slate-700">
|
||||
{children}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Bausteine für den Inhalt */
|
||||
|
||||
/** Ein nummerierter Schritt mit Überschrift. */
|
||||
export function HelpStep({
|
||||
n,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
n: number;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex gap-3">
|
||||
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-fg">
|
||||
{n}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-slate-900">{title}</h3>
|
||||
<div className="mt-1 space-y-2 text-slate-600">{children}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ein Befehl oder Wert zum Abtippen — auf schmalen Schirmen scrollbar. */
|
||||
export function HelpCode({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-lg bg-slate-100 px-3 py-2 font-mono text-xs text-slate-800">
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -238,6 +238,13 @@ const PATHS = {
|
||||
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
|
||||
</>
|
||||
),
|
||||
help: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.2 2.4c-.6.2-.95.75-.95 1.4v.45" />
|
||||
<path d="M11.75 16.75h.01" />
|
||||
</>
|
||||
),
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
@@ -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<HTMLDialogElement>(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`}
|
||||
>
|
||||
<div className="p-5 sm:p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||
|
||||
@@ -43,13 +43,31 @@ function sameData(a: unknown, b: unknown): boolean {
|
||||
return stableStringify(a) === stableStringify(b);
|
||||
}
|
||||
|
||||
/** Der bereits abgelegte Code, den die Dublettenprüfung gemeldet hat. */
|
||||
type Dublette = { id: string; label: string | null; createdAt: string };
|
||||
|
||||
/** Liest die Meldung aus einer 409-Antwort — oder gibt auf. */
|
||||
function leseDublette(details: unknown): Dublette | null {
|
||||
if (details === null || typeof details !== "object") return null;
|
||||
const feld = (details as { duplicate?: unknown }).duplicate;
|
||||
if (feld === null || typeof feld !== "object") return null;
|
||||
const { id, label, createdAt } = feld as Record<string, unknown>;
|
||||
if (typeof id !== "string" || typeof createdAt !== "string") return null;
|
||||
return { id, label: typeof label === "string" ? label : null, createdAt };
|
||||
}
|
||||
|
||||
const dublettenDatum = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
|
||||
|
||||
export function QrGenerator({
|
||||
typeId,
|
||||
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
|
||||
maySetSlug = true,
|
||||
/** Ist ein Logo der Installation hinterlegt? Sonst nur eigener Upload. */
|
||||
hasBrandingLogo = false,
|
||||
}: {
|
||||
typeId: QrTypeId;
|
||||
maySetSlug?: boolean;
|
||||
hasBrandingLogo?: boolean;
|
||||
}) {
|
||||
const qrType = qrTypes[typeId];
|
||||
const Form = qrForms[typeId];
|
||||
@@ -65,6 +83,14 @@ export function QrGenerator({
|
||||
const [saved, setSaved] = useState<QrCodeDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
/**
|
||||
* Gemeldete Dublette samt dem Inhalt, für den sie gilt. Ändert sich der
|
||||
* Inhalt, ist die Warnung überholt und verschwindet von selbst — ohne
|
||||
* einen Effekt, der sie hinterherräumt.
|
||||
*/
|
||||
const [duplicate, setDuplicate] = useState<
|
||||
{ treffer: Dublette; inhalt: string } | null
|
||||
>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const [preview, setPreview] = useState<PreviewState | null>(null);
|
||||
@@ -187,10 +213,18 @@ export function QrGenerator({
|
||||
!sameData(saved.design, design) ||
|
||||
(saved.label ?? "") !== label.trim());
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Nur zeigen, solange der Inhalt derselbe ist, für den die Meldung kam.
|
||||
const dublette =
|
||||
duplicate !== null &&
|
||||
duplicate.inhalt === stableStringify(validation.ok ? validation.data : data)
|
||||
? duplicate.treffer
|
||||
: null;
|
||||
|
||||
const handleSave = useCallback(async (trotzDublette = false) => {
|
||||
if (!validation.ok || !designValid || slugProblem) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setDuplicate(null);
|
||||
try {
|
||||
const body = {
|
||||
type: typeId,
|
||||
@@ -200,6 +234,8 @@ export function QrGenerator({
|
||||
label: label.trim() || undefined,
|
||||
// Ein statischer Code hat keinen Kurz-Link.
|
||||
slug: isDynamic ? wunschSlug.trim() || undefined : undefined,
|
||||
// Nur beim zweiten Anlauf, nachdem die Warnung dastand.
|
||||
allowDuplicate: trotzDublette,
|
||||
};
|
||||
const result = saved
|
||||
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
|
||||
@@ -212,11 +248,20 @@ export function QrGenerator({
|
||||
});
|
||||
setSaved(result.code);
|
||||
} catch (error) {
|
||||
setSaveError(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Der Code konnte nicht gespeichert werden.",
|
||||
);
|
||||
// Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage.
|
||||
const gemeldet =
|
||||
error instanceof ApiError && error.status === 409
|
||||
? leseDublette(error.details)
|
||||
: null;
|
||||
if (gemeldet) {
|
||||
setDuplicate({ treffer: gemeldet, inhalt: stableStringify(validation.data) });
|
||||
} else {
|
||||
setSaveError(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Der Code konnte nicht gespeichert werden.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -232,6 +277,7 @@ export function QrGenerator({
|
||||
setIsDynamic(dynamisch);
|
||||
setSaved(null);
|
||||
setSaveError(null);
|
||||
setDuplicate(null);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
@@ -423,14 +469,43 @@ export function QrGenerator({
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving ? "Legt ab …" : "In „Meine Codes“ ablegen"}
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
{dublette ? (
|
||||
<Notice tone="warning">
|
||||
<span className="font-medium">
|
||||
Diesen Inhalt hast du schon abgelegt
|
||||
</span>{" "}
|
||||
— {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "}
|
||||
vom {dublettenDatum.format(new Date(dublette.createdAt))}.
|
||||
Zweimal derselbe Inhalt ergibt zweimal denselben Code.
|
||||
<span className="mt-1 block text-xs">
|
||||
Verglichen wird nur der Inhalt, nicht die Gestaltung —
|
||||
derselbe Code in anderen Farben gilt hier als Dublette.
|
||||
</span>
|
||||
<span className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href="/meine-codes"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium underline"
|
||||
>
|
||||
Den vorhandenen ansehen
|
||||
<Icon name="external" className="size-3.5" />
|
||||
</Link>
|
||||
</span>
|
||||
</Notice>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => void handleSave(dublette !== null)}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving
|
||||
? "Legt ab …"
|
||||
: dublette
|
||||
? "Trotzdem ablegen"
|
||||
: "In „Meine Codes“ ablegen"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : saved ? (
|
||||
<div className="space-y-3">
|
||||
@@ -460,7 +535,7 @@ export function QrGenerator({
|
||||
{hasUnsavedChanges ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving ? "Speichert …" : "Änderungen speichern"}
|
||||
@@ -492,7 +567,7 @@ export function QrGenerator({
|
||||
</Notice>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !ready || Boolean(slugProblem)}
|
||||
>
|
||||
{saving ? "Speichert …" : "Dynamischen Code speichern"}
|
||||
@@ -510,7 +585,11 @@ export function QrGenerator({
|
||||
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
|
||||
Gestaltung
|
||||
</CardTitle>
|
||||
<DesignPanel design={design} onChange={setDesign} />
|
||||
<DesignPanel
|
||||
design={design}
|
||||
onChange={setDesign}
|
||||
hasBrandingLogo={hasBrandingLogo}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+33
-14
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user