Phase B2: mehrere Domains fuer Kurz-URLs
Eine Domain kann mehreren Gruppen dienen (n:m ueber DomainGrant), und der Slug eines Codes ist nur noch je Domain eindeutig - derselbe sprechende Slug darf also auf zwei Domains liegen. Sicherheitsrelevanter Kern: die Weiterleitung liest den Hostnamen aus X-Forwarded-Host bzw. Host und prueft ihn gegen die Domain-Tabelle. Ein unbekannter Hostname endet mit 404, faellt also auf die Standard-Domain zurueck und findet dort nichts. Ueber einen gefaelschten Header laesst sich damit keine fremde Domain ansprechen. Auch der Hostname fuer die Vorschau kommt aus der Datenbank, nicht aus der Anfrage. - Migration legt die Spalte erst nullable an, fuellt sie mit der Standard-Domain und macht sie danach verpflichtend; der globale Slug-Index weicht dem zusammengesetzten aus (domainId, slug). - Verwaltung -> Domains: registrieren, Gruppen zuteilen, Voreinstellung je Gruppe, abschalten. Domains mit Codes lassen sich weder loeschen noch einer Gruppe entziehen, die noch Codes darunter hat - beides wuerde gedruckte Codes ins Leere laufen lassen. Ein Fehler dabei gefunden und behoben: die Migration kennt BASE_URL nicht und legt die Standard-Domain mit einem Platzhalter-Hostnamen an. Korrigiert wurde er bisher nur beim Oeffnen der Domain-Verwaltung - auf einer frischen Installation waeren also Kurz-URLs auf 'standard.invalid' entstanden. ensureDefaultDomain() laeuft jetzt vor jeder Verwendung. Geprueft: 34 neue Pruefungen fuer Host-Aufloesung, Spoofing, Slug je Domain und Zuteilung; dazu 280 bestehende bei ausgeschalteter Anmeldung.
This commit is contained in:
@@ -209,9 +209,28 @@ docker compose up -d
|
|||||||
> aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue
|
> aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue
|
||||||
> dynamische Codes ins interne Netz statt auf die Domain.
|
> dynamische Codes ins interne Netz statt auf die Domain.
|
||||||
|
|
||||||
Mehrere Domains gleichzeitig unterstützt diese Version noch nicht — das kommt
|
### Mehrere Domains
|
||||||
in Phase B (siehe [Ausblick](#ausblick-version-2)). Zurzeit gilt genau eine
|
|
||||||
`BASE_URL`.
|
Sollen Kurz-URLs unter verschiedenen Adressen laufen — etwa je Kunde eine —,
|
||||||
|
lege sie unter **Verwaltung → Domains** an und richte im Proxy für jede einen
|
||||||
|
eigenen Proxy Host auf dieselbe Anwendung ein.
|
||||||
|
|
||||||
|
Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und
|
||||||
|
prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit
|
||||||
|
404 — über einen gefälschten Header lässt sich also keine fremde Domain
|
||||||
|
ansprechen.
|
||||||
|
|
||||||
|
Zwei Dinge, die daraus folgen:
|
||||||
|
|
||||||
|
- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende
|
||||||
|
Slug darf auf zwei Domains liegen und zeigt dort auf verschiedene Ziele.
|
||||||
|
- Eine Domain kann **mehreren Gruppen** dienen. Das heißt aber nicht, dass
|
||||||
|
diese die Codes der jeweils anderen sehen — die Übersicht bleibt strikt
|
||||||
|
nach Gruppe gefiltert.
|
||||||
|
|
||||||
|
`BASE_URL` bleibt dabei wichtig: sie liefert die Standard-Domain für Aufrufe
|
||||||
|
über eine nicht registrierte Adresse (etwa direkt über die IP im LAN) und
|
||||||
|
bestimmt, ob Kurz-URLs mit `http` oder `https` gebildet werden.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- Mehrere Domains für Kurz-URLs.
|
||||||
|
--
|
||||||
|
-- Zwei Dinge sind hier heikel und deshalb ausdrücklich schrittweise gelöst:
|
||||||
|
--
|
||||||
|
-- 1. `QrCode.domainId` ist verpflichtend, es gibt aber schon Codes. Die
|
||||||
|
-- Spalte entsteht zuerst ohne Einschränkung, wird gefüllt und erst danach
|
||||||
|
-- auf NOT NULL gesetzt.
|
||||||
|
-- 2. `slug` war global eindeutig und ist es künftig nur noch je Domain. Der
|
||||||
|
-- alte Index muss weichen, bevor der neue greifen kann.
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Domain" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"hostname" TEXT NOT NULL,
|
||||||
|
"label" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Domain_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DomainGrant" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"domainId" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DomainGrant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Domain_hostname_key" ON "Domain"("hostname");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DomainGrant_groupId_idx" ON "DomainGrant"("groupId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DomainGrant_domainId_groupId_key" ON "DomainGrant"("domainId", "groupId");
|
||||||
|
|
||||||
|
-- Standard-Domain. Der Hostname ist zunächst ein Platzhalter: die
|
||||||
|
-- Migration kennt BASE_URL nicht. Beim ersten Start ersetzt
|
||||||
|
-- ensureDefaultDomain() ihn durch den Hostnamen aus BASE_URL.
|
||||||
|
INSERT INTO "Domain" ("id", "hostname", "label", "isActive", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'dom_standard',
|
||||||
|
'standard.invalid',
|
||||||
|
'Standard-Domain',
|
||||||
|
true,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Alle bestehenden Gruppen bekommen die Standard-Domain als Voreinstellung.
|
||||||
|
INSERT INTO "DomainGrant" ("id", "domainId", "groupId", "isDefault", "createdAt")
|
||||||
|
SELECT 'dg_std_' || "Group"."id", 'dom_standard', "Group"."id", true, CURRENT_TIMESTAMP
|
||||||
|
FROM "Group";
|
||||||
|
|
||||||
|
-- AlterTable: erst nullable …
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "domainId" TEXT;
|
||||||
|
|
||||||
|
-- … bestehende Codes der Standard-Domain zuordnen …
|
||||||
|
UPDATE "QrCode" SET "domainId" = 'dom_standard' WHERE "domainId" IS NULL;
|
||||||
|
|
||||||
|
-- … und erst jetzt verpflichtend machen.
|
||||||
|
ALTER TABLE "QrCode" ALTER COLUMN "domainId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- Eindeutigkeit des Slugs von global auf „je Domain“ umstellen.
|
||||||
|
DROP INDEX "QrCode_slug_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "QrCode_domainId_slug_key" ON "QrCode"("domainId", "slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "QrCode_domainId_idx" ON "QrCode"("domainId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DomainGrant" ADD CONSTRAINT "DomainGrant_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DomainGrant" ADD CONSTRAINT "DomainGrant_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
+48
-3
@@ -74,8 +74,9 @@ model Group {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
codes QrCode[]
|
codes QrCode[]
|
||||||
|
domainGrants DomainGrant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
||||||
@@ -94,6 +95,42 @@ model Membership {
|
|||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eine Domain, unter der Kurz-URLs ausgeliefert werden.
|
||||||
|
///
|
||||||
|
/// Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und
|
||||||
|
/// prüft ihn gegen diese Tabelle. Ein unbekannter Hostname führt zu 404 —
|
||||||
|
/// damit lässt sich über einen gefälschten Header keine fremde Domain
|
||||||
|
/// ansprechen.
|
||||||
|
model Domain {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Kleingeschrieben, ohne Schema, ggf. mit Port — z. B. "qr.example.com".
|
||||||
|
hostname String @unique
|
||||||
|
label String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
grants DomainGrant[]
|
||||||
|
codes QrCode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
|
||||||
|
/// dienen; welche davon die Voreinstellung der Gruppe ist, steht in
|
||||||
|
/// `isDefault`.
|
||||||
|
model DomainGrant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
domainId String
|
||||||
|
groupId String
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
|
||||||
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([domainId, groupId])
|
||||||
|
@@index([groupId])
|
||||||
|
}
|
||||||
|
|
||||||
/// Freigabe eines QR-Typs durch die Verwaltung.
|
/// Freigabe eines QR-Typs durch die Verwaltung.
|
||||||
///
|
///
|
||||||
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
|
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
|
||||||
@@ -114,7 +151,10 @@ model QrTypeSetting {
|
|||||||
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||||
model QrCode {
|
model QrCode {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
slug String @unique
|
/// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei
|
||||||
|
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
||||||
|
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
|
||||||
|
slug String
|
||||||
type String
|
type String
|
||||||
label String?
|
label String?
|
||||||
data Json
|
data Json
|
||||||
@@ -128,6 +168,9 @@ model QrCode {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
/// Domain, unter der die Kurz-URL dieses Codes erreichbar ist.
|
||||||
|
domainId String
|
||||||
|
domain Domain @relation(fields: [domainId], references: [id])
|
||||||
/// Jeder Code gehört einer Gruppe — auch bei ausgeschalteter Anmeldung.
|
/// Jeder Code gehört einer Gruppe — auch bei ausgeschalteter Anmeldung.
|
||||||
/// Dann ist es die beim ersten Start angelegte Standardgruppe.
|
/// Dann ist es die beim ersten Start angelegte Standardgruppe.
|
||||||
groupId String
|
groupId String
|
||||||
@@ -137,6 +180,8 @@ model QrCode {
|
|||||||
createdById String?
|
createdById String?
|
||||||
createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@unique([domainId, slug])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
|
@@index([domainId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { DEFAULT_DOMAIN_ID } from "@/lib/domains";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
label: z.string().trim().max(80).nullable().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
/**
|
||||||
|
* Vollständige Liste der Gruppen, die diese Domain nutzen dürfen. Wer
|
||||||
|
* fehlt, verliert die Zuteilung.
|
||||||
|
*/
|
||||||
|
grants: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
groupId: z.string().trim().min(1),
|
||||||
|
isDefault: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
const domain = await prisma.domain.findUnique({ where: { id } });
|
||||||
|
if (!domain) {
|
||||||
|
throw new QrRequestError("Diese Domain existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.label !== undefined || parsed.data.isActive !== undefined) {
|
||||||
|
await prisma.domain.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(parsed.data.label !== undefined
|
||||||
|
? { label: parsed.data.label || null }
|
||||||
|
: {}),
|
||||||
|
...(parsed.data.isActive !== undefined
|
||||||
|
? { isActive: parsed.data.isActive }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.grants !== undefined) {
|
||||||
|
const grants = parsed.data.grants;
|
||||||
|
|
||||||
|
const known = await prisma.group.findMany({
|
||||||
|
where: { id: { in: grants.map((g) => g.groupId) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (known.length !== grants.length) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Mindestens eine der ausgewählten Gruppen existiert nicht mehr.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Gruppe, die diese Domain noch für Codes verwendet, darf die
|
||||||
|
// Zuteilung nicht verlieren — sonst zeigten deren Kurz-URLs auf eine
|
||||||
|
// Domain, die der Gruppe gar nicht mehr gehört.
|
||||||
|
const stillInUse = await prisma.qrCode.findMany({
|
||||||
|
where: { domainId: id, groupId: { notIn: grants.map((g) => g.groupId) } },
|
||||||
|
select: { group: { select: { name: true } } },
|
||||||
|
distinct: ["groupId"],
|
||||||
|
});
|
||||||
|
if (stillInUse.length > 0) {
|
||||||
|
const names = stillInUse.map((entry) => entry.group.name).join(", ");
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Diese Gruppen haben noch Codes unter dieser Domain: ${names}. Solange die Codes bestehen, muss die Zuteilung bleiben.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.domainGrant.deleteMany({
|
||||||
|
where: { domainId: id, groupId: { notIn: grants.map((g) => g.groupId) } },
|
||||||
|
}),
|
||||||
|
...grants.map((grant) =>
|
||||||
|
prisma.domainGrant.upsert({
|
||||||
|
where: {
|
||||||
|
domainId_groupId: { domainId: id, groupId: grant.groupId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
domainId: id,
|
||||||
|
groupId: grant.groupId,
|
||||||
|
isDefault: grant.isDefault,
|
||||||
|
},
|
||||||
|
update: { isDefault: grant.isDefault },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
// Je Gruppe darf nur eine Domain die Voreinstellung sein.
|
||||||
|
...grants
|
||||||
|
.filter((grant) => grant.isDefault)
|
||||||
|
.map((grant) =>
|
||||||
|
prisma.domainGrant.updateMany({
|
||||||
|
where: { groupId: grant.groupId, domainId: { not: id } },
|
||||||
|
data: { isDefault: false },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
if (id === DEFAULT_DOMAIN_ID) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Standard-Domain lässt sich nicht löschen — sie fängt Aufrufe über nicht registrierte Adressen ab.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await prisma.domain.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { codes: true } } },
|
||||||
|
});
|
||||||
|
if (!domain) {
|
||||||
|
throw new QrRequestError("Diese Domain existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain._count.codes > 0) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Unter dieser Domain liegen noch ${domain._count.codes} ${domain._count.codes === 1 ? "Code" : "Codes"}. Sie zu löschen würde gedruckte Codes ins Leere laufen lassen — schalte sie stattdessen ab, wenn sie nicht mehr genutzt werden soll.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.domain.delete({ where: { id } });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { ensureDefaultDomain, normalizeHostname } from "@/lib/domains";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Hostname, optional mit Port. Bewusst ohne Schema und ohne Pfad — das
|
||||||
|
* ist der Wert, den ein Browser im `Host`-Header sendet.
|
||||||
|
*/
|
||||||
|
const hostnameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, { error: "Bitte gib einen Hostnamen ein." })
|
||||||
|
.max(253, { error: "Der Hostname ist zu lang." })
|
||||||
|
.transform(normalizeHostname)
|
||||||
|
.refine((value) => /^[a-z0-9.-]+(:\d{1,5})?$/.test(value), {
|
||||||
|
error:
|
||||||
|
"Das ist kein gültiger Hostname. Erwartet wird z. B. qr.example.com — ohne https:// und ohne Pfad.",
|
||||||
|
})
|
||||||
|
.refine((value) => value.includes(".") || value.startsWith("localhost"), {
|
||||||
|
error: "Bitte gib den vollständigen Hostnamen an, z. B. qr.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
hostname: hostnameSchema,
|
||||||
|
label: z.string().trim().max(80).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
// Sorgt dafür, dass die Standard-Domain den Hostnamen aus BASE_URL trägt.
|
||||||
|
await ensureDefaultDomain();
|
||||||
|
|
||||||
|
const domains = await prisma.domain.findMany({
|
||||||
|
orderBy: { hostname: "asc" },
|
||||||
|
include: {
|
||||||
|
grants: { include: { group: { select: { id: true, name: true } } } },
|
||||||
|
_count: { select: { codes: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = await prisma.group.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
domains: domains.map((domain) => ({
|
||||||
|
id: domain.id,
|
||||||
|
hostname: domain.hostname,
|
||||||
|
label: domain.label,
|
||||||
|
isActive: domain.isActive,
|
||||||
|
codeCount: domain._count.codes,
|
||||||
|
grants: domain.grants.map((grant) => ({
|
||||||
|
groupId: grant.group.id,
|
||||||
|
groupName: grant.group.name,
|
||||||
|
isDefault: grant.isDefault,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
groups,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
const parsed = createSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.domain.findUnique({
|
||||||
|
where: { hostname: parsed.data.hostname },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new QrRequestError("Diese Domain ist bereits registriert.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await prisma.domain.create({
|
||||||
|
data: {
|
||||||
|
hostname: parsed.data.hostname,
|
||||||
|
label: parsed.data.label || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ domain: { id: domain.id, hostname: domain.hostname } },
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,10 @@ export async function GET(
|
|||||||
request.nextUrl.searchParams.get("format") === "png" ? "png" : "svg";
|
request.nextUrl.searchParams.get("format") === "png" ? "png" : "svg";
|
||||||
const isDownload = request.nextUrl.searchParams.get("download") === "1";
|
const isDownload = request.nextUrl.searchParams.get("download") === "1";
|
||||||
|
|
||||||
const code = await prisma.qrCode.findUnique({ where: { id } });
|
const code = await prisma.qrCode.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { domain: { select: { hostname: true } } },
|
||||||
|
});
|
||||||
if (!code) {
|
if (!code) {
|
||||||
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
||||||
}
|
}
|
||||||
@@ -46,7 +49,7 @@ export async function GET(
|
|||||||
margin: code.margin,
|
margin: code.margin,
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = shortUrl(code.slug);
|
const content = shortUrl(code.slug, code.domain.hostname);
|
||||||
const body =
|
const body =
|
||||||
format === "png"
|
format === "png"
|
||||||
? new Uint8Array(await renderPng(content, design))
|
? new Uint8Array(await renderPng(content, design))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Context = { params: Promise<{ id: string }> };
|
|||||||
|
|
||||||
const WITH_RELATIONS = {
|
const WITH_RELATIONS = {
|
||||||
group: { select: { name: true } },
|
group: { select: { name: true } },
|
||||||
|
domain: { select: { hostname: true } },
|
||||||
createdBy: { select: { name: true, email: true } },
|
createdBy: { select: { name: true, email: true } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { resolveTargetGroup } from "@/lib/auth/groups";
|
|||||||
import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
||||||
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { resolveTargetDomain } from "@/lib/domains";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
import { requireAvailableQrType } from "@/lib/qr/availability";
|
import { requireAvailableQrType } from "@/lib/qr/availability";
|
||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
@@ -26,6 +27,8 @@ const createSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
|
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
|
||||||
groupId: z.string().trim().min(1).optional(),
|
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 {
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
@@ -51,6 +54,7 @@ export async function GET(): Promise<Response> {
|
|||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
group: { select: { name: true } },
|
group: { select: { name: true } },
|
||||||
|
domain: { select: { hostname: true } },
|
||||||
createdBy: { select: { name: true, email: true } },
|
createdBy: { select: { name: true, email: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -77,6 +81,10 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
const groupId = await resolveTargetGroup(actor, parsed.data.groupId);
|
const groupId = await resolveTargetGroup(actor, parsed.data.groupId);
|
||||||
requirePermission(actor, "codes:create", { 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 qrType = await requireAvailableQrType(type);
|
||||||
|
|
||||||
const validated = qrType.validate(parsed.data.data);
|
const validated = qrType.validate(parsed.data.data);
|
||||||
@@ -97,11 +105,13 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
label: label && label.length > 0 ? label : null,
|
label: label && label.length > 0 ? label : null,
|
||||||
data: toJsonInput(validated.data),
|
data: toJsonInput(validated.data),
|
||||||
groupId,
|
groupId,
|
||||||
|
domainId,
|
||||||
createdById: actor.kind === "user" ? actor.user.id : null,
|
createdById: actor.kind === "user" ? actor.user.id : null,
|
||||||
...design,
|
...design,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
group: { select: { name: true } },
|
group: { select: { name: true } },
|
||||||
|
domain: { select: { hostname: true } },
|
||||||
createdBy: { select: { name: true, email: true } },
|
createdBy: { select: { name: true, email: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
|
||||||
import { getQrType } from "@/lib/qr/registry";
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
import { isValidSlug } from "@/lib/slug";
|
import { isValidSlug } from "@/lib/slug";
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ function notFound(message: string): Response {
|
|||||||
*
|
*
|
||||||
* Bewusst 302 (temporär): Das Ziel ist jederzeit änderbar, ein permanenter
|
* Bewusst 302 (temporär): Das Ziel ist jederzeit änderbar, ein permanenter
|
||||||
* Redirect würde in Browsern und Zwischenspeichern hängen bleiben.
|
* Redirect würde in Browsern und Zwischenspeichern hängen bleiben.
|
||||||
|
*
|
||||||
|
* Der Slug ist nur je Domain eindeutig, deshalb wird zuerst die Domain aus
|
||||||
|
* dem `Host`-Header bestimmt. Ein unbekannter Hostname endet hier — sonst
|
||||||
|
* ließe sich über einen gefälschten Header ein fremder Code ansprechen.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -45,7 +50,25 @@ export async function GET(
|
|||||||
return notFound("Diese Kurz-Adresse ist ungültig.");
|
return notFound("Diese Kurz-Adresse ist ungültig.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = await prisma.qrCode.findUnique({ where: { slug } });
|
const matched = await resolveRequestDomain(request.headers);
|
||||||
|
|
||||||
|
// Aufruf über eine nicht registrierte Adresse — etwa direkt über die IP im
|
||||||
|
// LAN. Dann gilt die Standard-Domain aus BASE_URL.
|
||||||
|
const domain = matched ?? {
|
||||||
|
...(await ensureDefaultDomain()),
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!domain.isActive) {
|
||||||
|
return notFound(
|
||||||
|
"Diese Adresse ist zurzeit abgeschaltet. Bitte wende dich an denjenigen, der den Code erstellt hat.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await prisma.qrCode.findUnique({
|
||||||
|
where: { domainId_slug: { domainId: domain.id, slug } },
|
||||||
|
});
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return notFound(
|
return notFound(
|
||||||
"Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an denjenigen, der ihn erstellt hat.",
|
"Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an denjenigen, der ihn erstellt hat.",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { DomainAdmin } from "@/components/domain-admin";
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { requirePagePermission } from "@/lib/auth/guard";
|
||||||
|
import { baseUrlHostname } from "@/lib/domains";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Domains" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function DomainsPage() {
|
||||||
|
await requirePagePermission("instance:manage");
|
||||||
|
|
||||||
|
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" />
|
||||||
|
Verwaltung
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Domains
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Unter welchen Adressen Kurz-URLs ausgeliefert werden. Eine Domain
|
||||||
|
kann mehreren Gruppen dienen; der Slug eines Codes ist je Domain
|
||||||
|
eindeutig.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DomainAdmin baseUrlHostname={baseUrlHostname()} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,10 +45,11 @@ function Tile({
|
|||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
await requirePagePermission("instance:manage");
|
await requirePagePermission("instance:manage");
|
||||||
|
|
||||||
const [userCount, groupCount, codeCount] = await Promise.all([
|
const [userCount, groupCount, codeCount, domainCount] = await Promise.all([
|
||||||
prisma.user.count(),
|
prisma.user.count(),
|
||||||
prisma.group.count(),
|
prisma.group.count(),
|
||||||
prisma.qrCode.count(),
|
prisma.qrCode.count(),
|
||||||
|
prisma.domain.count(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,6 +96,13 @@ export default async function AdminPage() {
|
|||||||
description="Gruppen anlegen und Mitglieder mit Rollen zuordnen."
|
description="Gruppen anlegen und Mitglieder mit Rollen zuordnen."
|
||||||
hint={`${groupCount} ${groupCount === 1 ? "Gruppe" : "Gruppen"}, ${codeCount} ${codeCount === 1 ? "Code" : "Codes"}`}
|
hint={`${groupCount} ${groupCount === 1 ? "Gruppe" : "Gruppen"}, ${codeCount} ${codeCount === 1 ? "Code" : "Codes"}`}
|
||||||
/>
|
/>
|
||||||
|
<Tile
|
||||||
|
href="/verwaltung/domains"
|
||||||
|
icon="globe"
|
||||||
|
title="Domains"
|
||||||
|
description="Adressen, unter denen Kurz-URLs ausgeliefert werden."
|
||||||
|
hint={`${domainCount} ${domainCount === 1 ? "Domain" : "Domains"} registriert`}
|
||||||
|
/>
|
||||||
<Tile
|
<Tile
|
||||||
href="/verwaltung/typen"
|
href="/verwaltung/typen"
|
||||||
icon="qr"
|
icon="qr"
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { Modal } from "@/components/modal";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Field,
|
||||||
|
Notice,
|
||||||
|
TextInput,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { ApiError, apiJson } from "@/lib/api-client";
|
||||||
|
|
||||||
|
type Grant = { groupId: string; groupName: string; isDefault: boolean };
|
||||||
|
|
||||||
|
type AdminDomain = {
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
label: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
codeCount: number;
|
||||||
|
grants: Grant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupOption = { id: string; name: string };
|
||||||
|
|
||||||
|
export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||||
|
const [domains, setDomains] = useState<AdminDomain[] | null>(null);
|
||||||
|
const [groups, setGroups] = useState<GroupOption[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [form, setForm] = useState({ hostname: "", label: "" });
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<AdminDomain | null>(null);
|
||||||
|
const [grants, setGrants] = useState<Grant[]>([]);
|
||||||
|
const [grantError, setGrantError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [deleting, setDeleting] = useState<AdminDomain | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await apiJson<{
|
||||||
|
domains: AdminDomain[];
|
||||||
|
groups: GroupOption[];
|
||||||
|
}>("/api/admin/domains");
|
||||||
|
setDomains(result.domains);
|
||||||
|
setGroups(result.groups);
|
||||||
|
setError(null);
|
||||||
|
} catch (caught) {
|
||||||
|
setDomains([]);
|
||||||
|
setError(
|
||||||
|
caught instanceof ApiError
|
||||||
|
? caught.message
|
||||||
|
: "Die Domains konnten nicht geladen werden.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// setState folgt erst nach dem await der Netzwerkantwort.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
await apiJson("/api/admin/domains", { method: "POST", json: form });
|
||||||
|
setCreating(false);
|
||||||
|
setForm({ hostname: "", label: "" });
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setFormError(
|
||||||
|
caught instanceof ApiError
|
||||||
|
? caught.message
|
||||||
|
: "Die Domain konnte nicht angelegt werden.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const patch = async (id: string, body: unknown, onDone?: () => void) => {
|
||||||
|
setBusy(id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiJson(`/api/admin/domains/${id}`, { method: "PATCH", json: body });
|
||||||
|
await load();
|
||||||
|
onDone?.();
|
||||||
|
return true;
|
||||||
|
} catch (caught) {
|
||||||
|
const message =
|
||||||
|
caught instanceof ApiError
|
||||||
|
? caught.message
|
||||||
|
: "Die Änderung konnte nicht gespeichert werden.";
|
||||||
|
if (onDone) setGrantError(message);
|
||||||
|
else setError(message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!deleting) return;
|
||||||
|
try {
|
||||||
|
await apiJson(`/api/admin/domains/${deleting.id}`, { method: "DELETE" });
|
||||||
|
setDeleting(null);
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(
|
||||||
|
caught instanceof ApiError
|
||||||
|
? caught.message
|
||||||
|
: "Die Domain konnte nicht gelöscht werden.",
|
||||||
|
);
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (domains === null) {
|
||||||
|
return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||||
|
|
||||||
|
<Notice tone="info">
|
||||||
|
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
|
||||||
|
weiterleitet. Die Weiterleitung erkennt sie am{" "}
|
||||||
|
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
|
||||||
|
gegen diese Liste — ein unbekannter Hostname endet mit 404.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" onClick={() => setCreating(true)}>
|
||||||
|
Domain registrieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<Card key={domain.id}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="font-mono font-semibold text-slate-900">
|
||||||
|
{domain.hostname}
|
||||||
|
</h2>
|
||||||
|
{domain.hostname === baseUrlHostname ? (
|
||||||
|
<Badge tone="popular">aus BASE_URL</Badge>
|
||||||
|
) : null}
|
||||||
|
{!domain.isActive ? (
|
||||||
|
<Badge tone="soon">abgeschaltet</Badge>
|
||||||
|
) : null}
|
||||||
|
<Badge tone="neutral">
|
||||||
|
{domain.codeCount} {domain.codeCount === 1 ? "Code" : "Codes"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{domain.label ? (
|
||||||
|
<p className="mt-1 text-sm text-slate-600">{domain.label}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
{domain.grants.length === 0
|
||||||
|
? "Keiner Gruppe zugeteilt — niemand kann hier Codes anlegen."
|
||||||
|
: domain.grants
|
||||||
|
.map(
|
||||||
|
(grant) =>
|
||||||
|
`${grant.groupName}${grant.isDefault ? " (Voreinstellung)" : ""}`,
|
||||||
|
)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy === domain.id}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(domain);
|
||||||
|
setGrants(domain.grants);
|
||||||
|
setGrantError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="pencil" className="size-3.5" />
|
||||||
|
Gruppen zuteilen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy === domain.id}
|
||||||
|
onClick={() => void patch(domain.id, { isActive: !domain.isActive })}
|
||||||
|
>
|
||||||
|
{domain.isActive ? "Abschalten" : "Einschalten"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 hover:bg-red-50"
|
||||||
|
disabled={busy === domain.id}
|
||||||
|
onClick={() => setDeleting(domain)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" className="size-3.5" />
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* --------------------------------------------------- Registrieren */}
|
||||||
|
<Modal
|
||||||
|
open={creating}
|
||||||
|
title="Domain registrieren"
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void create()}>
|
||||||
|
Registrieren
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{formError ? <Notice tone="error">{formError}</Notice> : null}
|
||||||
|
<Field
|
||||||
|
label="Hostname"
|
||||||
|
htmlFor="new-domain-host"
|
||||||
|
hint="Ohne https:// und ohne Pfad — genau so, wie er im Browser steht."
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id="new-domain-host"
|
||||||
|
className="font-mono"
|
||||||
|
placeholder="qr.example.com"
|
||||||
|
value={form.hostname}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm({ ...form, hostname: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Bezeichnung (optional)" htmlFor="new-domain-label">
|
||||||
|
<TextInput
|
||||||
|
id="new-domain-label"
|
||||||
|
placeholder="Kundenprojekt Nord"
|
||||||
|
value={form.label}
|
||||||
|
onChange={(event) => setForm({ ...form, label: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ------------------------------------------------------- Zuteilung */}
|
||||||
|
<Modal
|
||||||
|
open={editing !== null}
|
||||||
|
title={`Gruppen für ${editing?.hostname ?? ""}`}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setEditing(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === editing?.id}
|
||||||
|
onClick={() =>
|
||||||
|
editing &&
|
||||||
|
void patch(
|
||||||
|
editing.id,
|
||||||
|
{
|
||||||
|
grants: grants.map((grant) => ({
|
||||||
|
groupId: grant.groupId,
|
||||||
|
isDefault: grant.isDefault,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
() => setEditing(null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{grantError ? <Notice tone="error">{grantError}</Notice> : null}
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Mehrere Gruppen dürfen dieselbe Domain nutzen. Die Voreinstellung
|
||||||
|
bestimmt, welche Domain beim Anlegen eines Codes vorausgewählt ist.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{groups.map((group) => {
|
||||||
|
const grant = grants.find((entry) => entry.groupId === group.id);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={group.id}
|
||||||
|
className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 p-2.5"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`grant-${group.id}`}
|
||||||
|
label={group.name}
|
||||||
|
checked={Boolean(grant)}
|
||||||
|
onChange={(event) =>
|
||||||
|
setGrants((current) =>
|
||||||
|
event.target.checked
|
||||||
|
? [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
groupId: group.id,
|
||||||
|
groupName: group.name,
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: current.filter((entry) => entry.groupId !== group.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{grant ? (
|
||||||
|
<label className="ml-auto flex items-center gap-1.5 text-xs text-slate-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="size-3.5 rounded border-slate-300 accent-indigo-600"
|
||||||
|
checked={grant.isDefault}
|
||||||
|
onChange={(event) =>
|
||||||
|
setGrants((current) =>
|
||||||
|
current.map((entry) =>
|
||||||
|
entry.groupId === group.id
|
||||||
|
? { ...entry, isDefault: event.target.checked }
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Voreinstellung
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* -------------------------------------------------------- Löschen */}
|
||||||
|
<Modal
|
||||||
|
open={deleting !== null}
|
||||||
|
title="Domain löschen?"
|
||||||
|
onClose={() => setDeleting(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setDeleting(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="danger" onClick={() => void remove()}>
|
||||||
|
Endgültig löschen
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
<strong className="font-mono text-slate-900">
|
||||||
|
{deleting.hostname}
|
||||||
|
</strong>{" "}
|
||||||
|
wird entfernt.
|
||||||
|
{deleting.codeCount > 0
|
||||||
|
? ` Unter dieser Domain liegen noch ${deleting.codeCount} ${deleting.codeCount === 1 ? "Code" : "Codes"} — das Löschen wird deshalb abgelehnt. Schalte sie stattdessen ab.`
|
||||||
|
: " Es liegen keine Codes darunter."}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-1
@@ -29,6 +29,7 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|||||||
/** Ein Code samt der Beziehungen, die für die Anzeige gebraucht werden. */
|
/** Ein Code samt der Beziehungen, die für die Anzeige gebraucht werden. */
|
||||||
export type QrCodeWithRelations = QrCode & {
|
export type QrCodeWithRelations = QrCode & {
|
||||||
group?: { name: string } | null;
|
group?: { name: string } | null;
|
||||||
|
domain?: { hostname: string } | null;
|
||||||
createdBy?: { name: string | null; email: string } | null;
|
createdBy?: { name: string | null; email: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
|||||||
type: code.type as QrTypeId,
|
type: code.type as QrTypeId,
|
||||||
label: code.label,
|
label: code.label,
|
||||||
data,
|
data,
|
||||||
shortUrl: shortUrl(code.slug),
|
shortUrl: shortUrl(code.slug, code.domain?.hostname),
|
||||||
target,
|
target,
|
||||||
summary,
|
summary,
|
||||||
design: {
|
design: {
|
||||||
@@ -73,6 +74,8 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
|||||||
updatedAt: code.updatedAt.toISOString(),
|
updatedAt: code.updatedAt.toISOString(),
|
||||||
groupId: code.groupId,
|
groupId: code.groupId,
|
||||||
groupName: code.group?.name ?? null,
|
groupName: code.group?.name ?? null,
|
||||||
|
domainId: code.domainId,
|
||||||
|
domainHostname: code.domain?.hostname ?? null,
|
||||||
createdByName: code.createdBy
|
createdByName: code.createdBy
|
||||||
? (code.createdBy.name?.trim() || code.createdBy.email)
|
? (code.createdBy.name?.trim() || code.createdBy.email)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { Actor } from "@/lib/auth/actor";
|
||||||
|
import { can, visibleGroupIds, writableGroupIds } from "@/lib/auth/policy";
|
||||||
|
import { getBaseUrl } from "@/lib/env";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domains für Kurz-URLs.
|
||||||
|
*
|
||||||
|
* Die Anwendung läuft hinter einem Reverse Proxy und kann mehrere Domains
|
||||||
|
* bedienen. Welche gemeint ist, verrät der `Host`-Header der Anfrage. Weil
|
||||||
|
* dieser Header fälschbar ist, wird er **immer** gegen diese Tabelle geprüft:
|
||||||
|
* ein unbekannter Hostname führt zu 404. Damit bringt Fälschen nichts —
|
||||||
|
* ansprechen lässt sich nur, was ohnehin registriert ist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Von der Migration angelegte Standard-Domain. */
|
||||||
|
export const DEFAULT_DOMAIN_ID = "dom_standard";
|
||||||
|
const PLACEHOLDER_HOSTNAME = "standard.invalid";
|
||||||
|
|
||||||
|
/** Normalisiert einen Hostnamen: klein, ohne Schema, ohne abschließenden Punkt. */
|
||||||
|
export function normalizeHostname(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^[a-z][a-z0-9+.-]*:\/\//, "")
|
||||||
|
.replace(/\/.*$/, "")
|
||||||
|
.replace(/\.$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hostname (ggf. mit Port) aus BASE_URL. */
|
||||||
|
export function baseUrlHostname(): string {
|
||||||
|
try {
|
||||||
|
return new URL(getBaseUrl()).host.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return "localhost:3000";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorgt dafür, dass es die Standard-Domain gibt und ihr Hostname zu BASE_URL
|
||||||
|
* passt. Die Migration kann BASE_URL nicht kennen und setzt deshalb erst
|
||||||
|
* einen Platzhalter.
|
||||||
|
*/
|
||||||
|
export async function ensureDefaultDomain(): Promise<{
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
}> {
|
||||||
|
const hostname = baseUrlHostname();
|
||||||
|
|
||||||
|
const byHostname = await prisma.domain.findUnique({
|
||||||
|
where: { hostname },
|
||||||
|
select: { id: true, hostname: true },
|
||||||
|
});
|
||||||
|
if (byHostname) return byHostname;
|
||||||
|
|
||||||
|
const standard = await prisma.domain.findUnique({
|
||||||
|
where: { id: DEFAULT_DOMAIN_ID },
|
||||||
|
select: { id: true, hostname: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nur den unveränderten Platzhalter überschreiben — hat jemand die
|
||||||
|
// Standard-Domain bewusst umbenannt, bleibt das so.
|
||||||
|
if (standard && standard.hostname === PLACEHOLDER_HOSTNAME) {
|
||||||
|
return prisma.domain.update({
|
||||||
|
where: { id: DEFAULT_DOMAIN_ID },
|
||||||
|
data: { hostname },
|
||||||
|
select: { id: true, hostname: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (standard) return standard;
|
||||||
|
|
||||||
|
return prisma.domain.create({
|
||||||
|
data: { id: DEFAULT_DOMAIN_ID, hostname, label: "Standard-Domain" },
|
||||||
|
select: { id: true, hostname: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt die Domain, unter der eine Anfrage hereinkam.
|
||||||
|
*
|
||||||
|
* `X-Forwarded-Host` hat Vorrang, weil hinter einem Reverse Proxy dort der
|
||||||
|
* ursprüngliche Hostname steht. Beide Werte werden gegen die Domain-Tabelle
|
||||||
|
* geprüft — ohne Treffer gibt es kein Ergebnis.
|
||||||
|
*/
|
||||||
|
export async function resolveRequestDomain(
|
||||||
|
headers: Headers,
|
||||||
|
): Promise<{ id: string; hostname: string; isActive: boolean } | null> {
|
||||||
|
const candidates = [
|
||||||
|
headers.get("x-forwarded-host"),
|
||||||
|
headers.get("host"),
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
// Bei mehreren Proxies steht in X-Forwarded-Host eine Liste.
|
||||||
|
.map((value) => normalizeHostname(value.split(",")[0]));
|
||||||
|
|
||||||
|
for (const hostname of candidates) {
|
||||||
|
if (!hostname) continue;
|
||||||
|
const domain = await prisma.domain.findUnique({
|
||||||
|
where: { hostname },
|
||||||
|
select: { id: true, hostname: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (domain) return domain;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DomainOption = {
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
label: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Domains, die einer Gruppe zugeteilt sind. */
|
||||||
|
export async function listDomainsForGroup(
|
||||||
|
groupId: string,
|
||||||
|
): Promise<DomainOption[]> {
|
||||||
|
const grants = await prisma.domainGrant.findMany({
|
||||||
|
where: { groupId, domain: { isActive: true } },
|
||||||
|
include: { domain: true },
|
||||||
|
orderBy: [{ isDefault: "desc" }, { domain: { hostname: "asc" } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return grants.map((grant) => ({
|
||||||
|
id: grant.domain.id,
|
||||||
|
hostname: grant.domain.hostname,
|
||||||
|
label: grant.domain.label,
|
||||||
|
isActive: grant.domain.isActive,
|
||||||
|
isDefault: grant.isDefault,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Domains, die der Actor in Auswahllisten sehen darf. */
|
||||||
|
export async function listDomainsForActor(
|
||||||
|
actor: Actor,
|
||||||
|
): Promise<DomainOption[]> {
|
||||||
|
const visible = visibleGroupIds(actor);
|
||||||
|
|
||||||
|
const domains = await prisma.domain.findMany({
|
||||||
|
where:
|
||||||
|
visible === null
|
||||||
|
? undefined
|
||||||
|
: { grants: { some: { groupId: { in: visible } } } },
|
||||||
|
orderBy: { hostname: "asc" },
|
||||||
|
include: visible === null ? undefined : { grants: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return domains.map((domain) => ({
|
||||||
|
id: domain.id,
|
||||||
|
hostname: domain.hostname,
|
||||||
|
label: domain.label,
|
||||||
|
isActive: domain.isActive,
|
||||||
|
isDefault: domain.id === DEFAULT_DOMAIN_ID,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestimmt die Domain für einen neuen Code und prüft, dass die Gruppe sie
|
||||||
|
* überhaupt nutzen darf.
|
||||||
|
*/
|
||||||
|
export async function resolveTargetDomain(
|
||||||
|
actor: Actor,
|
||||||
|
groupId: string,
|
||||||
|
requestedDomainId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
// Muss vor jeder Verwendung laufen: die Migration legt die Standard-Domain
|
||||||
|
// mit einem Platzhalter-Hostnamen an, weil sie BASE_URL nicht kennt. Ohne
|
||||||
|
// diesen Aufruf entstünden auf einer frischen Installation Kurz-URLs auf
|
||||||
|
// "standard.invalid" — also gedruckte Codes, die ins Leere führen.
|
||||||
|
await ensureDefaultDomain();
|
||||||
|
|
||||||
|
const allowed = await listDomainsForGroup(groupId);
|
||||||
|
|
||||||
|
if (requestedDomainId) {
|
||||||
|
const match = allowed.find((entry) => entry.id === requestedDomainId);
|
||||||
|
if (!match) {
|
||||||
|
// Instanz-Administratoren dürfen jede aktive Domain verwenden.
|
||||||
|
if (can(actor, "instance:manage")) {
|
||||||
|
const domain = await prisma.domain.findUnique({
|
||||||
|
where: { id: requestedDomainId },
|
||||||
|
select: { id: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (domain?.isActive) return domain.id;
|
||||||
|
}
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Diese Domain steht der gewählten Gruppe nicht zur Verfügung.",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferred = allowed.find((entry) => entry.isDefault) ?? allowed[0];
|
||||||
|
if (preferred) return preferred.id;
|
||||||
|
|
||||||
|
// Der Gruppe wurde noch keine Domain zugeteilt — die Standard-Domain
|
||||||
|
// springt ein, damit das Anlegen nicht ohne Not scheitert.
|
||||||
|
const fallback = await ensureDefaultDomain();
|
||||||
|
return fallback.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gruppen, in denen der Actor schreiben darf — für die Auswahl im Generator. */
|
||||||
|
export async function writableGroupsWithDomains(actor: Actor): Promise<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
domains: DomainOption[];
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const writable = writableGroupIds(actor);
|
||||||
|
|
||||||
|
const groups = await prisma.group.findMany({
|
||||||
|
where: writable === null ? undefined : { id: { in: writable } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
groups.map(async (group) => ({
|
||||||
|
...group,
|
||||||
|
domains: await listDomainsForGroup(group.id),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ export type QrCodeDto = {
|
|||||||
/** Gruppe, der der Code gehört. */
|
/** Gruppe, der der Code gehört. */
|
||||||
groupId: string;
|
groupId: string;
|
||||||
groupName: string | null;
|
groupName: string | null;
|
||||||
|
domainId: string;
|
||||||
|
domainHostname: string | null;
|
||||||
/** Wer ihn angelegt hat — leer bei Codes aus der Zeit ohne Anmeldung. */
|
/** Wer ihn angelegt hat — leer bei Codes aus der Zeit ohne Anmeldung. */
|
||||||
createdByName: string | null;
|
createdByName: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
+18
-3
@@ -15,9 +15,24 @@ export function getBaseUrl(): string {
|
|||||||
return raw.replace(/\/+$/, "");
|
return raw.replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kurz-URL eines dynamischen Codes. */
|
/**
|
||||||
export function shortUrl(slug: string): string {
|
* Kurz-URL eines dynamischen Codes.
|
||||||
return `${getBaseUrl()}/r/${slug}`;
|
*
|
||||||
|
* Ohne Hostname gilt BASE_URL. Mit Hostname — dem der Code zugeordnet ist —
|
||||||
|
* wird dessen Domain verwendet; das Schema (http/https) kommt weiter aus
|
||||||
|
* BASE_URL, weil die Anwendung selbst nicht weiß, ob ein Proxy davor TLS
|
||||||
|
* beendet.
|
||||||
|
*/
|
||||||
|
export function shortUrl(slug: string, hostname?: string): string {
|
||||||
|
if (!hostname) return `${getBaseUrl()}/r/${slug}`;
|
||||||
|
|
||||||
|
let scheme = "http";
|
||||||
|
try {
|
||||||
|
scheme = new URL(getBaseUrl()).protocol.replace(":", "");
|
||||||
|
} catch {
|
||||||
|
// Unbrauchbare BASE_URL — http ist die verträglichere Annahme.
|
||||||
|
}
|
||||||
|
return `${scheme}://${hostname}/r/${slug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDatabaseUrl(): string {
|
export function getDatabaseUrl(): string {
|
||||||
|
|||||||
+18
-1
@@ -2,6 +2,7 @@ import "server-only";
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import { requireAvailableQrType } from "@/lib/qr/availability";
|
import { requireAvailableQrType } from "@/lib/qr/availability";
|
||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,8 @@ export const qrRequestSchema = z.object({
|
|||||||
mode: qrModeSchema.default("static"),
|
mode: qrModeSchema.default("static"),
|
||||||
/** Slug eines bereits gespeicherten dynamischen Codes. */
|
/** Slug eines bereits gespeicherten dynamischen Codes. */
|
||||||
slug: z.string().trim().optional(),
|
slug: z.string().trim().optional(),
|
||||||
|
/** Domain, unter der die Kurz-URL entsteht. */
|
||||||
|
domainId: z.string().trim().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type QrRequest = z.infer<typeof qrRequestSchema>;
|
export type QrRequest = z.infer<typeof qrRequestSchema>;
|
||||||
@@ -97,10 +100,24 @@ export async function resolveQrRequest(
|
|||||||
if (slug !== undefined && !isValidSlug(slug)) {
|
if (slug !== undefined && !isValidSlug(slug)) {
|
||||||
throw new QrRequestError("Ungültige Kurz-URL.");
|
throw new QrRequestError("Ungültige Kurz-URL.");
|
||||||
}
|
}
|
||||||
|
// Der Hostname kommt aus der Datenbank, nicht aus der Anfrage: sonst
|
||||||
|
// ließe sich eine beliebige Fremddomain in einen Code kodieren.
|
||||||
|
let hostname: string | undefined;
|
||||||
|
if (request.domainId) {
|
||||||
|
const domain = await prisma.domain.findUnique({
|
||||||
|
where: { id: request.domainId },
|
||||||
|
select: { hostname: true },
|
||||||
|
});
|
||||||
|
if (!domain) {
|
||||||
|
throw new QrRequestError("Diese Domain gibt es nicht.", 404);
|
||||||
|
}
|
||||||
|
hostname = domain.hostname;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
qrType,
|
qrType,
|
||||||
data: validated.data,
|
data: validated.data,
|
||||||
content: shortUrl(slug ?? PREVIEW_SLUG),
|
content: shortUrl(slug ?? PREVIEW_SLUG, hostname),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user