Files
qrcode-generator/src/app/api/codes/route.ts
T

258 lines
9.5 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 { findStaticDuplicate } from "@/lib/duplicates";
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(),
/**
* 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" &&
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);
// Die Freigabe haengt an der Zielgruppe — global abgeschaltet gewinnt.
const qrType = await requireAvailableQrType(type, groupId);
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);
}
// 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: {
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, dateienDesCodes(qrType, validated.data, design));
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, dateienDesCodes(qrType, validated.data, design));
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);
}
}