Sprechende Kurz-Links: selbst vergeben, aendern, frueher gueltig halten

Schliesst Issue #1. Der Wunsch betraf den Kurz-Link, nicht das Ziel: Er soll
sich von Anfang an bestimmen und spaeter aendern lassen.

Beim Anlegen gibt es dafuer ein Feld, im Bearbeiten-Dialog laesst er sich
aendern. Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und „/" als
Trenner — damit sind Adressen wie kunde-a/sommer moeglich. Umlaute bleiben
draussen, weil sie in Adressen unleserlich kodiert werden; Gross- und
Kleinschreibung ist beim Aufrufen egal.

Beim Aendern bleibt die vorherige Adresse gueltig. Das ist der Kern: Auf
gedruckten Codes steht die alte, und genau deren Bestand ist der Sinn eines
dynamischen Codes. Frueher vergebene Adressen stehen im Dialog und lassen
sich einzeln freigeben — fuer den Tippfehler, mit dem noch nichts gedruckt
ist. Solange eine Adresse gehalten wird, kann sie kein anderer Code
uebernehmen; sonst liessen sich fremde gedruckte Codes umlenken.

Vergeben und Aendern ist standardmaessig Administratoren vorbehalten. Ein
Kurz-Link ist eine oeffentliche Adresse, und wer ihn aendert, kann Aushaenge
entwerten. Unter Verwaltung -> Einstellungen laesst sich das fuer Bearbeiter
freigeben; ohne das Recht erscheint das Feld gar nicht, Codes anlegen und
deren Ziel aendern geht weiterhin. Eigene Aktion in der Policy statt Teil von
codes:update, weil sie mehr wiegt.

