fix: harden booking and meeting flows

This commit is contained in:
2026-09-17 02:42:27 +02:00
parent cb30ecade9
commit 66710fc65c
42 changed files with 683 additions and 608 deletions
+98
View File
@@ -0,0 +1,98 @@
# Security-Audit — 17.09.2026
Geprüfter Commit: `cb30eca`. Ergebnis: **ein Befund hoher Priorität (P1), fünf Befunde mittlerer Priorität (P2)** sowie Hinweise zu Abhängigkeiten. Keine Behebung vorgenommen.
## Umfang und Nachweisgrenzen
Quellcodeprüfung von Payload-Zugriffsregeln, Kunden-/Admin-Authentifizierung, Buchungen, Meetings, Analytics, Kalender-Feed, E-Mail-Versand und Deployment-Konfiguration. Die installierten Next.js-Guides zu Datensicherheit und Route Handlers sowie Payloads tatsächliche Local-API-Implementierung wurden herangezogen.
`npm test`: 5/5 Tests bestanden. Zusätzlich wurden die unveränderten Route Handler für Stornierung, Meeting-Beitritt, Token-Austausch und Buchungsanfragen isoliert ausgeführt, mit simulierter Datenbank und simuliertem LiveKit. Keine Produktivzugriffe, echten E-Mails oder Datenänderungen. Diese Prüfungen belegen die Handler-Logik, nicht das Verhalten eines realen Reverse-Proxys oder Video-Servers. Produktionskonfiguration, Git-Historie und laufende Infrastruktur waren nicht Gegenstand eines Penetrationstests.
## 1. P1 — Interne Buchungsnotizen gelangen in den Kundenbrowser
**Stellen:** `lib/booking/queries.ts:12`, `app/(frontend)/konto/termine/page.tsx:27`, `app/(frontend)/konto/page.tsx:46`, `collections/BookingRequests.ts:364`.
`getCustomerBookings()` liest vollständige Buchungsdokumente mit `depth: 2` über die Payload Local API. Ohne explizites `overrideAccess: false` umgeht diese sowohl Collection- als auch Feldzugriffsregeln. Das wurde in `node_modules/payload/dist/collections/operations/local/find.js` und `node_modules/payload/dist/fields/hooks/afterRead/promise.js` verifiziert.
Die Kontoseiten übergeben das gesamte Ergebnis als `booking` an die Client Component `BookingCard`. Dadurch wird auch `internalNote` an den Browser serialisiert, obwohl es durch `isAdminFieldLevel` geschützt und als „Nur für Anna sichtbar“ bezeichnet ist. Dass die Komponente die Notiz nicht sichtbar rendert, schützt sie nicht.
**Auswirkung:** Ein angemeldeter Kunde kann interne Notizen zu seinen Buchungen in der Seiten-/RSC-Antwort lesen. Kein Zugriff auf fremde Buchungen wurde hier festgestellt.
**Nachweis:** Statisch vollständiger Datenfluss von privilegierter Abfrage bis zur Client-Grenze; kein Live-Abruf realer Kundendaten.
**Behebung:** Kundenansichten nur explizit ausgewählte Felder als separates DTO übergeben. Wenn möglich zusätzlich mit `overrideAccess: false` und authentifiziertem Benutzer lesen. Privilegiert benötigte Meeting-Daten gesondert und minimal auflösen. Auch verschachtelte Beziehungen nicht vollständig serialisieren.
**Regressionstest:** Eine Buchung mit einer eindeutigen internen Testnotiz anlegen; die Notiz darf weder in HTML noch in einer RSC-Antwort des Kundenkontos vorkommen.
## 2. P2 — Stornierung widerruft den privaten Meeting-Zugang nicht
**Stellen:** `app/(payload)/api/booking/[id]/cancel/route.ts:29`, `app/(payload)/api/meetings/[slug]/join/route.ts:49`, `collections/BookingRequests.ts:229`.
Die Stornierung setzt nur den Buchungsstatus. Der verknüpfte Online-Event und sein Passwort bleiben gültig. Der Join-Endpunkt prüft Online-Status, Passwort und Zeitfenster, aber nicht den Status der zugehörigen privaten Buchung. Der E-Mail-Hook bei Stornierung sperrt den Event ebenfalls nicht.
**Auswirkung:** Wer das zuvor erhaltene Passwort kennt, kann innerhalb des ursprünglichen Zeitfensters auch nach der Stornierung neue Meeting-Zugangsdaten erhalten.
**Nachweis:** Isolierter Aufruf des Cancel-Handlers änderte die Buchung auf `cancelled`; anschließender anonymer Join mit dem alten Passwort lieferte HTTP 200 und Zugangsdaten.
**Behebung:** Für private Events bei jedem Join und Connect eine weiterhin bestätigte, passend verknüpfte Buchung verlangen. Bei Stornierung bestehende Sitzungen widerrufen; gegebenenfalls aktive Teilnehmer über LiveKit entfernen.
## 3. P2 — Token-Austausch umgeht das Meeting-Ende
**Stelle:** `app/(payload)/api/meetings/[slug]/connect/route.ts:27`.
`/connect` prüft das App-Token und die Kick-Liste, anschließend nur noch `isOnline`. Anders als `/join` prüft der Handler weder das aktuelle Meeting-Zeitfenster noch einen Widerruf durch Passwortwechsel. App-Tokens sind standardmäßig sechs Stunden gültig; neu ausgestellte LiveKit-Tokens gelten jeweils zwei Stunden.
**Auswirkung:** Ein zuvor zugelassener Teilnehmer erhält nach Ende des erlaubten Beitrittsfensters weiterhin neue Video-Zugangsdaten, solange sein App-Token gültig ist. Ein Passwortwechsel allein invalidiert bereits ausgestellte App-Tokens ebenfalls nicht.
**Nachweis:** Mit gültigem App-Token und einem simulierten, bereits am Vortag beendeten Event lieferte der unveränderte Connect-Handler HTTP 200 mit einem neuen Video-Token.
**Behebung:** Dieselbe serverseitige Zugangsprüfung für Join und Connect verwenden. Token-Laufzeiten am erlaubten Meeting-Ende begrenzen und einen Sitzungs-/Berechtigungsstand für Widerrufe prüfen. Die beabsichtigte Admin-Testzimmer-Ausnahme separat behandeln.
## 4. P2 — Krankheits- und Urlaubsgründe sind öffentlich abrufbar
**Stelle:** `collections/AvailabilityOverrides.ts:17`.
Die Collection erlaubt anonymes Lesen aller Dokumente. Das Feld `reason` enthält unter anderem `krankheit` und `urlaub` und besitzt keinen eigenen Leseschutz. Die generische Payload-API unter `/api/availability-overrides` macht damit Datum und Abwesenheitsgrund öffentlich; gleiches gilt grundsätzlich für GraphQL.
**Auswirkung:** Persönliche Abwesenheitsgründe lassen sich ohne Anmeldung abfragen, soweit entsprechende Einträge gespeichert sind. Zur Buchung reicht die Information, welche Slots verfügbar sind.
**Nachweis:** Statisch anhand von Collection-Zugriff, Felddefinition und generischem REST-Router.
**Behebung:** Collection-Lesen auf Admins beschränken oder mindestens den Grund schützen. Der bestehende serverseitige Slot-Endpunkt kann weiterhin ausschließlich verfügbare Zeiten herausgeben.
## 5. P2 — Registrierung und E-Mail-Wechsel umgehen die Versandlimits
**Stellen:** `collections/Customers.ts:84`, `collections/Customers.ts:46`.
Die öffentliche Kundenerstellung löst unmittelbar eine Bestätigungs-E-Mail aus. Änderungen der eigenen E-Mail-Adresse lösen denselben Versand erneut aus. Für diese Operationen gibt es keine entsprechenden Limits in der Collection. Die Limits des separaten `/api/auth/resend-verification`-Handlers greifen hier nicht; `maxLoginAttempts` schützt nur Login-Versuche.
**Auswirkung:** Ohne bestätigte Adresse lassen sich neue Konten für verschiedene Empfänger erzeugen beziehungsweise eigene E-Mail-Adressen wiederholt ändern und damit Bestätigungsmails auslösen. Das ermöglicht Versandmissbrauch, Datenbankwachstum und Belastung des SMTP-Kontos. Ein externes Proxy-Limit könnte die Häufigkeit mindern, ist im geprüften Repository aber nicht als Schutz für diese Vorgänge implementiert.
**Nachweis:** Statisch anhand der öffentlichen Collection-Operationen und Versand-Hooks. Kein Versandtest durchgeführt.
**Behebung:** Erstellung und E-Mail-Änderung vor dem Versand zentral pro IP, Konto und Zieladresse begrenzen. Regeln auf Collection-/Hook-Ebene oder einem exklusiven, geschützten Einstiegspunkt durchsetzen, sodass REST und GraphQL sie nicht umgehen können.
## 6. P2 — Ein bestätigtes Konto kann beliebig viele identische Buchungsanfragen erzeugen
**Stellen:** `app/(payload)/api/booking/request/route.ts:75`, `collections/BookingRequests.ts:163`.
Der Handler prüft Anmeldung, E-Mail-Bestätigung und Slot-Verfügbarkeit, aber weder eine Rate-Limit-Quote noch eine schon bestehende identische offene Anfrage. Offene Anfragen belegen den Slot nicht. Jeder erfolgreiche Create löst Bestätigungs- und Admin-Benachrichtigungen aus.
**Auswirkung:** Ein einziges verifiziertes Konto kann den Admin-Eingang, die Buchungsliste und den Mailversand mit Duplikaten fluten.
**Nachweis:** 25 unmittelbar aufeinanderfolgende identische Anfragen wurden vom unveränderten Handler mit HTTP 200 angenommen und an die simulierte Create-Operation weitergegeben. Keine echten E-Mails verschickt.
**Behebung:** Pro Kunde begrenzen und identische offene Anfragen atomar deduplizieren. Wiederholte identische Requests sollten keinen weiteren Datensatz und keinen erneuten Mailversand erzeugen.
## Abhängigkeiten und weitere Prüfergebnisse
`npm audit --json` meldete im Lockfile **7 betroffene Pakete: 6 moderate, 1 low, 0 high, 0 critical**. Die Zahl enthält transitive Abhängigkeitsketten und bedeutet nicht sieben unabhängig ausnutzbare Anwendungslücken.
- **esbuild 0.18.20** ist über `@payloadcms/db-postgres → drizzle-kit → @esbuild-kit/esm-loader → @esbuild-kit/core-utils` installiert. Das [Advisory GHSA-67mh-4wv8-2f99](https://github.com/advisories/GHSA-67mh-4wv8-2f99) betrifft esbuilds Entwicklungs-Webserver. Im geprüften App-Startpfad wurde dieser Server nicht gefunden; daraus wird keine bestätigte Produktionslücke abgeleitet. npm meldet für diese Kette keinen verfügbaren automatischen Fix.
- **DOMPurify/Monaco:** Das Lockfile-Audit meldete mehrere DOMPurify-Advisories, darunter [GHSA-55q2-fjhq-7xh7](https://github.com/advisories/GHSA-55q2-fjhq-7xh7), mit verfügbarem Fix. Dieses Advisory setzt spezielle `IN_PLACE`-/Hook-Nutzung voraus. `npm ls dompurify monaco-editor --all` zeigte beide Pakete im aktuellen Installationsbaum nicht an. Produktionsinstallation aus dem Lockfile und tatsächliche Nutzung müssen vor einer Aussage zur Ausnutzbarkeit abgeglichen werden; ein XSS-Exploit wurde nicht nachgewiesen.
- Ein begrenzter Musterscan der aktuell versionierten Dateien fand keine privaten Schlüsselblöcke oder GitHub-/AWS-Tokenmuster. Das ist kein vollständiger Secret- oder Historien-Scan.
- Positiv: Adminrechte verlangen Auth-Collection und Rolle; Buchungsaktionen prüfen den Eigentümer; Verifizierungs-/Kalender-Tokens werden gehasht gespeichert; öffentliche Event-Felder geben das Meeting-Passwort nicht über die reguläre Payload-API frei; Cron-Aufrufe prüfen ein Secret.
## Reihenfolge der Behebung
Zuerst die Weitergabe interner Notizen stoppen. Danach private Meeting-Zugänge und Token-Austausch an den aktuellen Berechtigungsstatus binden, Abwesenheitsgründe schützen und die beiden Versand-/Buchungsmissbrauchspfade begrenzen. Abhängigkeitsupdates separat prüfen und mit dem tatsächlich ausgelieferten Installationsbaum abgleichen.
+2 -2
View File
@@ -15,8 +15,8 @@ export default async function KontoOverviewPage() {
const bookings = await getCustomerBookings(customer.id); const bookings = await getCustomerBookings(customer.id);
const meetingSettings = await getMeetingSettingsGlobal(); const meetingSettings = await getMeetingSettingsGlobal();
const pending = bookings.filter((b) => b.status === "pending"); const pending = bookings.filter((b) => b.status === "pending");
const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.date) >= new Date()); const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.endTime) >= new Date());
const past = bookings.filter((b) => new Date(b.date) < new Date() || b.status === "rejected" || b.status === "cancelled"); const past = bookings.filter((b) => new Date(b.endTime) < new Date() || b.status === "rejected" || b.status === "cancelled");
return ( return (
<div className="space-y-10"> <div className="space-y-10">
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload"; import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token"; import { createMeetingToken } from "@/lib/meeting/token";
import { authorizeMeeting } from "@/lib/meeting/access";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,6 +13,7 @@ export async function POST(request: Request) {
let body: Body; let body: Body;
try { try {
body = await request.json(); body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch { } catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
} }
@@ -36,10 +38,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Dieser Termin ist kein Online-Termin." }, { status: 404 }); return NextResponse.json({ error: "Dieser Termin ist kein Online-Termin." }, { status: 404 });
} }
const access = await authorizeMeeting(payload, event, "host");
if (!access) return NextResponse.json({ error: "Dieser Termin ist nicht offen oder wurde storniert." }, { status: 403 });
const { token } = createMeetingToken({ const { token } = createMeetingToken({
eventSlug, eventSlug,
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host", name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
role: "host", role: "host",
accessVersion: access.accessVersion,
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
}); });
// Keep the token in the URL fragment: browsers do not send fragments to // Keep the token in the URL fragment: browsers do not send fragments to
@@ -20,6 +20,7 @@ export async function POST(request: Request) {
eventSlug, eventSlug,
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host", name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
role: "host", role: "host",
testRoom: true,
ttlMs: 60 * 60 * 1000, ttlMs: 60 * 60 * 1000,
}); });
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin; const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
@@ -53,6 +53,7 @@ export async function POST(request: Request) {
try { try {
const raw = await request.text(); const raw = await request.text();
body = JSON.parse(raw); body = JSON.parse(raw);
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch { } catch {
return new NextResponse(null, { status: 204 }); return new NextResponse(null, { status: 204 });
} }
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload"; import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit"; import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { allowVerificationEmail } from "@/lib/auth/verificationRateLimit";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification"; import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates"; import { verificationEmail } from "@/lib/email/authTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails"; import { sendEmail } from "@/lib/email/sendBookingEmails";
@@ -57,11 +58,12 @@ export async function POST(request: Request) {
let body: { email?: unknown } = {}; let body: { email?: unknown } = {};
try { try {
body = await request.json(); body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch { } catch {
// no body — handled below as a missing email // no body — handled below as a missing email
} }
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email) { if (!email || email.length > 254) {
return NextResponse.json({ error: "E-Mail-Adresse erforderlich." }, { status: 400 }); return NextResponse.json({ error: "E-Mail-Adresse erforderlich." }, { status: 400 });
} }
if (!checkRateLimit(`resend-verification:email:${email}`, { max: 3, windowMs: 60 * 60 * 1000 })) { if (!checkRateLimit(`resend-verification:email:${email}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
@@ -77,8 +79,8 @@ export async function POST(request: Request) {
} }
if (customer && !customer.emailVerified) { if (customer && !customer.emailVerified) {
if (!checkRateLimit(`resend-verification:customer:${customer.id}`, { max: 3, windowMs: 60 * 60 * 1000 })) { if (!allowVerificationEmail(request, customer.email, customer.id)) {
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 }); return NextResponse.json(GENERIC_RESPONSE);
} }
await issueAndSend(payload, customer); await issueAndSend(payload, customer);
} }
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getPayload } from "payload"; import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import { combineDateAndTime } from "@/lib/meeting/status";
import { withBookingTransaction } from "@/lib/booking/transaction";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -19,31 +21,40 @@ export async function POST(request: Request, { params }: Args) {
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 }); return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
} }
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null); return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
if (!booking || Number(booking.user) !== Number(user.id)) { const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req });
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 }); const booking = docs[0];
} if (!booking || Number(booking.user) !== Number(user.id)) {
const alternative = booking.proposedAlternative; return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) { }
return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 }); const alternative = booking.proposedAlternative;
} if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) {
return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 });
}
// The beforeChange hook on booking-requests re-checks for conflicts const alternativeStart = combineDateAndTime(alternative.date, alternative.startTime);
// (with an advisory lock) before allowing this to become "confirmed" — if (!Number.isFinite(alternativeStart.getTime()) || alternativeStart <= new Date()) {
// the alternative slot may have been taken by someone else since it was return NextResponse.json({ error: "Dieser Terminvorschlag liegt bereits in der Vergangenheit." }, { status: 409 });
// proposed. }
await payload.update({
collection: "booking-requests", // The beforeChange hook on booking-requests re-checks for conflicts
id, // (with an advisory lock) before allowing this to become "confirmed" —
data: { // the alternative slot may have been taken by someone else since it was
status: "confirmed", // proposed.
date: alternative.date, await payload.update({
startTime: alternative.startTime, collection: "booking-requests",
endTime: alternative.endTime, id,
}, req,
data: {
status: "confirmed",
date: alternative.date,
startTime: alternative.startTime,
endTime: alternative.endTime,
},
});
return NextResponse.json({ ok: true });
}); });
return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) { if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) {
return NextResponse.json({ error: error.message }, { status: 409 }); return NextResponse.json({ error: error.message }, { status: 409 });
+13 -9
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getPayload } from "payload"; import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import { withBookingTransaction } from "@/lib/booking/transaction";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -18,16 +19,19 @@ export async function POST(request: Request, { params }: Args) {
// Cancelling is deliberately allowed regardless of verification status — // Cancelling is deliberately allowed regardless of verification status —
// only creating/confirming a booking is a gated "protected" action. // only creating/confirming a booking is a gated "protected" action.
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null); return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
if (!booking || Number(booking.user) !== Number(user.id)) { const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req });
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 }); const booking = docs[0];
} if (!booking || Number(booking.user) !== Number(user.id)) {
if (booking.status === "cancelled") { return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
return NextResponse.json({ ok: true }); }
} if (booking.status === "cancelled") {
return NextResponse.json({ ok: true });
}
await payload.update({ collection: "booking-requests", id, data: { status: "cancelled" } }); await payload.update({ collection: "booking-requests", id, req, data: { status: "cancelled" } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
});
} catch (error) { } catch (error) {
console.error("booking cancel failed", error); console.error("booking cancel failed", error);
return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 }); return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 });
+46 -25
View File
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload"; import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots"; import { getAvailableSlots } from "@/lib/booking/slots";
import { withBookingTransaction } from "@/lib/booking/transaction";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { berlinDayBounds } from "@/lib/calendar/timezone"; import { berlinDayBounds } from "@/lib/calendar/timezone";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,13 +19,15 @@ export async function POST(request: Request) {
let body: Body; let body: Body;
try { try {
body = await request.json(); body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch { } catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
} }
const offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : ""; const offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : "";
const startISO = typeof body.start === "string" ? body.start : ""; const startISO = typeof body.start === "string" ? body.start : "";
const appointmentType = body.appointmentType === "online" ? "online" : "onsite"; const appointmentType = body.appointmentType;
if (appointmentType !== "online" && appointmentType !== "onsite") return NextResponse.json({ error: "Ungültige Terminart." }, { status: 400 });
const message = typeof body.message === "string" ? body.message.trim().slice(0, 2000) : ""; const message = typeof body.message === "string" ? body.message.trim().slice(0, 2000) : "";
if (!offerSlug || !startISO) { if (!offerSlug || !startISO) {
@@ -40,6 +44,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 }); return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
} }
if (!checkRateLimit(`booking:ip:${getClientIp(request)}`, { max: 30, windowMs: 3600000 }) ||
!checkRateLimit(`booking:customer:${user.id}`, { max: 10, windowMs: 3600000 })) {
return NextResponse.json({ error: "Zu viele Terminanfragen. Bitte versuche es später erneut." }, { status: 429 });
}
const { docs } = await payload.find({ const { docs } = await payload.find({
collection: "offers", collection: "offers",
where: { slug: { equals: offerSlug } }, where: { slug: { equals: offerSlug } },
@@ -61,32 +70,44 @@ export async function POST(request: Request) {
// stale slot list) — the hard, race-safe check still runs again when // stale slot list) — the hard, race-safe check still runs again when
// Anna confirms (see collections/BookingRequests.ts). // Anna confirms (see collections/BookingRequests.ts).
const dayBounds = berlinDayBounds(start); const dayBounds = berlinDayBounds(start);
const slotsByDay = await getAvailableSlots(payload, { return await withBookingTransaction(payload, dayBounds.key, async (req) => {
durationMinutes: offer.durationMinutes, const { docs: existing } = await payload.find({ collection: "booking-requests", req, depth: 0, limit: 1,
from: dayBounds.start, where: { and: [
to: dayBounds.end, { user: { equals: user.id } }, { offer: { equals: offer.id } },
}); { startTime: { equals: start.toISOString() } }, { appointmentType: { equals: appointmentType } },
const key = dayBounds.key; { status: { in: ["pending", "confirmed"] } },
const isStillFree = (slotsByDay.get(key) ?? []).some((s) => s.start.getTime() === start.getTime()); ] },
if (!isStillFree) { });
return NextResponse.json({ error: "Dieser Termin ist leider nicht mehr verfügbar." }, { status: 409 }); if (existing[0]) return NextResponse.json({ ok: true });
} const slotsByDay = await getAvailableSlots(payload, {
durationMinutes: offer.durationMinutes!,
req,
from: dayBounds.start,
to: dayBounds.end,
});
const key = dayBounds.key;
const isStillFree = (slotsByDay.get(key) ?? []).some((s) => s.start.getTime() === start.getTime());
if (!isStillFree) {
return NextResponse.json({ error: "Dieser Termin ist leider nicht mehr verfügbar." }, { status: 409 });
}
await payload.create({ await payload.create({
collection: "booking-requests", collection: "booking-requests",
data: { req,
user: user.id, data: {
offer: offer.id, user: user.id,
date: start.toISOString(), offer: offer.id,
startTime: start.toISOString(), date: start.toISOString(),
endTime: end.toISOString(), startTime: start.toISOString(),
appointmentType, endTime: end.toISOString(),
status: "pending", appointmentType,
userMessage: message || undefined, status: "pending",
}, userMessage: message || undefined,
}); },
});
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
});
} catch (error) { } catch (error) {
console.error("booking request failed", error); console.error("booking request failed", error);
return NextResponse.json({ error: "Die Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 }); return NextResponse.json({ error: "Die Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
+6 -6
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload"; import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots"; import { getAvailableSlots } from "@/lib/booking/slots";
import { berlinDayBounds, parseBerlinDate } from "@/lib/calendar/timezone";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -29,16 +30,15 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 }); return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
} }
const from = fromParam ? new Date(`${fromParam}T00:00:00`) : new Date(); const from = fromParam ? parseBerlinDate(fromParam) : berlinDayBounds(new Date()).start;
if (Number.isNaN(from.getTime())) { if (!from) {
return NextResponse.json({ error: "Ungültiges Startdatum." }, { status: 400 }); return NextResponse.json({ error: "Ungültiges Startdatum." }, { status: 400 });
} }
from.setHours(0, 0, 0, 0); const toDate = toParam ? parseBerlinDate(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
const to = toParam ? new Date(`${toParam}T00:00:00`) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000); if (!toDate) {
if (Number.isNaN(to.getTime())) {
return NextResponse.json({ error: "Ungültiges Enddatum." }, { status: 400 }); return NextResponse.json({ error: "Ungültiges Enddatum." }, { status: 400 });
} }
to.setHours(23, 59, 59, 999); const to = berlinDayBounds(toDate).end;
if (to < from) { if (to < from) {
return NextResponse.json({ error: "Das Enddatum muss nach dem Startdatum liegen." }, { status: 400 }); return NextResponse.json({ error: "Das Enddatum muss nach dem Startdatum liegen." }, { status: 400 });
@@ -4,6 +4,7 @@ import config from "@payload-config";
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit"; import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks"; import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
import { verifyMeetingToken } from "@/lib/meeting/token"; import { verifyMeetingToken } from "@/lib/meeting/token";
import { authorizeMeeting } from "@/lib/meeting/access";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -23,11 +24,17 @@ export async function POST(request: Request, { params }: Args) {
} }
try { try {
const isAdminTestRoom = meetingToken.role === "host" && slug.startsWith("test-"); let expiresAt = meetingToken.exp;
const isAdminTestRoom = meetingToken.role === "host" && meetingToken.testRoom === true && slug.startsWith("test-");
if (!isAdminTestRoom) { if (!isAdminTestRoom) {
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { docs } = await payload.find({ collection: "events", where: { slug: { equals: slug } }, limit: 1 }); const { docs } = await payload.find({ collection: "events", where: { slug: { equals: slug } }, limit: 1 });
if (!docs[0]?.isOnline) return NextResponse.json({ error: "Dieser Online-Termin wurde nicht gefunden." }, { status: 404 }); if (!docs[0]?.isOnline) return NextResponse.json({ error: "Dieser Online-Termin wurde nicht gefunden." }, { status: 404 });
const access = await authorizeMeeting(payload, docs[0], meetingToken.role);
if (!access || access.accessVersion !== meetingToken.accessVersion) {
return NextResponse.json({ error: "Dieser Meeting-Zugang ist nicht mehr gültig. Bitte tritt erneut bei." }, { status: 403 });
}
expiresAt = Math.min(expiresAt, access.expiresAt);
} }
const participantToken = await createLiveKitToken({ const participantToken = await createLiveKitToken({
@@ -36,6 +43,8 @@ export async function POST(request: Request, { params }: Args) {
name: meetingToken.name, name: meetingToken.name,
role: meetingToken.role, role: meetingToken.role,
clientId: meetingToken.clientId, clientId: meetingToken.clientId,
accessVersion: meetingToken.accessVersion,
expiresAt,
}); });
return NextResponse.json({ serverUrl: getLiveKitClientUrl(), participantToken }); return NextResponse.json({ serverUrl: getLiveKitClientUrl(), participantToken });
} catch (error) { } catch (error) {
+13 -20
View File
@@ -3,7 +3,8 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload"; import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token"; import { createMeetingToken } from "@/lib/meeting/token";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status"; import { authorizeMeeting } from "@/lib/meeting/access";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit"; import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks"; import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
@@ -20,10 +21,14 @@ type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) { export async function POST(request: Request, { params }: Args) {
const { slug } = await params; const { slug } = await params;
if (!checkRateLimit(`meeting-join:${getClientIp(request)}`, { max: 30, windowMs: 60000 })) {
return NextResponse.json({ error: "Zu viele Beitrittsversuche." }, { status: 429 });
}
let body: { name?: unknown; password?: unknown; clientId?: unknown }; let body: { name?: unknown; password?: unknown; clientId?: unknown };
try { try {
body = await request.json(); body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch { } catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
} }
@@ -65,31 +70,17 @@ export async function POST(request: Request, { params }: Args) {
} }
} }
const settings = await payload.findGlobal({ slug: "meeting-settings" }); const access = await authorizeMeeting(payload, event, isHost ? "host" : "participant");
const start = combineDateAndTime(event.date, event.startTime); if (!access) {
const end = event.endTime return NextResponse.json({ error: "Dieser Termin ist nicht offen oder wurde storniert." }, { status: 403 });
? combineDateAndTime(event.date, event.endTime)
: new Date(start.getTime() + 60 * 60_000);
const status = getMeetingStatus({
start,
end,
joinWindowMinutes: isHost ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
closeAfterMinutes: settings.meetingCloseAfterMinutes,
});
if (!canJoinMeeting(status)) {
const message =
status === "scheduled"
? "Dieser Termin ist noch nicht offen. Bitte versuche es näher am Beginn erneut."
: "Dieser Termin ist bereits beendet.";
return NextResponse.json({ error: message }, { status: 403 });
} }
const { token, participantId } = createMeetingToken({ const { token, participantId } = createMeetingToken({
eventSlug: slug, eventSlug: slug,
name, name,
role: isHost ? "host" : "participant", role: isHost ? "host" : "participant",
accessVersion: access.accessVersion,
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
// Only meaningful for participants — a host authenticates via a real // Only meaningful for participants — a host authenticates via a real
// session already, so there's no need to anchor a kick to it (and // session already, so there's no need to anchor a kick to it (and
// hosts can't be kicked in the first place. // hosts can't be kicked in the first place.
@@ -101,6 +92,8 @@ export async function POST(request: Request, { params }: Args) {
} }
const participantToken = await createLiveKitToken({ const participantToken = await createLiveKitToken({
eventSlug: slug, eventSlug: slug,
accessVersion: access.accessVersion,
expiresAt: access.expiresAt,
participantId, participantId,
name, name,
role: isHost ? "host" : "participant", role: isHost ? "host" : "participant",
@@ -19,6 +19,7 @@ export async function POST(request: Request, { params }: Args) {
let body: { participantId?: unknown }; let body: { participantId?: unknown };
try { try {
body = await request.json(); body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch { } catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
} }
@@ -29,7 +30,7 @@ export async function POST(request: Request, { params }: Args) {
try { try {
const roomService = getLiveKitRoomService(); const roomService = getLiveKitRoomService();
const room = getLiveKitRoomName(slug); const room = getLiveKitRoomName(slug, host.accessVersion);
const participant = (await roomService.listParticipants(room)).find((item) => item.identity === participantId); const participant = (await roomService.listParticipants(room)).find((item) => item.identity === participantId);
if (!participant) return NextResponse.json({ error: "Teilnehmer nicht gefunden." }, { status: 404 }); if (!participant) return NextResponse.json({ error: "Teilnehmer nicht gefunden." }, { status: 404 });
@@ -38,6 +38,7 @@ export async function POST(request: Request, { params }: Args) {
let body: { name?: unknown; email?: unknown }; let body: { name?: unknown; email?: unknown };
try { try {
body = await request.json(); body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch { } catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
} }
+1 -1
View File
@@ -14,7 +14,7 @@ export const AvailabilityOverrides: CollectionConfig = {
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.", description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
}, },
access: { access: {
read: () => true, read: isAdmin,
create: isAdmin, create: isAdmin,
update: isAdmin, update: isAdmin,
delete: isAdmin, delete: isAdmin,
+76 -77
View File
@@ -1,6 +1,8 @@
import { randomUUID } from "node:crypto";
import { APIError } from "payload"; import { APIError } from "payload";
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload"; import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload";
import { isAdmin, isAdminFieldLevel } from "@/access"; import { isAdmin, isAdminFieldLevel } from "@/access";
import { combineDateAndTime } from "@/lib/meeting/status";
import { acquireBookingDayLock } from "@/lib/booking/lock"; import { acquireBookingDayLock } from "@/lib/booking/lock";
import { import {
alternativeProposedEmail, alternativeProposedEmail,
@@ -12,7 +14,7 @@ import {
} from "@/lib/email/bookingTemplates"; } from "@/lib/email/bookingTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails"; import { sendEmail } from "@/lib/email/sendBookingEmails";
import type { Customer, Event, Offer } from "@/payload-types"; import type { Customer, Event, Offer } from "@/payload-types";
import { berlinDateKey, berlinDayBounds, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone"; import { berlinDateKey, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
export const APPOINTMENT_TYPES = [ export const APPOINTMENT_TYPES = [
{ label: "Vor Ort", value: "onsite" }, { label: "Vor Ort", value: "onsite" },
@@ -49,88 +51,73 @@ async function notifyAdmins(
await Promise.all(admins.filter((admin) => admin.email).map((admin) => sendEmail(admin.email, template))); await Promise.all(admins.filter((admin) => admin.email).map((admin) => sendEmail(admin.email, template)));
} }
// Runs whenever a booking is about to become "confirmed": locks the day // Normalize Payload time-only inputs before conflict checks and synchronize
// (see lib/booking/lock.ts), re-checks for overlapping confirmed bookings // the private meeting whenever a booking is moved, cancelled or reactivated.
// inside that same lock/transaction, and — for online appointments — creates export const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
// the linked video-call Event (reusing the existing meeting/password system) const status = data.status ?? originalDoc?.status ?? "pending";
// before the booking itself is written.
const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
// Covers both paths: an existing request being confirmed, AND Anna
// manually creating a booking that's already "confirmed" from the start
// (e.g. a phone booking) — both must go through the same conflict check.
const wasAlreadyConfirmed = originalDoc?.status === "confirmed";
if (data.status !== "confirmed" || wasAlreadyConfirmed) {
return data;
}
const date = data.date ?? originalDoc?.date; const date = data.date ?? originalDoc?.date;
const startTime = data.startTime ?? originalDoc?.startTime; const startTime = data.startTime ?? originalDoc?.startTime;
const endTime = data.endTime ?? originalDoc?.endTime; const endTime = data.endTime ?? originalDoc?.endTime;
const appointmentType = data.appointmentType ?? originalDoc?.appointmentType; const appointmentType = data.appointmentType ?? originalDoc?.appointmentType;
if (!date || !startTime || !endTime) return data; // Field-level validation will reject this anyway. if (!date || !startTime || !endTime) return data;
const start = combineDateAndTime(date, startTime);
const end = combineDateAndTime(date, endTime);
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start) {
throw new APIError("Das Ende muss nach dem Beginn am selben Tag liegen.", 400, undefined, true);
}
data.date = start.toISOString();
data.startTime = start.toISOString();
data.endTime = end.toISOString();
await acquireBookingDayLock(req, dateKey(date)); const alternative = data.proposedAlternative;
if (alternative && (alternative.date || alternative.startTime || alternative.endTime)) {
const dayBounds = berlinDayBounds(new Date(date)); if (!alternative.date || !alternative.startTime || !alternative.endTime) {
const dayStart = dayBounds.start.toISOString(); throw new APIError("Bitte den alternativen Termin vollständig angeben.", 400, undefined, true);
const dayEnd = dayBounds.end.toISOString(); }
const conflictClauses: Where[] = [ const altStart = combineDateAndTime(alternative.date, alternative.startTime);
{ status: { equals: "confirmed" } }, const altEnd = combineDateAndTime(alternative.date, alternative.endTime);
{ date: { greater_than_equal: dayStart } }, if (!Number.isFinite(altStart.getTime()) || !Number.isFinite(altEnd.getTime()) || altEnd <= altStart) {
{ date: { less_than_equal: dayEnd } }, throw new APIError("Der alternative Zeitraum ist ungültig.", 400, undefined, true);
]; }
if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } }); data.proposedAlternative = { date: altStart.toISOString(), startTime: altStart.toISOString(), endTime: altEnd.toISOString() };
const { docs: sameDayConfirmed } = await req.payload.find({
collection: "booking-requests",
where: { and: conflictClauses },
req,
limit: 200,
});
const newStart = new Date(startTime).getTime();
const newEnd = new Date(endTime).getTime();
const conflict = sameDayConfirmed.some((doc) => {
const s = new Date(doc.startTime).getTime();
const e = new Date(doc.endTime).getTime();
return newStart < e && s < newEnd;
});
if (conflict) {
throw new APIError(
"Dieser Zeitraum ist bereits mit einem anderen bestätigten Termin belegt. Bitte wähle eine andere Zeit oder lehne den überschneidenden Termin zuerst ab.",
409,
undefined,
true,
);
} }
if (appointmentType === "online" && !data.linkedEvent && !originalDoc?.linkedEvent) { if (status === "confirmed") {
const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc?.offer, req })) as Offer; await acquireBookingDayLock(req, dateKey(data.date));
const event = (await req.payload.create({ const conflictClauses: Where[] = [
collection: "events", { status: { equals: "confirmed" } },
req, { startTime: { less_than: data.endTime } },
data: { { endTime: { greater_than: data.startTime } },
title: offer.title, ];
date, if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
startTime, const { totalDocs } = await req.payload.count({ collection: "booking-requests", where: { and: conflictClauses }, req });
endTime, if (totalDocs) throw new APIError("Dieser Zeitraum ist bereits mit einem bestätigten Termin belegt.", 409, undefined, true);
category: "sonstiges", }
isOnline: true, // Events' own beforeChange hook auto-generates the password.
isPrivateBooking: true, const linked = data.linkedEvent ?? originalDoc?.linkedEvent;
// Only set on the update-to-confirmed path — on create-as-confirmed const linkedId = typeof linked === "object" ? linked?.id : linked;
// this booking doesn't have an id yet (assigned after insert), so if (linkedId) {
// the back-reference is simply left blank in that one case. const existing = await req.payload.findByID({ collection: "events", id: linkedId, depth: 0, req });
...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}), if (existing.isPrivateBooking) {
}, const isOnline = status === "confirmed" && appointmentType === "online";
// Created as a draft so it never appears in public /termine listings if (existing.date !== data.date || existing.startTime !== data.startTime || existing.endTime !== data.endTime || existing.isOnline !== isOnline) {
// (see lib/payload/content.ts) — it's still reachable via its direct await req.payload.update({ collection: "events", id: existing.id, req, data: {
// join link, which is exactly what a private 1:1 booking needs. date: data.date, startTime: data.startTime, endTime: data.endTime, isOnline,
draft: true, } });
})) as Event; }
}
} else if (status === "confirmed" && appointmentType === "online") {
const relatedOffer = data.offer ?? originalDoc?.offer;
const offer = await req.payload.findByID({ collection: "offers", id: typeof relatedOffer === "object" ? relatedOffer.id : relatedOffer, req });
const event = await req.payload.create({ collection: "events", req, draft: true, data: {
title: offer.title,
slug: `buchung-${randomUUID()}`,
date: data.date, startTime: data.startTime, endTime: data.endTime,
category: "sonstiges", isOnline: true, isPrivateBooking: true,
...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}),
} });
data.linkedEvent = event.id; data.linkedEvent = event.id;
} }
return data; return data;
}; };
@@ -183,7 +170,8 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
const statusChanged = operation === "create" || previousDoc?.status !== doc.status; const statusChanged = operation === "create" || previousDoc?.status !== doc.status;
if (statusChanged && doc.status === "confirmed") { const scheduleChanged = previousDoc && ["date", "startTime", "endTime", "appointmentType"].some((field) => doc[field] !== previousDoc[field]);
if ((statusChanged || scheduleChanged) && doc.status === "confirmed") {
let online: { joinUrl: string; password: string } | undefined; let online: { joinUrl: string; password: string } | undefined;
let onsite: { address: string; mapsUrl: string } | undefined; let onsite: { address: string; mapsUrl: string } | undefined;
@@ -239,7 +227,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
); );
} }
const proposedNew = doc.proposedAlternative?.date && doc.proposedAlternative.date !== previousDoc?.proposedAlternative?.date; const proposedNew = doc.proposedAlternative?.date && JSON.stringify(doc.proposedAlternative) !== JSON.stringify(previousDoc?.proposedAlternative);
if (proposedNew && doc.status === "pending") { if (proposedNew && doc.status === "pending") {
await sendEmail( await sendEmail(
user.email, user.email,
@@ -282,7 +270,7 @@ export const BookingRequests: CollectionConfig = {
// setting their own status to "confirmed" directly. // setting their own status to "confirmed" directly.
create: isAdmin, create: isAdmin,
read: ({ req }) => { read: ({ req }) => {
if (req.user?.collection === "users") return true; if (req.user?.collection === "users") return req.user.role === "admin";
if (req.user?.collection === "customers") return { user: { equals: req.user.id } }; if (req.user?.collection === "customers") return { user: { equals: req.user.id } };
return false; return false;
}, },
@@ -290,8 +278,19 @@ export const BookingRequests: CollectionConfig = {
delete: isAdmin, delete: isAdmin,
}, },
hooks: { hooks: {
beforeOperation: [async ({ args, operation, req }) => {
if (operation === "create" || operation === "update" || operation === "delete") {
await acquireBookingDayLock(req, "booking-calendar");
}
return args;
}],
beforeChange: [handleConfirmation], beforeChange: [handleConfirmation],
afterChange: [notifyByEmail], afterChange: [notifyByEmail],
afterDelete: [async ({ doc, req }) => {
const id = typeof doc.linkedEvent === "object" ? doc.linkedEvent?.id : doc.linkedEvent;
if (id) await req.payload.update({ collection: "events", id, req, data: { isOnline: false } });
return doc;
}],
}, },
fields: [ fields: [
{ {
+7
View File
@@ -1,4 +1,6 @@
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload"; import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { APIError } from "payload";
import { allowVerificationEmail } from "@/lib/auth/verificationRateLimit";
import { isAdmin, isAdminFieldLevel } from "@/access"; import { isAdmin, isAdminFieldLevel } from "@/access";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification"; import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates"; import { verificationEmail } from "@/lib/email/authTemplates";
@@ -35,6 +37,10 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc,
data.email !== originalDoc.email; data.email !== originalDoc.email;
if (operation !== "create" && !emailChanged) return data; if (operation !== "create" && !emailChanged) return data;
if (!allowVerificationEmail(req, String(data.email ?? originalDoc?.email ?? ""), originalDoc?.id)) {
throw new APIError("Zu viele Bestätigungs-E-Mails. Bitte versuche es später erneut.", 429, undefined, true);
}
const token = generateVerificationToken(); const token = generateVerificationToken();
data.emailVerified = false; data.emailVerified = false;
data.emailVerificationTokenHash = hashVerificationToken(token); data.emailVerificationTokenHash = hashVerificationToken(token);
@@ -46,6 +52,7 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc,
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => { const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => {
const token = req.context.pendingVerificationToken; const token = req.context.pendingVerificationToken;
if (typeof token !== "string") return doc; if (typeof token !== "string") return doc;
delete req.context.pendingVerificationToken;
try { try {
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000"; const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
await sendEmail( await sendEmail(
+5 -3
View File
@@ -3,6 +3,7 @@ import { isAdmin, isAdminFieldLevel, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug"; import { slugField } from "@/fields/slug";
import { seoFields } from "@/fields/seo"; import { seoFields } from "@/fields/seo";
import { generateMeetingPassword } from "@/lib/meeting/password"; import { generateMeetingPassword } from "@/lib/meeting/password";
import { revokeChangedMeeting } from "@/lib/meeting/revoke";
export const EVENT_CATEGORIES = [ export const EVENT_CATEGORIES = [
{ label: "Prozessbegleitung", value: "prozessbegleitung" }, { label: "Prozessbegleitung", value: "prozessbegleitung" },
@@ -18,9 +19,9 @@ export const EVENT_CATEGORIES = [
// Auto-generates the meeting password for new online events, and // Auto-generates the meeting password for new online events, and
// regenerates it whenever the admin ticks "Neues Passwort generieren". // regenerates it whenever the admin ticks "Neues Passwort generieren".
// Never derived from the event id/slug — always a fresh random value. // Never derived from the event id/slug — always a fresh random value.
const setMeetingPassword: CollectionBeforeChangeHook = ({ data }) => { const setMeetingPassword: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
if (!data?.isOnline) return data; if (!(data.isOnline ?? originalDoc?.isOnline)) return data;
if (!data.meetingPassword || data.regeneratePassword) { if (!(data.meetingPassword ?? originalDoc?.meetingPassword) || data.regeneratePassword || originalDoc?.isOnline === false) {
data.meetingPassword = generateMeetingPassword(); data.meetingPassword = generateMeetingPassword();
} }
data.regeneratePassword = false; data.regeneratePassword = false;
@@ -52,6 +53,7 @@ export const Events: CollectionConfig = {
}, },
hooks: { hooks: {
beforeChange: [setMeetingPassword], beforeChange: [setMeetingPassword],
afterChange: [revokeChangedMeeting],
}, },
fields: [ fields: [
{ {
+2 -1
View File
@@ -4,6 +4,7 @@ import { useState, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { ResendVerificationButton } from "./ResendVerificationButton"; import { ResendVerificationButton } from "./ResendVerificationButton";
import { TextField, FormError } from "@/components/FormField"; import { TextField, FormError } from "@/components/FormField";
import { safeLoginRedirect } from "@/lib/auth/redirect";
export function LoginForm() { export function LoginForm() {
const router = useRouter(); const router = useRouter();
@@ -32,7 +33,7 @@ export function LoginForm() {
setLoading(false); setLoading(false);
return; return;
} }
router.push(searchParams.get("next") || "/konto"); router.push(safeLoginRedirect(searchParams.get("next")));
router.refresh(); router.refresh();
} catch { } catch {
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut."); setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
+5 -5
View File
@@ -3,18 +3,18 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge"; import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge";
import type { BookingRequest, Event, Offer } from "@/payload-types"; import type { CustomerBooking } from "@/lib/booking/customerView";
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone"; import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
import type { MeetingStatus } from "@/lib/meeting/status"; import type { MeetingStatus } from "@/lib/meeting/status";
export function BookingCard({ booking, meetingStatus }: { booking: BookingRequest; meetingStatus?: MeetingStatus | null }) { export function BookingCard({ booking, meetingStatus }: { booking: CustomerBooking; meetingStatus?: MeetingStatus | null }) {
const router = useRouter(); const router = useRouter();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const offer = typeof booking.offer === "object" ? (booking.offer as Offer) : null; const offer = booking.offer;
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null; const event = booking.linkedEvent;
const displayStatus = resolveDisplayStatus(booking.status, booking.date); const displayStatus = resolveDisplayStatus(booking.status, booking.endTime);
const hasAlternative = booking.status === "pending" && Boolean(booking.proposedAlternative?.date); const hasAlternative = booking.status === "pending" && Boolean(booking.proposedAlternative?.date);
async function callAction(path: string) { async function callAction(path: string) {
+9 -4
View File
@@ -27,6 +27,7 @@ export function BookingWidget({
}) { }) {
const [slotsByDay, setSlotsByDay] = useState<SlotsResponse["slots"]>({}); const [slotsByDay, setSlotsByDay] = useState<SlotsResponse["slots"]>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [selectedDay, setSelectedDay] = useState<Date | null>(null); const [selectedDay, setSelectedDay] = useState<Date | null>(null);
const [selectedSlot, setSelectedSlot] = useState<string | null>(null); const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
const [appointmentType, setAppointmentType] = useState<"onsite" | "online">("onsite"); const [appointmentType, setAppointmentType] = useState<"onsite" | "online">("onsite");
@@ -35,12 +36,15 @@ export function BookingWidget({
const [result, setResult] = useState<{ ok: boolean; error?: string } | null>(null); const [result, setResult] = useState<{ ok: boolean; error?: string } | null>(null);
useEffect(() => { useEffect(() => {
const controller = new AbortController();
const from = new Date(); const from = new Date();
const to = new Date(from.getTime() + 28 * 24 * 60 * 60 * 1000); const to = new Date(from.getTime() + 28 * 24 * 60 * 60 * 1000);
fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`) fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`, { signal: controller.signal })
.then((res) => res.json()) .then((res) => { if (!res.ok) throw new Error("Slots unavailable"); return res.json(); })
.then((data: SlotsResponse) => setSlotsByDay(data.slots ?? {})) .then((data: SlotsResponse) => setSlotsByDay(data.slots ?? {}))
.finally(() => setLoading(false)); .catch(() => { if (!controller.signal.aborted) setLoadError(true); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [offerSlug]); }, [offerSlug]);
if (result?.ok) { if (result?.ok) {
@@ -126,11 +130,12 @@ export function BookingWidget({
if (loading) { if (loading) {
return <p className="text-sm text-anouma-plum/70">Verfügbare Termine werden geladen …</p>; return <p className="text-sm text-anouma-plum/70">Verfügbare Termine werden geladen …</p>;
} }
if (loadError) return <FormError>Verfügbare Termine konnten nicht geladen werden. Bitte lade die Seite erneut.</FormError>;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,340px)_1fr]"> <div className="grid gap-6 lg:grid-cols-[minmax(0,340px)_1fr]">
<MonthCalendar markers={markers} onSelectDay={setSelectedDay} selectedDay={selectedDay} /> <MonthCalendar markers={markers} onSelectDay={(day) => { setSelectedDay(day); setSelectedSlot(null); }} selectedDay={selectedDay} />
<div> <div>
{!selectedDay && <p className="text-sm text-anouma-plum/70">Wähle einen markierten Tag, um freie Zeiten zu sehen.</p>} {!selectedDay && <p className="text-sm text-anouma-plum/70">Wähle einen markierten Tag, um freie Zeiten zu sehen.</p>}
{selectedDay && daySlots.length === 0 && ( {selectedDay && daySlots.length === 0 && (
+4 -3
View File
@@ -4,16 +4,17 @@ import { useState } from "react";
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar"; import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
import { BookingCard } from "./BookingCard"; import { BookingCard } from "./BookingCard";
import { resolveDisplayStatus } from "./BookingStatusBadge"; import { resolveDisplayStatus } from "./BookingStatusBadge";
import type { BookingRequest, MeetingSetting } from "@/payload-types"; import type { MeetingSetting } from "@/payload-types";
import type { CustomerBooking } from "@/lib/booking/customerView";
import { berlinDateKey } from "@/lib/calendar/timezone"; import { berlinDateKey } from "@/lib/calendar/timezone";
import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess"; import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess";
export function PersonalCalendar({ bookings, meetingSettings }: { bookings: BookingRequest[]; meetingSettings: MeetingSetting }) { export function PersonalCalendar({ bookings, meetingSettings }: { bookings: CustomerBooking[]; meetingSettings: MeetingSetting }) {
const [selectedDay, setSelectedDay] = useState<Date | null>(null); const [selectedDay, setSelectedDay] = useState<Date | null>(null);
const markers: CalendarMarker[] = bookings.map((b) => ({ const markers: CalendarMarker[] = bookings.map((b) => ({
date: new Date(b.date), date: new Date(b.date),
status: resolveDisplayStatus(b.status, b.date) as CalendarMarker["status"], status: resolveDisplayStatus(b.status, b.endTime) as CalendarMarker["status"],
})); }));
const selectedBookings = selectedDay const selectedBookings = selectedDay
+3 -3
View File
@@ -171,9 +171,9 @@ export function CallRoom({ slug }: { slug: string }) {
localTracksRef.current = previewTracks; localTracksRef.current = previewTracks;
try { try {
let serverUrl = session.serverUrl; let serverUrl: string;
let participantToken = session.participantToken; let participantToken: string;
if (!serverUrl || !participantToken) { {
const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/connect`, { const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/connect`, {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${session.token}` }, headers: { Authorization: `Bearer ${session.token}` },
+5 -4
View File
@@ -13,16 +13,17 @@ const MAX_TRACKED_KEYS = 5000;
function sweepExpired(now: number) { function sweepExpired(now: number) {
for (const [key, bucket] of buckets) { for (const [key, bucket] of buckets) {
if (bucket.resetAt < now) buckets.delete(key); if (bucket.resetAt <= now) buckets.delete(key);
} }
} }
export function checkRateLimit(key: string, opts: { max: number; windowMs: number }): boolean { export function checkRateLimit(key: string, opts: { max: number; windowMs: number }): boolean {
const now = Date.now(); const now = Date.now();
if (buckets.size > MAX_TRACKED_KEYS) sweepExpired(now); if (buckets.size >= MAX_TRACKED_KEYS) sweepExpired(now);
const bucket = buckets.get(key); const bucket = buckets.get(key);
if (!bucket || bucket.resetAt < now) { if (!bucket || bucket.resetAt <= now) {
if (!bucket && buckets.size >= MAX_TRACKED_KEYS) return false;
buckets.set(key, { count: 1, resetAt: now + opts.windowMs }); buckets.set(key, { count: 1, resetAt: now + opts.windowMs });
return true; return true;
} }
@@ -31,7 +32,7 @@ export function checkRateLimit(key: string, opts: { max: number; windowMs: numbe
return true; return true;
} }
export function getClientIp(request: Request): string { export function getClientIp(request: { headers: Headers }): string {
// Deployment is always behind exactly one trusted reverse proxy (see // Deployment is always behind exactly one trusted reverse proxy (see
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published // DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
// directly to the host). nginx's default `$proxy_add_x_forwarded_for` // directly to the host). nginx's default `$proxy_add_x_forwarded_for`
+5
View File
@@ -0,0 +1,5 @@
/** Only origin-relative application paths may be used after login. */
export function safeLoginRedirect(value: string | null): string {
if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u0020]/.test(value)) return "/konto";
return value;
}
+9
View File
@@ -0,0 +1,9 @@
import { checkRateLimit, getClientIp } from "./rateLimit";
/** Shared budgets for registration, email changes and resend (REST and GraphQL). */
export function allowVerificationEmail(request: { headers: Headers }, email: string, customerId?: string | number): boolean {
const windowMs = 60 * 60 * 1000;
return checkRateLimit(`verification:ip:${getClientIp(request)}`, { max: 10, windowMs }) &&
(customerId === undefined || checkRateLimit(`verification:customer:${customerId}`, { max: 3, windowMs })) &&
checkRateLimit(`verification:email:${email.trim().toLowerCase()}`, { max: 3, windowMs });
}
+25
View File
@@ -0,0 +1,25 @@
import type { BookingRequest } from "@/payload-types";
/** Explicit allow-list at the server/client boundary, including relationships. */
export function customerBookingView(booking: BookingRequest) {
const offer = typeof booking.offer === "object" ? booking.offer : null;
const event = typeof booking.linkedEvent === "object" ? booking.linkedEvent : null;
const alternative = booking.proposedAlternative;
return {
id: booking.id,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
status: booking.status,
appointmentType: booking.appointmentType,
offer: offer ? { title: offer.title } : null,
linkedEvent: event ? { slug: event.slug, isOnline: event.isOnline } : null,
proposedAlternative: alternative ? {
date: alternative.date,
startTime: alternative.startTime,
endTime: alternative.endTime,
} : null,
};
}
export type CustomerBooking = ReturnType<typeof customerBookingView>;
+3 -2
View File
@@ -13,13 +13,14 @@ import type { PayloadRequest } from "payload";
*/ */
export async function acquireBookingDayLock(req: PayloadRequest, dateKey: string): Promise<void> { export async function acquireBookingDayLock(req: PayloadRequest, dateKey: string): Promise<void> {
const txID = req.transactionID ? String(await req.transactionID) : undefined; const txID = req.transactionID ? String(await req.transactionID) : undefined;
if (!txID) return; // No active transaction — nothing to lock against. if (!txID) throw new Error("A booking lock requires an active transaction");
const adapter = req.payload.db as unknown as { const adapter = req.payload.db as unknown as {
sessions?: Record<string, { db: { execute: (query: unknown) => Promise<unknown> } }>; sessions?: Record<string, { db: { execute: (query: unknown) => Promise<unknown> } }>;
drizzle: { execute: (query: unknown) => Promise<unknown> }; drizzle: { execute: (query: unknown) => Promise<unknown> };
}; };
const tx = adapter.sessions?.[txID]?.db ?? adapter.drizzle; const tx = adapter.sessions?.[txID]?.db;
if (!tx) throw new Error("Booking transaction not found");
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${dateKey}))`); await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${dateKey}))`);
} }
+5 -4
View File
@@ -1,8 +1,9 @@
import type { BookingRequest, Event, MeetingSetting } from "@/payload-types"; import type { MeetingSetting } from "@/payload-types";
import type { CustomerBooking } from "./customerView";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus, type MeetingStatus } from "@/lib/meeting/status"; import { canJoinMeeting, combineDateAndTime, getMeetingStatus, type MeetingStatus } from "@/lib/meeting/status";
export function getBookingMeetingStatus(booking: BookingRequest, settings: MeetingSetting): MeetingStatus | null { export function getBookingMeetingStatus(booking: CustomerBooking, settings: MeetingSetting): MeetingStatus | null {
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null; const event = booking.linkedEvent;
if (booking.status !== "confirmed" || booking.appointmentType !== "online" || !event?.isOnline) return null; if (booking.status !== "confirmed" || booking.appointmentType !== "online" || !event?.isOnline) return null;
const start = combineDateAndTime(booking.date, booking.startTime); const start = combineDateAndTime(booking.date, booking.startTime);
@@ -17,7 +18,7 @@ export function getBookingMeetingStatus(booking: BookingRequest, settings: Meeti
}); });
} }
export function canJoinBookingMeeting(booking: BookingRequest, settings: MeetingSetting): boolean { export function canJoinBookingMeeting(booking: CustomerBooking, settings: MeetingSetting): boolean {
const status = getBookingMeetingStatus(booking, settings); const status = getBookingMeetingStatus(booking, settings);
return status !== null && canJoinMeeting(status); return status !== null && canJoinMeeting(status);
} }
+5 -5
View File
@@ -1,13 +1,13 @@
import { cache } from "react"; import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload"; import { getCMS } from "@/lib/payload/getPayload";
import type { BookingRequest } from "@/payload-types"; import { customerBookingView, type CustomerBooking } from "./customerView";
/** /**
* These are called only after the caller has already verified who the * These are called only after the caller has already verified who the
* current customer is (see lib/auth/customer.ts) — the `where: { user }` * current customer is (see lib/auth/customer.ts) — the `where: { user }`
* filter below is what actually scopes results to that one customer. * filter below is what actually scopes results to that one customer.
*/ */
export const getCustomerBookings = cache(async (customerId: number): Promise<BookingRequest[]> => { export const getCustomerBookings = cache(async (customerId: number): Promise<CustomerBooking[]> => {
const payload = await getCMS(); const payload = await getCMS();
const result = await payload.find({ const result = await payload.find({
collection: "booking-requests", collection: "booking-requests",
@@ -16,10 +16,10 @@ export const getCustomerBookings = cache(async (customerId: number): Promise<Boo
depth: 2, depth: 2,
limit: 200, limit: 200,
}); });
return result.docs; return result.docs.map(customerBookingView);
}); });
export async function getCustomerBookingById(customerId: number, id: number): Promise<BookingRequest | null> { export async function getCustomerBookingById(customerId: number, id: number): Promise<CustomerBooking | null> {
const payload = await getCMS(); const payload = await getCMS();
const result = await payload.find({ const result = await payload.find({
collection: "booking-requests", collection: "booking-requests",
@@ -27,5 +27,5 @@ export async function getCustomerBookingById(customerId: number, id: number): Pr
depth: 2, depth: 2,
limit: 1, limit: 1,
}); });
return result.docs[0] ?? null; return result.docs[0] ? customerBookingView(result.docs[0]) : null;
} }
+11 -8
View File
@@ -1,4 +1,4 @@
import type { Payload } from "payload"; import type { Payload, PayloadRequest } from "payload";
import { WEEKDAYS } from "@/collections/Availability"; import { WEEKDAYS } from "@/collections/Availability";
import { berlinParts, berlinWallTimeToUTC, type CalendarDate } from "@/lib/calendar/timezone"; import { berlinParts, berlinWallTimeToUTC, type CalendarDate } from "@/lib/calendar/timezone";
@@ -63,17 +63,20 @@ function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
*/ */
export async function getAvailableSlots( export async function getAvailableSlots(
payload: Payload, payload: Payload,
args: { durationMinutes: number; from: Date; to: Date }, args: { durationMinutes: number; from: Date; to: Date; req?: PayloadRequest },
): Promise<Map<string, TimeSlot[]>> { ): Promise<Map<string, TimeSlot[]>> {
const { durationMinutes, from, to } = args; const { durationMinutes, from, to, req } = args;
const result = new Map<string, TimeSlot[]>(); const result = new Map<string, TimeSlot[]>();
if (!Number.isInteger(durationMinutes) || durationMinutes <= 0 || durationMinutes > 1440 ||
!Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || to < from ||
to.getTime() - from.getTime() > 63 * 86400000) return result;
const [{ docs: weekly }, { docs: overrides }, { docs: busyBookings }] = await Promise.all([ const [{ docs: weekly }, { docs: overrides }, { docs: busyBookings }] = await Promise.all([
payload.find({ collection: "availability", where: { active: { equals: true } }, limit: 100 }), payload.find({ collection: "availability", where: { active: { equals: true } }, pagination: false, req }),
payload.find({ payload.find({
collection: "availability-overrides", collection: "availability-overrides",
where: { and: [{ date: { greater_than_equal: from.toISOString() } }, { date: { less_than_equal: to.toISOString() } }] }, where: { and: [{ date: { greater_than_equal: from.toISOString() } }, { date: { less_than_equal: to.toISOString() } }] },
limit: 200, pagination: false, req,
}), }),
payload.find({ payload.find({
collection: "booking-requests", collection: "booking-requests",
@@ -84,7 +87,7 @@ export async function getAvailableSlots(
{ date: { less_than_equal: to.toISOString() } }, { date: { less_than_equal: to.toISOString() } },
], ],
}, },
limit: 500, pagination: false, req,
}), }),
]); ]);
@@ -131,11 +134,11 @@ export async function getAvailableSlots(
const slotEnd = atMinutesOfDay(day, start + durationMinutes); const slotEnd = atMinutesOfDay(day, start + durationMinutes);
if (slotStart < now) continue; if (slotStart < now) continue;
if (dayBusy.some((b) => overlaps(slotStart, slotEnd, b.start, b.end))) continue; if (dayBusy.some((b) => overlaps(slotStart, slotEnd, b.start, b.end))) continue;
slots.push({ start: slotStart, end: slotEnd }); if (!slots.some((slot) => slot.start.getTime() === slotStart.getTime())) slots.push({ start: slotStart, end: slotEnd });
} }
} }
if (slots.length > 0) result.set(key, slots); if (slots.length > 0) result.set(key, slots.sort((a, b) => a.start.getTime() - b.start.getTime()));
} }
return result; return result;
+17
View File
@@ -0,0 +1,17 @@
import { commitTransaction, createLocalReq, initTransaction, killTransaction, type Payload, type PayloadRequest } from "payload";
import { acquireBookingDayLock } from "./lock";
export async function withBookingTransaction<T>(payload: Payload, key: string, action: (req: PayloadRequest) => Promise<T>): Promise<T> {
const req = await createLocalReq({}, payload);
try {
if (!await initTransaction(req)) throw new Error("Booking transaction unavailable");
await acquireBookingDayLock(req, "booking-calendar");
await acquireBookingDayLock(req, key);
const result = await action(req);
await commitTransaction(req);
return result;
} catch (error) {
await killTransaction(req);
throw error;
}
}
+9
View File
@@ -2,6 +2,15 @@ const BERLIN_TZ = "Europe/Berlin";
export type CalendarDate = { year: number; month: number; day: number }; export type CalendarDate = { year: number; month: number; day: number };
/** Strict calendar input, interpreted in Berlin regardless of server TZ. */
export function parseBerlinDate(value: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const [year, month, day] = value.split("-").map(Number);
if (year < 1000 || month < 1 || month > 12 || day < 1 || day > 31) return null;
const date = berlinWallTimeToUTC({ year, month, day }, 0, 0);
return berlinDateKey(date) === value ? date : null;
}
export function berlinDateKey(date: Date): string { export function berlinDateKey(date: Date): string {
const { year, month, day } = berlinParts(date); const { year, month, day } = berlinParts(date);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
+3
View File
@@ -15,6 +15,9 @@ export function getMailTransport(): nodemailer.Transporter {
host: SMTP_HOST, host: SMTP_HOST,
port: Number(SMTP_PORT), port: Number(SMTP_PORT),
secure: Number(SMTP_PORT) === 465, secure: Number(SMTP_PORT) === 465,
connectionTimeout: 10000,
greetingTimeout: 10000,
socketTimeout: 20000,
auth: { user: SMTP_USER, pass: SMTP_PASSWORD }, auth: { user: SMTP_USER, pass: SMTP_PASSWORD },
}); });
return cached; return cached;
+42
View File
@@ -0,0 +1,42 @@
import { createHmac } from "node:crypto";
import type { Payload } from "payload";
import type { Event, MeetingSetting } from "@/payload-types";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "./status";
import type { MeetingRole } from "./token";
export function meetingAccessVersion(event: Event): string {
const secret = process.env.MEETING_SESSION_SECRET;
if (!secret) throw new Error("MEETING_SESSION_SECRET is not set");
return createHmac("sha256", secret).update(JSON.stringify([
event.id, event.slug, event.meetingPassword, event.date, event.startTime,
event.endTime, event.isOnline, event._status, event.isPrivateBooking,
])).digest("hex");
}
export function meetingWindow(event: Event, settings: MeetingSetting, role: MeetingRole, now = new Date()) {
const start = combineDateAndTime(event.date, event.startTime);
const end = event.endTime ? combineDateAndTime(event.date, event.endTime) : new Date(start.getTime() + 3600000);
const closeAt = end.getTime() + settings.meetingCloseAfterMinutes * 60000;
const status = getMeetingStatus({ start, end,
joinWindowMinutes: role === "host" ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
closeAfterMinutes: settings.meetingCloseAfterMinutes, now });
return { allowed: Number.isFinite(closeAt) && end > start && canJoinMeeting(status), closeAt, status };
}
/** Shared by every issuer: existing credentials are not a substitute for current access. */
export async function authorizeMeeting(payload: Payload, event: Event, role: MeetingRole) {
if (!event.isOnline || (!event.isPrivateBooking && event._status !== "published" && role !== "host")) return null;
if (event.isPrivateBooking) {
const { docs } = await payload.find({ collection: "booking-requests", depth: 0, limit: 1,
where: { and: [
{ linkedEvent: { equals: event.id } }, { status: { equals: "confirmed" } },
{ appointmentType: { equals: "online" } },
] },
});
if (!docs[0]) return null;
}
const settings = await payload.findGlobal({ slug: "meeting-settings" });
const window = meetingWindow(event, settings, role);
if (!window.allowed) return null;
return { accessVersion: meetingAccessVersion(event), expiresAt: window.closeAt };
}
+10 -4
View File
@@ -17,9 +17,9 @@ function getLiveKitApiUrl(): string {
} }
/** Stable opaque room name; the public event slug is never sent to LiveKit logs. */ /** Stable opaque room name; the public event slug is never sent to LiveKit logs. */
export function getLiveKitRoomName(eventSlug: string): string { export function getLiveKitRoomName(eventSlug: string, accessVersion?: string): string {
const secret = requiredEnv("LIVEKIT_API_SECRET"); const secret = requiredEnv("LIVEKIT_API_SECRET");
return `anouma-${createHmac("sha256", secret).update(eventSlug).digest("hex").slice(0, 32)}`; return `anouma-${createHmac("sha256", secret).update(JSON.stringify([eventSlug, accessVersion])).digest("hex").slice(0, 32)}`;
} }
export async function createLiveKitToken(args: { export async function createLiveKitToken(args: {
@@ -28,12 +28,18 @@ export async function createLiveKitToken(args: {
name: string; name: string;
role: MeetingRole; role: MeetingRole;
clientId?: string; clientId?: string;
accessVersion?: string;
expiresAt: number;
}): Promise<string> { }): Promise<string> {
const room = getLiveKitRoomName(args.eventSlug); const room = getLiveKitRoomName(args.eventSlug, args.accessVersion);
const remainingSeconds = Math.floor((args.expiresAt - Date.now()) / 1000);
if (remainingSeconds <= 0) throw new Error("Meeting access has expired");
const token = new AccessToken(requiredEnv("LIVEKIT_API_KEY"), requiredEnv("LIVEKIT_API_SECRET"), { const token = new AccessToken(requiredEnv("LIVEKIT_API_KEY"), requiredEnv("LIVEKIT_API_SECRET"), {
identity: args.participantId, identity: args.participantId,
name: args.name, name: args.name,
ttl: "2h", // Self-hosted LiveKit cannot revoke already issued JWTs. Keep the
// connection credential short-lived; the app re-authorizes reconnects.
ttl: Math.min(60, remainingSeconds),
metadata: JSON.stringify({ role: args.role, clientId: args.clientId }), metadata: JSON.stringify({ role: args.role, clientId: args.clientId }),
}); });
token.addGrant({ token.addGrant({
+19
View File
@@ -0,0 +1,19 @@
import type { CollectionAfterChangeHook } from "payload";
import type { Event } from "@/payload-types";
import { meetingAccessVersion } from "./access";
import { getLiveKitRoomName, getLiveKitRoomService } from "./livekit";
/** Disconnect the previous room when its password, schedule or access changes. */
export const revokeChangedMeeting: CollectionAfterChangeHook<Event> = async ({ doc, previousDoc, req }) => {
if (!previousDoc?.isOnline || !previousDoc.slug || !process.env.LIVEKIT_URL || !process.env.MEETING_SESSION_SECRET) return doc;
const previousVersion = meetingAccessVersion(previousDoc);
if (previousVersion === meetingAccessVersion(doc)) return doc;
try {
const service = getLiveKitRoomService();
const name = getLiveKitRoomName(previousDoc.slug, previousVersion);
if ((await service.listRooms([name])).length) await service.deleteRoom(name);
} catch (err) {
req.payload.logger.error({ err, msg: "Could not disconnect revoked meeting room" });
}
return doc;
};
+2
View File
@@ -36,6 +36,8 @@ export function getMeetingStatus({
const closeAt = end.getTime() + closeAfterMinutes * 60_000; const closeAt = end.getTime() + closeAfterMinutes * 60_000;
const t = now.getTime(); const t = now.getTime();
if (![openAt, closeAt, t].every(Number.isFinite) || end <= start) return "ended";
if (t >= closeAt) return "ended"; if (t >= closeAt) return "ended";
if (t >= start.getTime()) return "live"; if (t >= start.getTime()) return "live";
if (t >= openAt) return "open"; if (t >= openAt) return "open";
+9 -1
View File
@@ -17,6 +17,8 @@ export type MeetingTokenPayload = {
clientId?: string; clientId?: string;
name: string; name: string;
role: MeetingRole; role: MeetingRole;
accessVersion?: string;
testRoom?: boolean;
exp: number; // unix ms exp: number; // unix ms
}; };
@@ -41,6 +43,8 @@ export function createMeetingToken(args: {
role: MeetingRole; role: MeetingRole;
clientId?: string; clientId?: string;
ttlMs?: number; ttlMs?: number;
accessVersion?: string;
testRoom?: boolean;
}): { token: string; participantId: string } { }): { token: string; participantId: string } {
const participantId = randomUUID(); const participantId = randomUUID();
const clientId = const clientId =
@@ -53,6 +57,8 @@ export function createMeetingToken(args: {
clientId, clientId,
name: args.name, name: args.name,
role: args.role, role: args.role,
accessVersion: args.accessVersion,
testRoom: args.testRoom,
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
}; };
const body = base64url(JSON.stringify(payload)); const body = base64url(JSON.stringify(payload));
@@ -76,7 +82,9 @@ export function verifyMeetingToken(token: unknown): MeetingTokenPayload | null {
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload; const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload;
if ( if (
!Number.isFinite(payload.exp) || !Number.isFinite(payload.exp) ||
payload.exp < Date.now() || payload.exp <= Date.now() ||
(payload.accessVersion !== undefined && !/^[0-9a-f]{64}$/.test(payload.accessVersion)) ||
(payload.testRoom !== undefined && typeof payload.testRoom !== "boolean") ||
typeof payload.eventSlug !== "string" || typeof payload.eventSlug !== "string" ||
payload.eventSlug.length > 200 || payload.eventSlug.length > 200 ||
typeof payload.participantId !== "string" || typeof payload.participantId !== "string" ||
+3 -392
View File
@@ -566,395 +566,6 @@
"source-map-support": "^0.5.21" "source-map-support": "^0.5.21"
} }
}, },
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
"integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
"integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
"integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
"integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
"integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
"integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
"integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
"integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
"integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
"integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
"integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
"integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
"integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
"integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
"integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
"integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
"integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
"integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
"integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
"integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
"integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
"integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/esbuild": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.18.20",
"@esbuild/android-arm64": "0.18.20",
"@esbuild/android-x64": "0.18.20",
"@esbuild/darwin-arm64": "0.18.20",
"@esbuild/darwin-x64": "0.18.20",
"@esbuild/freebsd-arm64": "0.18.20",
"@esbuild/freebsd-x64": "0.18.20",
"@esbuild/linux-arm": "0.18.20",
"@esbuild/linux-arm64": "0.18.20",
"@esbuild/linux-ia32": "0.18.20",
"@esbuild/linux-loong64": "0.18.20",
"@esbuild/linux-mips64el": "0.18.20",
"@esbuild/linux-ppc64": "0.18.20",
"@esbuild/linux-riscv64": "0.18.20",
"@esbuild/linux-s390x": "0.18.20",
"@esbuild/linux-x64": "0.18.20",
"@esbuild/netbsd-x64": "0.18.20",
"@esbuild/openbsd-x64": "0.18.20",
"@esbuild/sunos-x64": "0.18.20",
"@esbuild/win32-arm64": "0.18.20",
"@esbuild/win32-ia32": "0.18.20",
"@esbuild/win32-x64": "0.18.20"
}
},
"node_modules/@esbuild-kit/esm-loader": { "node_modules/@esbuild-kit/esm-loader": {
"version": "2.6.5", "version": "2.6.5",
"resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz",
@@ -5999,9 +5610,9 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.8", "version": "3.4.15",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"peer": true, "peer": true,
"optionalDependencies": { "optionalDependencies": {
+6
View File
@@ -46,5 +46,11 @@
"eslint-config-next": "^16.3.5", "eslint-config-next": "^16.3.5",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
},
"overrides": {
"dompurify": "^3.4.15",
"@esbuild-kit/core-utils": {
"esbuild": "^0.25.12"
}
} }
} }
+144
View File
@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Payload, PayloadRequest } from "payload";
import type { BookingRequest, Event, MeetingSetting } from "../payload-types";
import { customerBookingView } from "../lib/booking/customerView";
import { authorizeMeeting, meetingAccessVersion, meetingWindow } from "../lib/meeting/access";
import { createMeetingToken, verifyMeetingToken } from "../lib/meeting/token";
import { createLiveKitToken, getLiveKitRoomName } from "../lib/meeting/livekit";
import { allowVerificationEmail } from "../lib/auth/verificationRateLimit";
import { safeLoginRedirect } from "../lib/auth/redirect";
import { parseBerlinDate, berlinDayBounds } from "../lib/calendar/timezone";
import { getAvailableSlots } from "../lib/booking/slots";
import { handleConfirmation } from "../collections/BookingRequests";
import { Events } from "../collections/Events";
import { AvailabilityOverrides } from "../collections/AvailabilityOverrides";
process.env.MEETING_SESSION_SECRET = "test-only-meeting-secret";
process.env.LIVEKIT_API_KEY = "test-api-key";
process.env.LIVEKIT_API_SECRET = "test-only-livekit-secret-long-enough";
const settings = { hostJoinWindowMinutes: 30, participantJoinWindowMinutes: 10, meetingCloseAfterMinutes: 10 } as MeetingSetting;
const event = { id: 1, slug: "private-call", title: "Fixture", date: "2027-07-01T10:00:00.000Z", startTime: "2027-07-01T10:00:00.000Z", endTime: "2027-07-01T11:00:00.000Z", isOnline: true, _status: "published", meetingPassword: "fixture" } as Event;
test("customer props exclude private fields, even inside populated relationships", () => {
const booking = { id: 1, internalNote: "SECRET_NOTE", user: { emailVerificationTokenHash: "SECRET_HASH" },
offer: { title: "Consultation", internalNote: "SECRET_OFFER" },
linkedEvent: { slug: "private-call", isOnline: true, meetingPassword: "SECRET_PASSWORD", bookingRequest: { internalNote: "SECRET_NESTED" } },
proposedAlternative: { date: "2027-07-01", internalNote: "SECRET_ALTERNATIVE" },
} as unknown as BookingRequest;
const serialized = JSON.stringify(customerBookingView(booking));
assert.ok(!serialized.includes("SECRET"));
assert.ok(serialized.includes("Consultation"));
assert.ok(serialized.includes("private-call"));
});
test("closed and malformed meeting windows never grant access", () => {
assert.equal(meetingWindow(event, settings, "participant", new Date("2027-07-01T11:10:00Z")).allowed, false);
assert.equal(meetingWindow({ ...event, endTime: "invalid" }, settings, "participant").allowed, false);
assert.equal(meetingWindow(event, settings, "participant", new Date("2027-07-01T09:49:59Z")).allowed, false);
assert.equal(meetingWindow(event, settings, "participant", new Date("2027-07-01T10:00:00Z")).allowed, true);
});
test("cancelled private meetings are rejected using the current booking status", async () => {
let queried = false;
const payload = { find: async (args: { where: unknown }) => {
queried = true;
assert.ok(JSON.stringify(args.where).includes('"confirmed"'));
return { docs: [] };
} } as unknown as Payload;
assert.equal(await authorizeMeeting(payload, { ...event, isPrivateBooking: true }, "participant"), null);
assert.equal(queried, true);
});
test("password and schedule changes invalidate credentials and isolate the new room", () => {
const version = meetingAccessVersion(event);
const changed = meetingAccessVersion({ ...event, meetingPassword: "replacement" });
assert.notEqual(version, changed);
assert.notEqual(version, meetingAccessVersion({ ...event, endTime: "2027-07-01T12:00:00Z" }));
assert.notEqual(getLiveKitRoomName(event.slug!, version), getLiveKitRoomName(event.slug!, changed));
const issued = createMeetingToken({ eventSlug: event.slug!, name: "Guest", role: "participant", accessVersion: version });
assert.equal(verifyMeetingToken(issued.token)?.accessVersion, version);
const expired = createMeetingToken({ eventSlug: event.slug!, name: "Guest", role: "participant", ttlMs: 0 });
assert.equal(verifyMeetingToken(expired.token), null);
});
test("LiveKit connection credentials expire within the allowed window and one minute", async () => {
const deadline = Date.now() + 30000;
const jwt = await createLiveKitToken({ eventSlug: "fixture", participantId: "guest", name: "Guest", role: "participant", expiresAt: deadline });
const claims = JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString());
assert.ok(claims.exp * 1000 <= deadline);
assert.ok(claims.exp - claims.nbf <= 60);
assert.equal(claims.video.roomAdmin, undefined);
});
test("verification budgets apply across operations, address casing and changing IPs", () => {
for (let i = 0; i < 3; i++) assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": `fixture-${i}` }) }, "Target@example.com"), true);
assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": "fixture-new" }) }, "target@EXAMPLE.com"), false);
for (let i = 0; i < 3; i++) assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": `account-${i}` }) }, `target${i}@example.com`, 17), true);
assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": "account-new" }) }, "fresh@example.com", 17), false);
});
test("login rejects external, script and backslash redirects", () => {
for (const value of ["javascript:alert(1)", "https://other.invalid", "//other.invalid", "/\\other.invalid", "/\n/other.invalid", null]) assert.equal(safeLoginRedirect(value), "/konto");
assert.equal(safeLoginRedirect("/angebote/beratung?x=1"), "/angebote/beratung?x=1");
});
test("slot date parsing uses Berlin boundaries and rejects impossible calendar dates", () => {
assert.equal(parseBerlinDate("2027-07-01")?.toISOString(), "2027-06-30T22:00:00.000Z");
assert.equal(parseBerlinDate("2027-02-30"), null);
assert.equal(parseBerlinDate("2027-7-1"), null);
assert.equal(berlinDayBounds(parseBerlinDate("2027-03-28")!).end.getTime() - parseBerlinDate("2027-03-28")!.getTime() + 1, 23 * 3600000);
});
test("invalid slot durations terminate safely without querying the database", async () => {
const payload = { find: () => { throw new Error("Unexpected query"); } } as unknown as Payload;
for (const durationMinutes of [0, -1, NaN, Infinity, 0.5]) {
assert.equal((await getAvailableSlots(payload, { durationMinutes, from: new Date(), to: new Date() })).size, 0);
}
});
test("public users cannot read absence reasons", () => {
const read = AvailabilityOverrides.access!.read!;
assert.equal(read({ req: { user: null } } as Parameters<typeof read>[0]), false);
});
function hookArgs(data: Record<string, unknown>, originalDoc: Record<string, unknown>, payload: Record<string, unknown>) {
return { data, originalDoc, req: { transactionID: "fixture", payload: { ...payload,
db: { sessions: { fixture: { db: { execute: async () => undefined } } } },
} } as unknown as PayloadRequest } as Parameters<typeof handleConfirmation>[0];
}
test("rescheduling a confirmed booking checks conflicts instead of silently overlapping", async () => {
const original = { id: 1, status: "confirmed", appointmentType: "onsite", date: event.date, startTime: event.startTime, endTime: event.endTime };
await assert.rejects(() => handleConfirmation(hookArgs({ startTime: "2027-07-01T10:30:00Z" }, original, {
count: async () => ({ totalDocs: 1 }),
})), { status: 409 });
});
test("cancelling a private booking disables its event", async () => {
let updated = false;
await handleConfirmation(hookArgs({ status: "cancelled" }, { id: 1, status: "confirmed", appointmentType: "online", date: event.date, startTime: event.startTime, endTime: event.endTime, linkedEvent: 2 }, {
findByID: async () => ({ ...event, id: 2, isPrivateBooking: true }),
update: async (args: { data: { isOnline: boolean } }) => { assert.equal(args.data.isOnline, false); updated = true; },
}));
assert.equal(updated, true);
});
test("repeated bookings for one offer get distinct private meeting slugs", async () => {
const slugs: string[] = [];
const payload = { count: async () => ({ totalDocs: 0 }), findByID: async () => ({ title: "Same offer" }),
create: async (args: { data: { slug: string } }) => { slugs.push(args.data.slug); return { id: slugs.length }; },
};
for (let i = 0; i < 2; i++) await handleConfirmation(hookArgs({ status: "confirmed", offer: 1, appointmentType: "online", date: event.date, startTime: event.startTime, endTime: event.endTime }, {}, payload));
assert.equal(new Set(slugs).size, 2);
});
test("ordinary event edits preserve the password; explicit regeneration changes it", async () => {
const hook = Events.hooks!.beforeChange![0];
const unchanged = await hook({ data: { isOnline: true }, originalDoc: event } as unknown as Parameters<typeof hook>[0]);
assert.equal(unchanged.meetingPassword, undefined);
const changed = await hook({ data: { regeneratePassword: true }, originalDoc: event } as unknown as Parameters<typeof hook>[0]);
assert.ok(changed.meetingPassword);
assert.notEqual(changed.meetingPassword, event.meetingPassword);
});