Das Feld „Name (optional)" stand nur im Dynamisch-Block. Bei einem statischen Code gab es damit keine Moeglichkeit, ihm einen Namen zum Wiederfinden zu geben. Jetzt gilt es in beiden Betriebsarten und tut zweierlei: Es bestimmt den Dateinamen beim Herunterladen — aus „Plakat Eingang" wird qr-plakat-eingang.png statt qr-example-com-plakat.png — und es laesst sich damit ein statischer Code in „Meine Codes" ablegen. Ein abgelegter statischer Code ist ein Schnappschuss, kein steuerbares Ziel. Aenderbar ist nur der Name; Inhalt und Gestaltung bleiben, wie sie beim Ablegen waren, denn genau das steckt im gedruckten Bild. Er bekommt keinen Kurz-Link, ist unter /r nicht erreichbar und zaehlt keine Scans — in der Liste steht dort „statisch" und ein Strich. Wer nach dem Ablegen den Inhalt aendert, bekommt das Ablegen erneut angeboten; der vorherige Eintrag bleibt als eigener Schnappschuss. Dafuer traegt QrCode jetzt `mode`, und `slug` darf leer sein. In PostgreSQL schliessen sich mehrere NULL in einem eindeutigen Index nicht aus, die Bedingung „je Domain eindeutig" gilt fuer dynamische Codes also unveraendert. Refs #2
214 lines
7.8 KiB
TypeScript
214 lines
7.8 KiB
TypeScript
import type { NextRequest } from "next/server";
|
|
import { z } from "zod";
|
|
|
|
import { getActor } from "@/lib/auth/actor";
|
|
import { resolveTargetGroup } from "@/lib/auth/groups";
|
|
import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
|
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
|
import { prisma } from "@/lib/db";
|
|
import { resolveTargetDomain } from "@/lib/domains";
|
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
|
import { requireAvailableQrType } from "@/lib/qr/availability";
|
|
import { designSchema } from "@/lib/qr/design";
|
|
import { assertDynamicPossible } from "@/lib/qr/request";
|
|
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
|
|
import { describeSlugProblem, generateSlug, normalizeSlug } from "@/lib/slug";
|
|
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
|
import { getSettings } from "@/lib/settings";
|
|
import { attachUploads } from "@/lib/uploads";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const createSchema = z.object({
|
|
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
|
|
data: z.record(z.string(), z.unknown()),
|
|
design: designSchema,
|
|
label: z
|
|
.string()
|
|
.trim()
|
|
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
|
.optional(),
|
|
/**
|
|
* Statisch oder dynamisch. Ein statischer Code wird nur abgelegt, damit man
|
|
* ihn wiederfindet — er bekommt keinen Kurz-Link und zählt keine Scans.
|
|
*/
|
|
mode: z.enum(["static", "dynamic"], { error: "Ungültiger Modus." })
|
|
.default("dynamic"),
|
|
/**
|
|
* Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt.
|
|
* Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge.
|
|
*/
|
|
slug: z.string().trim().max(120).optional(),
|
|
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
|
|
groupId: z.string().trim().min(1).optional(),
|
|
/** Unter welcher Domain die Kurz-URL entsteht. */
|
|
domainId: z.string().trim().min(1).optional(),
|
|
});
|
|
|
|
function isUniqueViolation(error: unknown): boolean {
|
|
return (
|
|
typeof error === "object" &&
|
|
error !== null &&
|
|
"code" in error &&
|
|
(error as { code?: unknown }).code === "P2002"
|
|
);
|
|
}
|
|
|
|
/** Die dynamischen Codes, die der Anfragende sehen darf — neueste zuerst. */
|
|
export async function GET(): Promise<Response> {
|
|
try {
|
|
const actor = await getActor();
|
|
if (actor.kind === "anonymous") {
|
|
throw new QrRequestError("Bitte melde dich an.", 401);
|
|
}
|
|
|
|
const visible = visibleGroupIds(actor);
|
|
const codes = await prisma.qrCode.findMany({
|
|
where: visible === null ? undefined : { groupId: { in: visible } },
|
|
orderBy: { createdAt: "desc" },
|
|
include: {
|
|
group: { select: { name: true } },
|
|
domain: { select: { hostname: true } },
|
|
createdBy: { select: { name: true, email: true } },
|
|
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
|
},
|
|
});
|
|
|
|
return Response.json({ codes: codes.map(toQrCodeDto) });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|
|
|
|
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
|
|
export async function POST(request: NextRequest): Promise<Response> {
|
|
try {
|
|
const actor = await getActor();
|
|
|
|
const parsed = createSchema.safeParse(await request.json());
|
|
if (!parsed.success) {
|
|
return jsonError(firstZodMessage(parsed.error));
|
|
}
|
|
|
|
const { type, design, label } = parsed.data;
|
|
|
|
// Zielgruppe zuerst: davon hängt ab, ob überhaupt angelegt werden darf.
|
|
const groupId = await resolveTargetGroup(actor, parsed.data.groupId);
|
|
requirePermission(actor, "codes:create", { groupId });
|
|
|
|
// Die Domain bestimmt, unter welchem Hostnamen die Kurz-URL erreichbar
|
|
// ist — und der Slug ist nur je Domain eindeutig.
|
|
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId);
|
|
|
|
const qrType = await requireAvailableQrType(type);
|
|
|
|
const validated = qrType.validate(parsed.data.data);
|
|
if (!validated.ok) {
|
|
return jsonError(validated.message);
|
|
}
|
|
|
|
const istStatisch = parsed.data.mode === "static";
|
|
|
|
if (istStatisch) {
|
|
// Ein statischer Code trägt seinen Inhalt selbst — der Typ muss das
|
|
// können, sonst gäbe es nichts zu kodieren.
|
|
if (!qrType.supportsStatic) {
|
|
return jsonError(
|
|
`Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`,
|
|
);
|
|
}
|
|
if (parsed.data.slug) {
|
|
return jsonError(
|
|
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
|
|
);
|
|
}
|
|
} else {
|
|
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
|
|
assertDynamicPossible(qrType, validated.data);
|
|
}
|
|
|
|
// Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht —
|
|
// sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte.
|
|
const gewuenscht = parsed.data.slug?.trim();
|
|
if (gewuenscht) {
|
|
// Einen Kurz-Link selbst zu vergeben wiegt mehr als einen Code anzulegen.
|
|
const { editorsMaySetSlug } = await getSettings();
|
|
requirePermission(actor, "codes:slug", { groupId, editorsMaySetSlug });
|
|
|
|
const problem = describeSlugProblem(gewuenscht);
|
|
if (problem) return jsonError(problem);
|
|
await assertSlugAvailable(domainId, gewuenscht);
|
|
}
|
|
|
|
if (istStatisch) {
|
|
const created = await prisma.qrCode.create({
|
|
data: {
|
|
mode: "static",
|
|
slug: null,
|
|
type: qrType.id,
|
|
label: label && label.length > 0 ? label : null,
|
|
data: toJsonInput(validated.data),
|
|
groupId,
|
|
domainId,
|
|
createdById: actor.kind === "user" ? actor.user.id : null,
|
|
...design,
|
|
},
|
|
include: {
|
|
group: { select: { name: true } },
|
|
domain: { select: { hostname: true } },
|
|
createdBy: { select: { name: true, email: true } },
|
|
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
|
},
|
|
});
|
|
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
|
}
|
|
|
|
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
try {
|
|
const created = await prisma.qrCode.create({
|
|
data: {
|
|
slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
|
|
type: qrType.id,
|
|
label: label && label.length > 0 ? label : null,
|
|
data: toJsonInput(validated.data),
|
|
groupId,
|
|
domainId,
|
|
createdById: actor.kind === "user" ? actor.user.id : null,
|
|
...design,
|
|
},
|
|
include: {
|
|
group: { select: { name: true } },
|
|
domain: { select: { hostname: true } },
|
|
createdBy: { select: { name: true, email: true } },
|
|
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
|
},
|
|
});
|
|
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
|
// vorher lagen sie ohne Bezug herum.
|
|
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
|
|
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
|
} catch (error) {
|
|
// Bei einem selbst gewählten Kurz-Link hilft kein zweiter Versuch: Er
|
|
// käme immer wieder auf dieselbe Adresse.
|
|
if (gewuenscht && isUniqueViolation(error)) {
|
|
return jsonError(
|
|
`Der Kurz-Link „${normalizeSlug(gewuenscht)}" ist auf dieser Domain schon vergeben.`,
|
|
409,
|
|
);
|
|
}
|
|
if (!isUniqueViolation(error)) throw error;
|
|
}
|
|
}
|
|
|
|
throw new QrRequestError(
|
|
"Es konnte keine freie Kurz-URL erzeugt werden. Bitte versuche es erneut.",
|
|
500,
|
|
);
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|