Merge branch 'main' into feat/8-logo-im-code
# Conflicts: # docs/funktionen.md
This commit is contained in:
+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") ?? "";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user