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:
+27
-1
@@ -21,12 +21,30 @@ export type Action =
|
||||
| "codes:update"
|
||||
/** Codes löschen. */
|
||||
| "codes:delete"
|
||||
/**
|
||||
* Den Kurz-Link selbst vergeben oder ändern.
|
||||
*
|
||||
* Eigene Aktion und nicht Teil von `codes:update`, weil sie mehr wiegt: Ein
|
||||
* Kurz-Link ist eine öffentliche Adresse. Wer ihn ändert, entwertet im
|
||||
* Zweifel gedruckte Codes; wer ihn vergibt, besetzt eine Adresse, die
|
||||
* jemand anderes gebraucht hätte. Deshalb standardmäßig nur für
|
||||
* Administratoren — freigeben lässt es sich in der Verwaltung.
|
||||
*/
|
||||
| "codes:slug"
|
||||
/** Mitglieder und Rollen einer Gruppe verwalten. */
|
||||
| "group:manage"
|
||||
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
|
||||
| "instance:manage";
|
||||
|
||||
export type Scope = { groupId?: string };
|
||||
export type Scope = {
|
||||
groupId?: string;
|
||||
/**
|
||||
* Die Einstellung aus der Verwaltung, ob Bearbeiter Kurz-Links vergeben
|
||||
* dürfen. Wird von der aufrufenden Stelle mitgegeben, damit die Regeln hier
|
||||
* ohne Datenbankzugriff auskommen und prüfbar bleiben.
|
||||
*/
|
||||
editorsMaySetSlug?: boolean;
|
||||
};
|
||||
|
||||
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
|
||||
|
||||
@@ -89,6 +107,12 @@ export function can(actor: Actor, action: Action, scope: Scope = {}): boolean {
|
||||
case "codes:update":
|
||||
case "codes:delete":
|
||||
return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
|
||||
case "codes:slug":
|
||||
// Administratoren der Gruppe immer, Bearbeiter nur nach Freigabe.
|
||||
if (role === "ADMIN") return true;
|
||||
return (
|
||||
ROLE_RANK[role] >= ROLE_RANK.EDITOR && scope.editorsMaySetSlug === true
|
||||
);
|
||||
case "group:manage":
|
||||
return role === "ADMIN";
|
||||
default:
|
||||
@@ -104,6 +128,8 @@ const DENIED_MESSAGES: Record<Action, string> = {
|
||||
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||
"codes:delete":
|
||||
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||
"codes:slug":
|
||||
"Den Kurz-Link darf hier nur ein Administrator vergeben oder ändern. Ein Administrator der Installation kann das unter Verwaltung → Einstellungen für Bearbeiter freigeben.",
|
||||
"group:manage":
|
||||
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
|
||||
"instance:manage":
|
||||
|
||||
@@ -31,6 +31,7 @@ export type QrCodeWithRelations = QrCode & {
|
||||
group?: { name: string } | null;
|
||||
domain?: { hostname: string } | null;
|
||||
createdBy?: { name: string | null; email: string } | null;
|
||||
aliases?: { slug: string }[];
|
||||
};
|
||||
|
||||
export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
||||
@@ -60,6 +61,7 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
||||
return {
|
||||
id: code.id,
|
||||
slug: code.slug,
|
||||
aliases: (code.aliases ?? []).map((alias) => alias.slug),
|
||||
type: code.type as QrTypeId,
|
||||
label: code.label,
|
||||
data,
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { QrTypeId } from "@/lib/qr/type-module";
|
||||
export type QrCodeDto = {
|
||||
id: string;
|
||||
slug: string;
|
||||
/** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */
|
||||
aliases: string[];
|
||||
type: QrTypeId;
|
||||
label: string | null;
|
||||
data: Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export type InstanceSettings = {
|
||||
/** Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern? */
|
||||
editorsMaySetSlug: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: InstanceSettings = {
|
||||
editorsMaySetSlug: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Bewusst ohne Zwischenspeicher — dieselbe Erfahrung wie beim
|
||||
* Erscheinungsbild: API-Route und Seite laufen in getrennten Modulinstanzen,
|
||||
* ein Puffer wäre nach dem Speichern veraltet. Es ist eine Abfrage auf den
|
||||
* Primärschlüssel einer Tabelle mit einer einzigen Zeile.
|
||||
*/
|
||||
export async function getSettings(): Promise<InstanceSettings> {
|
||||
try {
|
||||
const zeile = await prisma.instanceSetting.findUnique({
|
||||
where: { id: "instance" },
|
||||
});
|
||||
return zeile
|
||||
? { editorsMaySetSlug: zeile.editorsMaySetSlug }
|
||||
: DEFAULT_SETTINGS;
|
||||
} catch (error) {
|
||||
// Ein Datenbankproblem darf nicht dazu führen, dass keine Seite erscheint.
|
||||
// Im Zweifel gilt die zurückhaltendere Einstellung.
|
||||
console.error("Einstellungen konnten nicht geladen werden:", error);
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
update: Partial<InstanceSettings>,
|
||||
): Promise<InstanceSettings> {
|
||||
await prisma.instanceSetting.upsert({
|
||||
where: { id: "instance" },
|
||||
update,
|
||||
create: { id: "instance", ...update },
|
||||
});
|
||||
return getSettings();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import "server-only";
|
||||
|
||||
import type { QrCode } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
import { isValidSlug, normalizeSlug } from "@/lib/slug";
|
||||
|
||||
/**
|
||||
* Sucht den Code zu einem Kurz-Link — einschließlich der früheren Adressen.
|
||||
*
|
||||
* Zwei Stufen, aus einem Grund: Der häufige Fall ist ein gescannter Code, und
|
||||
* der liefert die Adresse genau so, wie sie gespeichert ist. Dieser Zugriff
|
||||
* geht über den exakten Index. Erst wenn er nichts findet, wird
|
||||
* großschreibungsunabhängig gesucht und danach unter den früheren Adressen —
|
||||
* beides seltene Fälle, etwa wenn jemand die Adresse abtippt.
|
||||
*/
|
||||
export async function findCodeBySlug(
|
||||
domainId: string,
|
||||
slug: string,
|
||||
): Promise<{ code: QrCode; ueberFrueheAdresse: boolean } | null> {
|
||||
if (!isValidSlug(slug)) return null;
|
||||
|
||||
const exakt = await prisma.qrCode.findUnique({
|
||||
where: { domainId_slug: { domainId, slug } },
|
||||
});
|
||||
if (exakt) return { code: exakt, ueberFrueheAdresse: false };
|
||||
|
||||
const normalisiert = normalizeSlug(slug);
|
||||
|
||||
const egalGeschrieben = await prisma.qrCode.findFirst({
|
||||
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
|
||||
});
|
||||
if (egalGeschrieben) {
|
||||
return { code: egalGeschrieben, ueberFrueheAdresse: false };
|
||||
}
|
||||
|
||||
const alias = await prisma.qrCodeAlias.findFirst({
|
||||
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
|
||||
include: { qrCode: true },
|
||||
});
|
||||
if (alias) return { code: alias.qrCode, ueberFrueheAdresse: true };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist dieser Kurz-Link auf dieser Domain noch frei?
|
||||
*
|
||||
* Belegt ist er auch dann, wenn ihn ein anderer Code als **frühere** Adresse
|
||||
* hält — sonst könnte man einem fremden Code seine gedruckten Adressen
|
||||
* wegnehmen.
|
||||
*/
|
||||
export async function assertSlugAvailable(
|
||||
domainId: string,
|
||||
slug: string,
|
||||
/** Der eigene Code darf seine eigenen Adressen natürlich behalten. */
|
||||
eigenerCodeId?: string,
|
||||
): Promise<void> {
|
||||
const normalisiert = normalizeSlug(slug);
|
||||
|
||||
const belegtVomCode = await prisma.qrCode.findFirst({
|
||||
where: {
|
||||
domainId,
|
||||
slug: { equals: normalisiert, mode: "insensitive" },
|
||||
...(eigenerCodeId ? { id: { not: eigenerCodeId } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (belegtVomCode) {
|
||||
throw new QrRequestError(
|
||||
`Der Kurz-Link „${normalisiert}" ist auf dieser Domain schon vergeben.`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const belegtAlsAlias = await prisma.qrCodeAlias.findFirst({
|
||||
where: {
|
||||
domainId,
|
||||
slug: { equals: normalisiert, mode: "insensitive" },
|
||||
...(eigenerCodeId ? { qrCodeId: { not: eigenerCodeId } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (belegtAlsAlias) {
|
||||
throw new QrRequestError(
|
||||
`Der Kurz-Link „${normalisiert}" gehört als frühere Adresse noch zu einem ` +
|
||||
`anderen Code. Wer ihn freigibt, kann ihn danach hier verwenden.`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
+91
-3
@@ -8,8 +8,12 @@ const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
|
||||
export const SLUG_LENGTH = 8;
|
||||
|
||||
/** Muster, gegen das eingehende Slugs geprüft werden. */
|
||||
export const SLUG_PATTERN = /^[0-9A-Za-z]{4,32}$/;
|
||||
/** Höchstens so viele durch `/` getrennte Abschnitte. */
|
||||
export const MAX_SLUG_SEGMENTS = 3;
|
||||
/** Länge eines Abschnitts. */
|
||||
export const MAX_SEGMENT_LENGTH = 48;
|
||||
/** Länge des ganzen Kurz-Links. */
|
||||
export const MAX_SLUG_LENGTH = 100;
|
||||
|
||||
export function generateSlug(length: number = SLUG_LENGTH): string {
|
||||
let slug = "";
|
||||
@@ -19,6 +23,90 @@ export function generateSlug(length: number = SLUG_LENGTH): string {
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergleichsform eines Kurz-Links.
|
||||
*
|
||||
* Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: Wer
|
||||
* `qr.firma.de/r/Sommeraktion` abtippt, soll dort landen, wo
|
||||
* `sommeraktion` liegt. Deshalb wird überall gegen diese Form verglichen —
|
||||
* beim Auflösen wie bei der Prüfung auf Eindeutigkeit.
|
||||
*/
|
||||
export function normalizeSlug(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Ein einzelner Abschnitt: Kleinbuchstaben, Ziffern, Bindestriche. */
|
||||
const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
/**
|
||||
* Prüft einen Kurz-Link, wie er in der Adresse stehen darf.
|
||||
*
|
||||
* Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und `/` als Trenner —
|
||||
* damit sind Adressen wie `kunde-a/sommeraktion` möglich. Bewusst **nicht**
|
||||
* erlaubt: Umlaute und Akzente (sie werden in Adressen unleserlich kodiert),
|
||||
* Leerzeichen, Unterstriche sowie Bindestriche am Rand eines Abschnitts.
|
||||
*
|
||||
* Selbst erzeugte Kurz-Links aus dem gemischten Alphabet oben bestehen diese
|
||||
* Prüfung ebenfalls, weil verglichen wird, nachdem kleingeschrieben wurde.
|
||||
*
|
||||
* Führende, doppelte und abschließende Schrägstriche gelten als ungültig — und
|
||||
* zwar auch beim Auflösen. Sonst führten `/r/sommer` und `/r//sommer` auf
|
||||
* denselben Code, und es gäbe zwei Adressen für dieselbe Sache.
|
||||
*/
|
||||
export function isValidSlug(value: string): boolean {
|
||||
return SLUG_PATTERN.test(value);
|
||||
const slug = normalizeSlug(value);
|
||||
if (slug.length === 0 || slug.length > MAX_SLUG_LENGTH) return false;
|
||||
if (slug.startsWith("/") || slug.endsWith("/") || slug.includes("//")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const abschnitte = slug.split("/");
|
||||
if (abschnitte.length > MAX_SLUG_SEGMENTS) return false;
|
||||
|
||||
return abschnitte.every(
|
||||
(abschnitt) =>
|
||||
abschnitt.length > 0 &&
|
||||
abschnitt.length <= MAX_SEGMENT_LENGTH &&
|
||||
SEGMENT.test(abschnitt),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft einen selbst gewählten Kurz-Link und liefert eine deutsche Begründung,
|
||||
* wenn er nicht taugt. Ausführlicher als `isValidSlug`, weil hier jemand
|
||||
* zusieht und wissen will, was falsch war.
|
||||
*/
|
||||
export function describeSlugProblem(eingabe: string): string | null {
|
||||
const roh = eingabe.trim();
|
||||
if (roh.length === 0) return "Bitte gib einen Kurz-Link ein.";
|
||||
|
||||
if (/^\/|\/$/.test(roh)) {
|
||||
return "Der Kurz-Link darf nicht mit „/“ beginnen oder enden.";
|
||||
}
|
||||
if (roh.includes("//")) {
|
||||
return "Zwei Schrägstriche hintereinander sind nicht möglich.";
|
||||
}
|
||||
if (roh.length > MAX_SLUG_LENGTH) {
|
||||
return `Der Kurz-Link ist zu lang (höchstens ${MAX_SLUG_LENGTH} Zeichen).`;
|
||||
}
|
||||
|
||||
const slug = normalizeSlug(roh);
|
||||
const abschnitte = slug.split("/");
|
||||
|
||||
if (abschnitte.length > MAX_SLUG_SEGMENTS) {
|
||||
return `Höchstens ${MAX_SLUG_SEGMENTS} durch „/“ getrennte Abschnitte.`;
|
||||
}
|
||||
for (const abschnitt of abschnitte) {
|
||||
if (abschnitt.length > MAX_SEGMENT_LENGTH) {
|
||||
return `Jeder Abschnitt darf höchstens ${MAX_SEGMENT_LENGTH} Zeichen lang sein.`;
|
||||
}
|
||||
if (/^-|-$/.test(abschnitt)) {
|
||||
return "Ein Bindestrich darf nicht am Anfang oder Ende eines Abschnitts stehen.";
|
||||
}
|
||||
if (!SEGMENT.test(abschnitt)) {
|
||||
return "Erlaubt sind Buchstaben ohne Umlaute, Ziffern, Bindestriche und „/“ als Trenner.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user