Technisch: /r und /s sind Sammelrouten, weil ein Kurz-Link „/" enthalten
darf. Die Eindeutigkeit gilt zusaetzlich auf der kleingeschriebenen Form; die
Migration bricht ab, falls der Bestand das nicht zulaesst, statt still Daten
zu verlieren. Der bestehende exakte Index bleibt — er traegt den haeufigen
Fall, den gescannten Code.
This commit is contained in:
Florian Wilde
2026-08-17 10:41:57 +02:00
parent c38b27ca85
commit 46e7eb0b59
24 changed files with 917 additions and 49 deletions
+12 -2
View File
@@ -4,6 +4,9 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard";
import { getQrTypeState } from "@/lib/qr/availability";
import { getQrType } from "@/lib/qr/registry";
@@ -47,7 +50,7 @@ function Hinweis({
}
export default async function CreatePage({ params }: Props) {
await requirePageActor();
const actor = await requirePageActor();
const { type } = await params;
const state = await getQrTypeState(type);
@@ -75,5 +78,12 @@ export default async function CreatePage({ params }: Props) {
);
}
return <QrGenerator typeId={state.qrType.id} />;
// Ob der Kurz-Link selbst vergeben werden darf, haengt an Rolle und
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
// noch einmal.
const { editorsMaySetSlug } = await getSettings();
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
}
+10 -2
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard";
export const metadata: Metadata = { title: "Meine Codes" };
@@ -8,10 +10,16 @@ export const metadata: Metadata = { title: "Meine Codes" };
export const dynamic = "force-dynamic";
export default async function MyCodesPage() {
await requirePageActor();
const actor = await requirePageActor();
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte
// nebeneinander zeigen können.
// Der Kurz-Link darf je Gruppe verschieden bearbeitbar sein; hier zaehlt,
// ob es irgendwo erlaubt ist. Die Route prueft es beim Speichern erneut,
// dann mit der Gruppe des betroffenen Codes.
const { editorsMaySetSlug } = await getSettings();
const maySetSlug = can(actor, "codes:slug", { editorsMaySetSlug });
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
@@ -25,7 +33,7 @@ export default async function MyCodesPage() {
</p>
</div>
<CodesList />
<CodesList maySetSlug={maySetSlug} />
</div>
);
}
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { SettingsAdmin } from "@/components/settings-admin";
import { requirePagePermission } from "@/lib/auth/guard";
import { getSettings } from "@/lib/settings";
export const metadata: Metadata = { title: "Einstellungen" };
export const dynamic = "force-dynamic";
export default async function SettingsPage() {
await requirePagePermission("instance:manage");
const settings = await getSettings();
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/verwaltung"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Verwaltung
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Einstellungen
</h1>
<p className="mt-2 text-slate-600">
Was Bearbeiter dürfen und was Administratoren vorbehalten bleibt.
</p>
</div>
<SettingsAdmin initial={{ editorsMaySetSlug: settings.editorsMaySetSlug }} />
</div>
);
}
+14 -1
View File
@@ -6,6 +6,7 @@ import { Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Verwaltung" };
@@ -46,13 +47,14 @@ function Tile({
export default async function AdminPage() {
await requirePagePermission("instance:manage");
const [userCount, groupCount, codeCount, domainCount, branding] =
const [userCount, groupCount, codeCount, domainCount, branding, settings] =
await Promise.all([
prisma.user.count(),
prisma.group.count(),
prisma.qrCode.count(),
prisma.domain.count(),
getBranding(),
getSettings(),
]);
const eigenes = [
@@ -120,6 +122,17 @@ export default async function AdminPage() {
description="Festlegen, welche Typen auf der Startseite erscheinen."
hint="15 Typen insgesamt"
/>
<Tile
href="/verwaltung/einstellungen"
icon="lock"
title="Einstellungen"
description="Was Bearbeiter dürfen — etwa eigene Kurz-Links vergeben."
hint={
settings.editorsMaySetSlug
? "Kurz-Links: auch Bearbeiter"
: "Kurz-Links: nur Administratoren"
}
/>
<Tile
href="/verwaltung/erscheinungsbild"
icon="image"
+40
View File
@@ -0,0 +1,40 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { getSettings, saveSettings } from "@/lib/settings";
export const dynamic = "force-dynamic";
const updateSchema = z.object({
editorsMaySetSlug: z.boolean().optional(),
});
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
return Response.json({ settings: await getSettings() });
} catch (error) {
return errorResponse(error);
}
}
export async function PATCH(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
if (Object.keys(parsed.data).length === 0) {
return jsonError("Es wurden keine Änderungen übermittelt.");
}
return Response.json({ settings: await saveSettings(parsed.data) });
} catch (error) {
return errorResponse(error);
}
}
+90 -5
View File
@@ -10,6 +10,9 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
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 { QrRequestError } from "@/lib/qr/type-module";
@@ -21,6 +24,7 @@ const WITH_RELATIONS = {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
} as const;
const updateSchema = z.object({
@@ -32,6 +36,17 @@ const updateSchema = z.object({
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.nullable()
.optional(),
/** Neuer Kurz-Link. Der bisherige bleibt als frühere Adresse gültig. */
slug: z.string().trim().max(120).optional(),
/**
* Welche früheren Adressen weiterhin gelten sollen.
*
* Bewusst als vollständige Liste und nicht als „lösche diese eine": So
* beschreibt die Anfrage den gewünschten Zustand, und die Oberfläche muss
* sich nicht merken, was sie schon geschickt hat. Was fehlt, wird
* freigegeben — und ist damit sofort tot.
*/
aliases: z.array(z.string().trim().max(120)).max(50).optional(),
});
async function loadCode(id: string) {
@@ -109,14 +124,84 @@ export async function PATCH(
Object.assign(update, parsed.data.design);
}
if (Object.keys(update).length === 0) {
/* ------------------------------------------------------- Kurz-Link */
const neuerSlug = parsed.data.slug?.trim();
const umbenannt =
neuerSlug !== undefined &&
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
/**
* Welche früheren Adressen gelten sollen. Fehlt das Feld, bleibt alles wie
* es ist; steht es da, beschreibt es den gewünschten Zustand.
*/
const behalten =
parsed.data.aliases === undefined
? null
: new Set(parsed.data.aliases.map(normalizeSlug));
const vorhandeneAliase = await prisma.qrCodeAlias.findMany({
where: { qrCodeId: existing.id },
select: { id: true, slug: true },
});
const freigeben = behalten
? vorhandeneAliase.filter((a) => !behalten.has(normalizeSlug(a.slug)))
: [];
// Erst prüfen, wenn wirklich etwas am Kurz-Link geändert wird. Die
// Oberfläche schickt die Liste der früheren Adressen bei jeder Änderung
// mit — unverändert soll das kein zusätzliches Recht verlangen.
if (umbenannt || freigeben.length > 0) {
const { editorsMaySetSlug } = await getSettings();
requirePermission(actor, "codes:slug", {
groupId: existing.groupId,
editorsMaySetSlug,
});
}
if (umbenannt) {
const problem = describeSlugProblem(neuerSlug);
if (problem) return jsonError(problem);
await assertSlugAvailable(existing.domainId, neuerSlug, existing.id);
update.slug = normalizeSlug(neuerSlug);
}
if (
Object.keys(update).length === 0 &&
!umbenannt &&
freigeben.length === 0
) {
return jsonError("Es wurden keine Änderungen übermittelt.");
}
const updated = await prisma.qrCode.update({
where: { id },
data: update,
include: WITH_RELATIONS,
// Umbenennen, alte Adresse sichern und Freigaben in einem Zug — sonst
// gäbe es einen Moment, in dem der Code unter keiner Adresse erreichbar
// ist, oder eine freigegebene Adresse bliebe hängen.
const updated = await prisma.$transaction(async (tx) => {
if (freigeben.length > 0) {
await tx.qrCodeAlias.deleteMany({
where: { id: { in: freigeben.map((a) => a.id) } },
});
}
if (umbenannt) {
// Der bisherige Kurz-Link bleibt gültig — daran hängen gedruckte
// Codes. Freigeben lässt er sich danach einzeln.
await tx.qrCodeAlias.create({
data: {
domainId: existing.domainId,
slug: existing.slug,
qrCodeId: existing.id,
},
});
}
return tx.qrCode.update({
where: { id },
data: update,
include: WITH_RELATIONS,
});
});
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
+32 -2
View File
@@ -12,7 +12,9 @@ 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 { generateSlug } from "@/lib/slug";
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";
@@ -26,6 +28,11 @@ const createSchema = z.object({
.trim()
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.optional(),
/**
* 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. */
@@ -57,6 +64,7 @@ export async function GET(): Promise<Response> {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
},
});
@@ -96,12 +104,25 @@ export async function POST(request: NextRequest): Promise<Response> {
// 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);
}
// 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: generateSlug(),
slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
type: qrType.id,
label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data),
@@ -114,6 +135,7 @@ export async function POST(request: NextRequest): Promise<Response> {
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 —
@@ -122,6 +144,14 @@ export async function POST(request: NextRequest): Promise<Response> {
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;
}
}
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ slug: string }> };
// Ein Kurz-Link darf „/" enthalten (z. B. kunde-a/sommer), deshalb eine
// Sammelroute: Next liefert die Abschnitte dann als Liste.
type Context = { params: Promise<{ slug: string[] }> };
function notFound(message: string): Response {
return new Response(
@@ -44,7 +47,8 @@ export async function GET(
request: NextRequest,
context: Context,
): Promise<Response> {
const { slug } = await context.params;
const abschnitte = (await context.params).slug;
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) {
return notFound("Diese Kurz-Adresse ist ungültig.");
@@ -65,9 +69,8 @@ export async function GET(
);
}
const code = await prisma.qrCode.findUnique({
where: { domainId_slug: { domainId: domain.id, slug } },
});
const gefunden = await findCodeBySlug(domain.id, slug);
const code = gefunden?.code;
if (!code) {
return notFound(
@@ -85,7 +88,7 @@ export async function GET(
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
// wenn das eine nicht registrierte ist, etwa eine IP im LAN).
target = `/s/${slug}`;
target = `/s/${code.slug}`;
} else {
try {
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
@@ -3,14 +3,15 @@ import { headers } from "next/headers";
import { qrLandings } from "@/components/landing";
import { LandingNotice } from "@/components/landing/shell";
import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ slug: string }> };
// Sammelroute, weil ein Kurz-Link „/" enthalten darf.
type PageProps = { params: Promise<{ slug: string[] }> };
/**
* Öffentliche Landingpage eines dynamischen Codes.
@@ -21,16 +22,15 @@ type PageProps = { params: Promise<{ slug: string }> };
* Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan.
*/
async function loadCode(slug: string) {
async function loadCode(abschnitte: string[] | undefined) {
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) return null;
const matched = await resolveRequestDomain(await headers());
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
if (!domain.isActive) return null;
return prisma.qrCode.findUnique({
where: { domainId_slug: { domainId: domain.id, slug } },
});
return (await findCodeBySlug(domain.id, slug))?.code ?? null;
}
export async function generateMetadata({