From 66710fc65ce9dc4d721c270fd0fbe49c047ed347 Mon Sep 17 00:00:00 2001 From: maro Date: Thu, 17 Sep 2026 02:42:27 +0200 Subject: [PATCH] fix: harden booking and meeting flows --- SECURITY_AUDIT.md | 98 +++++ app/(frontend)/konto/page.tsx | 4 +- .../api/admin/meetings/start/route.ts | 6 + .../api/admin/meetings/test/route.ts | 1 + app/(payload)/api/analytics/track/route.ts | 1 + .../api/auth/resend-verification/route.ts | 10 +- .../booking/[id]/accept-alternative/route.ts | 57 ++- .../api/booking/[id]/cancel/route.ts | 22 +- app/(payload)/api/booking/request/route.ts | 71 ++-- app/(payload)/api/booking/slots/route.ts | 12 +- .../api/meetings/[slug]/connect/route.ts | 11 +- .../api/meetings/[slug]/join/route.ts | 33 +- .../api/meetings/[slug]/kick/route.ts | 3 +- .../api/meetings/[slug]/register/route.ts | 1 + collections/AvailabilityOverrides.ts | 2 +- collections/BookingRequests.ts | 153 ++++--- collections/Customers.ts | 7 + collections/Events.ts | 8 +- components/auth/LoginForm.tsx | 3 +- components/booking/BookingCard.tsx | 10 +- components/booking/BookingWidget.tsx | 13 +- components/booking/PersonalCalendar.tsx | 7 +- components/meeting/CallRoom.tsx | 6 +- lib/auth/rateLimit.ts | 9 +- lib/auth/redirect.ts | 5 + lib/auth/verificationRateLimit.ts | 9 + lib/booking/customerView.ts | 25 ++ lib/booking/lock.ts | 5 +- lib/booking/meetingAccess.ts | 9 +- lib/booking/queries.ts | 10 +- lib/booking/slots.ts | 19 +- lib/booking/transaction.ts | 17 + lib/calendar/timezone.ts | 9 + lib/email/transport.ts | 3 + lib/meeting/access.ts | 42 ++ lib/meeting/livekit.ts | 14 +- lib/meeting/revoke.ts | 19 + lib/meeting/status.ts | 2 + lib/meeting/token.ts | 10 +- package-lock.json | 395 +----------------- package.json | 6 + tests/security.test.ts | 144 +++++++ 42 files changed, 683 insertions(+), 608 deletions(-) create mode 100644 SECURITY_AUDIT.md create mode 100644 lib/auth/redirect.ts create mode 100644 lib/auth/verificationRateLimit.ts create mode 100644 lib/booking/customerView.ts create mode 100644 lib/booking/transaction.ts create mode 100644 lib/meeting/access.ts create mode 100644 lib/meeting/revoke.ts create mode 100644 tests/security.test.ts diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 0000000..fe1838c --- /dev/null +++ b/SECURITY_AUDIT.md @@ -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. diff --git a/app/(frontend)/konto/page.tsx b/app/(frontend)/konto/page.tsx index f541ed4..3cbd558 100644 --- a/app/(frontend)/konto/page.tsx +++ b/app/(frontend)/konto/page.tsx @@ -15,8 +15,8 @@ export default async function KontoOverviewPage() { const bookings = await getCustomerBookings(customer.id); const meetingSettings = await getMeetingSettingsGlobal(); const pending = bookings.filter((b) => b.status === "pending"); - const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.date) >= new Date()); - const past = bookings.filter((b) => new Date(b.date) < new Date() || b.status === "rejected" || b.status === "cancelled"); + const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.endTime) >= new Date()); + const past = bookings.filter((b) => new Date(b.endTime) < new Date() || b.status === "rejected" || b.status === "cancelled"); return (
diff --git a/app/(payload)/api/admin/meetings/start/route.ts b/app/(payload)/api/admin/meetings/start/route.ts index 77cf9f3..e2d3aa4 100644 --- a/app/(payload)/api/admin/meetings/start/route.ts +++ b/app/(payload)/api/admin/meetings/start/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getPayload } from "payload"; import config from "@payload-config"; import { createMeetingToken } from "@/lib/meeting/token"; +import { authorizeMeeting } from "@/lib/meeting/access"; export const dynamic = "force-dynamic"; @@ -12,6 +13,7 @@ export async function POST(request: Request) { let body: Body; try { body = await request.json(); + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body"); } catch { 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 }); } + 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({ eventSlug, name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "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 diff --git a/app/(payload)/api/admin/meetings/test/route.ts b/app/(payload)/api/admin/meetings/test/route.ts index f2b2b49..762dc4e 100644 --- a/app/(payload)/api/admin/meetings/test/route.ts +++ b/app/(payload)/api/admin/meetings/test/route.ts @@ -20,6 +20,7 @@ export async function POST(request: Request) { eventSlug, name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host", role: "host", + testRoom: true, ttlMs: 60 * 60 * 1000, }); const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin; diff --git a/app/(payload)/api/analytics/track/route.ts b/app/(payload)/api/analytics/track/route.ts index 6b26d2e..bed5e99 100644 --- a/app/(payload)/api/analytics/track/route.ts +++ b/app/(payload)/api/analytics/track/route.ts @@ -53,6 +53,7 @@ export async function POST(request: Request) { try { const raw = await request.text(); body = JSON.parse(raw); + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body"); } catch { return new NextResponse(null, { status: 204 }); } diff --git a/app/(payload)/api/auth/resend-verification/route.ts b/app/(payload)/api/auth/resend-verification/route.ts index ecdce8c..af5b64c 100644 --- a/app/(payload)/api/auth/resend-verification/route.ts +++ b/app/(payload)/api/auth/resend-verification/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getPayload } from "payload"; import config from "@payload-config"; 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 { verificationEmail } from "@/lib/email/authTemplates"; import { sendEmail } from "@/lib/email/sendBookingEmails"; @@ -57,11 +58,12 @@ export async function POST(request: Request) { let body: { email?: unknown } = {}; try { body = await request.json(); + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body"); } catch { // no body — handled below as a missing email } - const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; - if (!email) { + const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : ""; + if (!email || email.length > 254) { return NextResponse.json({ error: "E-Mail-Adresse erforderlich." }, { status: 400 }); } 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 (!checkRateLimit(`resend-verification:customer:${customer.id}`, { max: 3, windowMs: 60 * 60 * 1000 })) { - return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 }); + if (!allowVerificationEmail(request, customer.email, customer.id)) { + return NextResponse.json(GENERIC_RESPONSE); } await issueAndSend(payload, customer); } diff --git a/app/(payload)/api/booking/[id]/accept-alternative/route.ts b/app/(payload)/api/booking/[id]/accept-alternative/route.ts index 445e691..d59b124 100644 --- a/app/(payload)/api/booking/[id]/accept-alternative/route.ts +++ b/app/(payload)/api/booking/[id]/accept-alternative/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; import { getPayload } from "payload"; import config from "@payload-config"; +import { combineDateAndTime } from "@/lib/meeting/status"; +import { withBookingTransaction } from "@/lib/booking/transaction"; 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 }); } - const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null); - if (!booking || Number(booking.user) !== Number(user.id)) { - return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 }); - } - 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 }); - } + return await withBookingTransaction(payload, `booking:${id}`, async (req) => { + const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req }); + const booking = docs[0]; + if (!booking || Number(booking.user) !== Number(user.id)) { + return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 }); + } + 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 - // (with an advisory lock) before allowing this to become "confirmed" — - // the alternative slot may have been taken by someone else since it was - // proposed. - await payload.update({ - collection: "booking-requests", - id, - data: { - status: "confirmed", - date: alternative.date, - startTime: alternative.startTime, - endTime: alternative.endTime, - }, + const alternativeStart = combineDateAndTime(alternative.date, alternative.startTime); + if (!Number.isFinite(alternativeStart.getTime()) || alternativeStart <= new Date()) { + return NextResponse.json({ error: "Dieser Terminvorschlag liegt bereits in der Vergangenheit." }, { status: 409 }); + } + + // The beforeChange hook on booking-requests re-checks for conflicts + // (with an advisory lock) before allowing this to become "confirmed" — + // the alternative slot may have been taken by someone else since it was + // proposed. + await payload.update({ + collection: "booking-requests", + 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) { if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) { return NextResponse.json({ error: error.message }, { status: 409 }); diff --git a/app/(payload)/api/booking/[id]/cancel/route.ts b/app/(payload)/api/booking/[id]/cancel/route.ts index 8cd7f67..091935a 100644 --- a/app/(payload)/api/booking/[id]/cancel/route.ts +++ b/app/(payload)/api/booking/[id]/cancel/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { getPayload } from "payload"; import config from "@payload-config"; +import { withBookingTransaction } from "@/lib/booking/transaction"; 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 — // only creating/confirming a booking is a gated "protected" action. - const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null); - if (!booking || Number(booking.user) !== Number(user.id)) { - return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 }); - } - if (booking.status === "cancelled") { - return NextResponse.json({ ok: true }); - } + return await withBookingTransaction(payload, `booking:${id}`, async (req) => { + const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req }); + const booking = docs[0]; + if (!booking || Number(booking.user) !== Number(user.id)) { + return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 }); + } + if (booking.status === "cancelled") { + return NextResponse.json({ ok: true }); + } - await payload.update({ collection: "booking-requests", id, data: { status: "cancelled" } }); - return NextResponse.json({ ok: true }); + await payload.update({ collection: "booking-requests", id, req, data: { status: "cancelled" } }); + return NextResponse.json({ ok: true }); + }); } catch (error) { console.error("booking cancel failed", error); return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 }); diff --git a/app/(payload)/api/booking/request/route.ts b/app/(payload)/api/booking/request/route.ts index 9817eca..d078983 100644 --- a/app/(payload)/api/booking/request/route.ts +++ b/app/(payload)/api/booking/request/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from "next/server"; import { getPayload } from "payload"; import config from "@payload-config"; 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"; export const dynamic = "force-dynamic"; @@ -17,13 +19,15 @@ export async function POST(request: Request) { let body: Body; try { body = await request.json(); + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body"); } catch { return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); } const offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : ""; 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) : ""; 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 }); } + 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({ collection: "offers", 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 // Anna confirms (see collections/BookingRequests.ts). const dayBounds = berlinDayBounds(start); - const slotsByDay = await getAvailableSlots(payload, { - durationMinutes: offer.durationMinutes, - 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 }); - } + return await withBookingTransaction(payload, dayBounds.key, async (req) => { + const { docs: existing } = await payload.find({ collection: "booking-requests", req, depth: 0, limit: 1, + where: { and: [ + { user: { equals: user.id } }, { offer: { equals: offer.id } }, + { startTime: { equals: start.toISOString() } }, { appointmentType: { equals: appointmentType } }, + { status: { in: ["pending", "confirmed"] } }, + ] }, + }); + 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({ - collection: "booking-requests", - data: { - user: user.id, - offer: offer.id, - date: start.toISOString(), - startTime: start.toISOString(), - endTime: end.toISOString(), - appointmentType, - status: "pending", - userMessage: message || undefined, - }, - }); + await payload.create({ + collection: "booking-requests", + req, + data: { + user: user.id, + offer: offer.id, + date: start.toISOString(), + startTime: start.toISOString(), + endTime: end.toISOString(), + appointmentType, + status: "pending", + userMessage: message || undefined, + }, + }); - return NextResponse.json({ ok: true }); + return NextResponse.json({ ok: true }); + }); } catch (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 }); diff --git a/app/(payload)/api/booking/slots/route.ts b/app/(payload)/api/booking/slots/route.ts index 95396f8..c0d0338 100644 --- a/app/(payload)/api/booking/slots/route.ts +++ b/app/(payload)/api/booking/slots/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getPayload } from "payload"; import config from "@payload-config"; import { getAvailableSlots } from "@/lib/booking/slots"; +import { berlinDayBounds, parseBerlinDate } from "@/lib/calendar/timezone"; 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 }); } - const from = fromParam ? new Date(`${fromParam}T00:00:00`) : new Date(); - if (Number.isNaN(from.getTime())) { + const from = fromParam ? parseBerlinDate(fromParam) : berlinDayBounds(new Date()).start; + if (!from) { return NextResponse.json({ error: "Ungültiges Startdatum." }, { status: 400 }); } - from.setHours(0, 0, 0, 0); - const to = toParam ? new Date(`${toParam}T00:00:00`) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000); - if (Number.isNaN(to.getTime())) { + const toDate = toParam ? parseBerlinDate(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000); + if (!toDate) { return NextResponse.json({ error: "Ungültiges Enddatum." }, { status: 400 }); } - to.setHours(23, 59, 59, 999); + const to = berlinDayBounds(toDate).end; if (to < from) { return NextResponse.json({ error: "Das Enddatum muss nach dem Startdatum liegen." }, { status: 400 }); diff --git a/app/(payload)/api/meetings/[slug]/connect/route.ts b/app/(payload)/api/meetings/[slug]/connect/route.ts index 6ea4758..933852b 100644 --- a/app/(payload)/api/meetings/[slug]/connect/route.ts +++ b/app/(payload)/api/meetings/[slug]/connect/route.ts @@ -4,6 +4,7 @@ import config from "@payload-config"; import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit"; import { isMeetingParticipantKicked } from "@/lib/meeting/kicks"; import { verifyMeetingToken } from "@/lib/meeting/token"; +import { authorizeMeeting } from "@/lib/meeting/access"; export const dynamic = "force-dynamic"; @@ -23,11 +24,17 @@ export async function POST(request: Request, { params }: Args) { } 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) { const payload = await getPayload({ config }); 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 }); + 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({ @@ -36,6 +43,8 @@ export async function POST(request: Request, { params }: Args) { name: meetingToken.name, role: meetingToken.role, clientId: meetingToken.clientId, + accessVersion: meetingToken.accessVersion, + expiresAt, }); return NextResponse.json({ serverUrl: getLiveKitClientUrl(), participantToken }); } catch (error) { diff --git a/app/(payload)/api/meetings/[slug]/join/route.ts b/app/(payload)/api/meetings/[slug]/join/route.ts index 64a4860..3596767 100644 --- a/app/(payload)/api/meetings/[slug]/join/route.ts +++ b/app/(payload)/api/meetings/[slug]/join/route.ts @@ -3,7 +3,8 @@ import { NextResponse } from "next/server"; import { getPayload } from "payload"; import config from "@payload-config"; 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 { isMeetingParticipantKicked } from "@/lib/meeting/kicks"; @@ -20,10 +21,14 @@ type Args = { params: Promise<{ slug: string }> }; export async function POST(request: Request, { params }: Args) { 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 }; try { body = await request.json(); + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body"); } catch { 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 start = combineDateAndTime(event.date, event.startTime); - const end = event.endTime - ? 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 access = await authorizeMeeting(payload, event, isHost ? "host" : "participant"); + if (!access) { + return NextResponse.json({ error: "Dieser Termin ist nicht offen oder wurde storniert." }, { status: 403 }); } const { token, participantId } = createMeetingToken({ eventSlug: slug, name, 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 // session already, so there's no need to anchor a kick to it (and // 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({ eventSlug: slug, + accessVersion: access.accessVersion, + expiresAt: access.expiresAt, participantId, name, role: isHost ? "host" : "participant", diff --git a/app/(payload)/api/meetings/[slug]/kick/route.ts b/app/(payload)/api/meetings/[slug]/kick/route.ts index 87c6d7d..f505168 100644 --- a/app/(payload)/api/meetings/[slug]/kick/route.ts +++ b/app/(payload)/api/meetings/[slug]/kick/route.ts @@ -19,6 +19,7 @@ export async function POST(request: Request, { params }: Args) { let body: { participantId?: unknown }; try { body = await request.json(); + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body"); } catch { return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); } @@ -29,7 +30,7 @@ export async function POST(request: Request, { params }: Args) { try { const roomService = getLiveKitRoomService(); - const room = getLiveKitRoomName(slug); + const room = getLiveKitRoomName(slug, host.accessVersion); const participant = (await roomService.listParticipants(room)).find((item) => item.identity === participantId); if (!participant) return NextResponse.json({ error: "Teilnehmer nicht gefunden." }, { status: 404 }); diff --git a/app/(payload)/api/meetings/[slug]/register/route.ts b/app/(payload)/api/meetings/[slug]/register/route.ts index e1b0302..86f5e31 100644 --- a/app/(payload)/api/meetings/[slug]/register/route.ts +++ b/app/(payload)/api/meetings/[slug]/register/route.ts @@ -38,6 +38,7 @@ export async function POST(request: Request, { params }: Args) { let body: { name?: unknown; email?: unknown }; try { body = await request.json(); + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body"); } catch { return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); } diff --git a/collections/AvailabilityOverrides.ts b/collections/AvailabilityOverrides.ts index 0ea5bb4..95a50a9 100644 --- a/collections/AvailabilityOverrides.ts +++ b/collections/AvailabilityOverrides.ts @@ -14,7 +14,7 @@ export const AvailabilityOverrides: CollectionConfig = { description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.", }, access: { - read: () => true, + read: isAdmin, create: isAdmin, update: isAdmin, delete: isAdmin, diff --git a/collections/BookingRequests.ts b/collections/BookingRequests.ts index 5bc844e..b70689b 100644 --- a/collections/BookingRequests.ts +++ b/collections/BookingRequests.ts @@ -1,6 +1,8 @@ +import { randomUUID } from "node:crypto"; import { APIError } from "payload"; import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload"; import { isAdmin, isAdminFieldLevel } from "@/access"; +import { combineDateAndTime } from "@/lib/meeting/status"; import { acquireBookingDayLock } from "@/lib/booking/lock"; import { alternativeProposedEmail, @@ -12,7 +14,7 @@ import { } from "@/lib/email/bookingTemplates"; import { sendEmail } from "@/lib/email/sendBookingEmails"; 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 = [ { 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))); } -// Runs whenever a booking is about to become "confirmed": locks the day -// (see lib/booking/lock.ts), re-checks for overlapping confirmed bookings -// inside that same lock/transaction, and — for online appointments — creates -// the linked video-call Event (reusing the existing meeting/password system) -// 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; - } - +// Normalize Payload time-only inputs before conflict checks and synchronize +// the private meeting whenever a booking is moved, cancelled or reactivated. +export const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => { + const status = data.status ?? originalDoc?.status ?? "pending"; const date = data.date ?? originalDoc?.date; const startTime = data.startTime ?? originalDoc?.startTime; const endTime = data.endTime ?? originalDoc?.endTime; 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 dayBounds = berlinDayBounds(new Date(date)); - const dayStart = dayBounds.start.toISOString(); - const dayEnd = dayBounds.end.toISOString(); - const conflictClauses: Where[] = [ - { status: { equals: "confirmed" } }, - { date: { greater_than_equal: dayStart } }, - { date: { less_than_equal: dayEnd } }, - ]; - if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } }); - - 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, - ); + const alternative = data.proposedAlternative; + if (alternative && (alternative.date || alternative.startTime || alternative.endTime)) { + if (!alternative.date || !alternative.startTime || !alternative.endTime) { + throw new APIError("Bitte den alternativen Termin vollständig angeben.", 400, undefined, true); + } + const altStart = combineDateAndTime(alternative.date, alternative.startTime); + const altEnd = combineDateAndTime(alternative.date, alternative.endTime); + if (!Number.isFinite(altStart.getTime()) || !Number.isFinite(altEnd.getTime()) || altEnd <= altStart) { + throw new APIError("Der alternative Zeitraum ist ungültig.", 400, undefined, true); + } + data.proposedAlternative = { date: altStart.toISOString(), startTime: altStart.toISOString(), endTime: altEnd.toISOString() }; } - if (appointmentType === "online" && !data.linkedEvent && !originalDoc?.linkedEvent) { - const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc?.offer, req })) as Offer; - const event = (await req.payload.create({ - collection: "events", - req, - data: { - title: offer.title, - date, - startTime, - endTime, - category: "sonstiges", - isOnline: true, // Events' own beforeChange hook auto-generates the password. - isPrivateBooking: true, - // Only set on the update-to-confirmed path — on create-as-confirmed - // this booking doesn't have an id yet (assigned after insert), so - // the back-reference is simply left blank in that one case. - ...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}), - }, - // Created as a draft so it never appears in public /termine listings - // (see lib/payload/content.ts) — it's still reachable via its direct - // join link, which is exactly what a private 1:1 booking needs. - draft: true, - })) as Event; + if (status === "confirmed") { + await acquireBookingDayLock(req, dateKey(data.date)); + const conflictClauses: Where[] = [ + { status: { equals: "confirmed" } }, + { startTime: { less_than: data.endTime } }, + { endTime: { greater_than: data.startTime } }, + ]; + if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } }); + const { totalDocs } = await req.payload.count({ collection: "booking-requests", where: { and: conflictClauses }, req }); + if (totalDocs) throw new APIError("Dieser Zeitraum ist bereits mit einem bestätigten Termin belegt.", 409, undefined, true); + } + + const linked = data.linkedEvent ?? originalDoc?.linkedEvent; + const linkedId = typeof linked === "object" ? linked?.id : linked; + if (linkedId) { + const existing = await req.payload.findByID({ collection: "events", id: linkedId, depth: 0, req }); + if (existing.isPrivateBooking) { + const isOnline = status === "confirmed" && appointmentType === "online"; + if (existing.date !== data.date || existing.startTime !== data.startTime || existing.endTime !== data.endTime || existing.isOnline !== isOnline) { + await req.payload.update({ collection: "events", id: existing.id, req, data: { + date: data.date, startTime: data.startTime, endTime: data.endTime, isOnline, + } }); + } + } + } 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; } - return data; }; @@ -183,7 +170,8 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper 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 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") { await sendEmail( user.email, @@ -282,7 +270,7 @@ export const BookingRequests: CollectionConfig = { // setting their own status to "confirmed" directly. create: isAdmin, 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 } }; return false; }, @@ -290,8 +278,19 @@ export const BookingRequests: CollectionConfig = { delete: isAdmin, }, hooks: { + beforeOperation: [async ({ args, operation, req }) => { + if (operation === "create" || operation === "update" || operation === "delete") { + await acquireBookingDayLock(req, "booking-calendar"); + } + return args; + }], beforeChange: [handleConfirmation], 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: [ { diff --git a/collections/Customers.ts b/collections/Customers.ts index 6d58420..c876443 100644 --- a/collections/Customers.ts +++ b/collections/Customers.ts @@ -1,4 +1,6 @@ import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload"; +import { APIError } from "payload"; +import { allowVerificationEmail } from "@/lib/auth/verificationRateLimit"; import { isAdmin, isAdminFieldLevel } from "@/access"; import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification"; import { verificationEmail } from "@/lib/email/authTemplates"; @@ -35,6 +37,10 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc, data.email !== originalDoc.email; 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(); data.emailVerified = false; data.emailVerificationTokenHash = hashVerificationToken(token); @@ -46,6 +52,7 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc, const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => { const token = req.context.pendingVerificationToken; if (typeof token !== "string") return doc; + delete req.context.pendingVerificationToken; try { const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000"; await sendEmail( diff --git a/collections/Events.ts b/collections/Events.ts index bdc114f..6b36a2c 100644 --- a/collections/Events.ts +++ b/collections/Events.ts @@ -3,6 +3,7 @@ import { isAdmin, isAdminFieldLevel, publishedOrAdmin } from "@/access"; import { slugField } from "@/fields/slug"; import { seoFields } from "@/fields/seo"; import { generateMeetingPassword } from "@/lib/meeting/password"; +import { revokeChangedMeeting } from "@/lib/meeting/revoke"; export const EVENT_CATEGORIES = [ { label: "Prozessbegleitung", value: "prozessbegleitung" }, @@ -18,9 +19,9 @@ export const EVENT_CATEGORIES = [ // Auto-generates the meeting password for new online events, and // regenerates it whenever the admin ticks "Neues Passwort generieren". // Never derived from the event id/slug — always a fresh random value. -const setMeetingPassword: CollectionBeforeChangeHook = ({ data }) => { - if (!data?.isOnline) return data; - if (!data.meetingPassword || data.regeneratePassword) { +const setMeetingPassword: CollectionBeforeChangeHook = ({ data, originalDoc }) => { + if (!(data.isOnline ?? originalDoc?.isOnline)) return data; + if (!(data.meetingPassword ?? originalDoc?.meetingPassword) || data.regeneratePassword || originalDoc?.isOnline === false) { data.meetingPassword = generateMeetingPassword(); } data.regeneratePassword = false; @@ -52,6 +53,7 @@ export const Events: CollectionConfig = { }, hooks: { beforeChange: [setMeetingPassword], + afterChange: [revokeChangedMeeting], }, fields: [ { diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx index a39a658..712e34c 100644 --- a/components/auth/LoginForm.tsx +++ b/components/auth/LoginForm.tsx @@ -4,6 +4,7 @@ import { useState, type FormEvent } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { ResendVerificationButton } from "./ResendVerificationButton"; import { TextField, FormError } from "@/components/FormField"; +import { safeLoginRedirect } from "@/lib/auth/redirect"; export function LoginForm() { const router = useRouter(); @@ -32,7 +33,7 @@ export function LoginForm() { setLoading(false); return; } - router.push(searchParams.get("next") || "/konto"); + router.push(safeLoginRedirect(searchParams.get("next"))); router.refresh(); } catch { setError("Verbindung fehlgeschlagen. Bitte versuche es erneut."); diff --git a/components/booking/BookingCard.tsx b/components/booking/BookingCard.tsx index 66b74e6..85ace66 100644 --- a/components/booking/BookingCard.tsx +++ b/components/booking/BookingCard.tsx @@ -3,18 +3,18 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; 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 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 [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const offer = typeof booking.offer === "object" ? (booking.offer as Offer) : null; - const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null; - const displayStatus = resolveDisplayStatus(booking.status, booking.date); + const offer = booking.offer; + const event = booking.linkedEvent; + const displayStatus = resolveDisplayStatus(booking.status, booking.endTime); const hasAlternative = booking.status === "pending" && Boolean(booking.proposedAlternative?.date); async function callAction(path: string) { diff --git a/components/booking/BookingWidget.tsx b/components/booking/BookingWidget.tsx index 605bd5f..21c6372 100644 --- a/components/booking/BookingWidget.tsx +++ b/components/booking/BookingWidget.tsx @@ -27,6 +27,7 @@ export function BookingWidget({ }) { const [slotsByDay, setSlotsByDay] = useState({}); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); const [selectedDay, setSelectedDay] = useState(null); const [selectedSlot, setSelectedSlot] = useState(null); const [appointmentType, setAppointmentType] = useState<"onsite" | "online">("onsite"); @@ -35,12 +36,15 @@ export function BookingWidget({ const [result, setResult] = useState<{ ok: boolean; error?: string } | null>(null); useEffect(() => { + const controller = new AbortController(); const from = new Date(); const to = new Date(from.getTime() + 28 * 24 * 60 * 60 * 1000); - fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`) - .then((res) => res.json()) + fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`, { signal: controller.signal }) + .then((res) => { if (!res.ok) throw new Error("Slots unavailable"); return res.json(); }) .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]); if (result?.ok) { @@ -126,11 +130,12 @@ export function BookingWidget({ if (loading) { return

Verfügbare Termine werden geladen …

; } + if (loadError) return Verfügbare Termine konnten nicht geladen werden. Bitte lade die Seite erneut.; return (
- + { setSelectedDay(day); setSelectedSlot(null); }} selectedDay={selectedDay} />
{!selectedDay &&

Wähle einen markierten Tag, um freie Zeiten zu sehen.

} {selectedDay && daySlots.length === 0 && ( diff --git a/components/booking/PersonalCalendar.tsx b/components/booking/PersonalCalendar.tsx index 1897ed7..7cd4e02 100644 --- a/components/booking/PersonalCalendar.tsx +++ b/components/booking/PersonalCalendar.tsx @@ -4,16 +4,17 @@ import { useState } from "react"; import { MonthCalendar, type CalendarMarker } from "./MonthCalendar"; import { BookingCard } from "./BookingCard"; 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 { 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(null); const markers: CalendarMarker[] = bookings.map((b) => ({ 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 diff --git a/components/meeting/CallRoom.tsx b/components/meeting/CallRoom.tsx index 9f1ff1d..61a7da3 100644 --- a/components/meeting/CallRoom.tsx +++ b/components/meeting/CallRoom.tsx @@ -171,9 +171,9 @@ export function CallRoom({ slug }: { slug: string }) { localTracksRef.current = previewTracks; try { - let serverUrl = session.serverUrl; - let participantToken = session.participantToken; - if (!serverUrl || !participantToken) { + let serverUrl: string; + let participantToken: string; + { const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/connect`, { method: "POST", headers: { Authorization: `Bearer ${session.token}` }, diff --git a/lib/auth/rateLimit.ts b/lib/auth/rateLimit.ts index d2a8077..b396705 100644 --- a/lib/auth/rateLimit.ts +++ b/lib/auth/rateLimit.ts @@ -13,16 +13,17 @@ const MAX_TRACKED_KEYS = 5000; function sweepExpired(now: number) { 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 { 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); - 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 }); return true; } @@ -31,7 +32,7 @@ export function checkRateLimit(key: string, opts: { max: number; windowMs: numbe 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.md — Nginx Proxy Manager; the app port isn't published // directly to the host). nginx's default `$proxy_add_x_forwarded_for` diff --git a/lib/auth/redirect.ts b/lib/auth/redirect.ts new file mode 100644 index 0000000..c5c8362 --- /dev/null +++ b/lib/auth/redirect.ts @@ -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; +} diff --git a/lib/auth/verificationRateLimit.ts b/lib/auth/verificationRateLimit.ts new file mode 100644 index 0000000..8cc9da4 --- /dev/null +++ b/lib/auth/verificationRateLimit.ts @@ -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 }); +} diff --git a/lib/booking/customerView.ts b/lib/booking/customerView.ts new file mode 100644 index 0000000..60ff90a --- /dev/null +++ b/lib/booking/customerView.ts @@ -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; diff --git a/lib/booking/lock.ts b/lib/booking/lock.ts index 0e3fab9..6e76f29 100644 --- a/lib/booking/lock.ts +++ b/lib/booking/lock.ts @@ -13,13 +13,14 @@ import type { PayloadRequest } from "payload"; */ export async function acquireBookingDayLock(req: PayloadRequest, dateKey: string): Promise { 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 { sessions?: Record Promise } }>; drizzle: { execute: (query: unknown) => Promise }; }; - 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}))`); } diff --git a/lib/booking/meetingAccess.ts b/lib/booking/meetingAccess.ts index 2f65afe..6b2ab0a 100644 --- a/lib/booking/meetingAccess.ts +++ b/lib/booking/meetingAccess.ts @@ -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"; -export function getBookingMeetingStatus(booking: BookingRequest, settings: MeetingSetting): MeetingStatus | null { - const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null; +export function getBookingMeetingStatus(booking: CustomerBooking, settings: MeetingSetting): MeetingStatus | null { + const event = booking.linkedEvent; if (booking.status !== "confirmed" || booking.appointmentType !== "online" || !event?.isOnline) return null; 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); return status !== null && canJoinMeeting(status); } diff --git a/lib/booking/queries.ts b/lib/booking/queries.ts index e8aaa34..36e99bd 100644 --- a/lib/booking/queries.ts +++ b/lib/booking/queries.ts @@ -1,13 +1,13 @@ import { cache } from "react"; 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 * current customer is (see lib/auth/customer.ts) — the `where: { user }` * filter below is what actually scopes results to that one customer. */ -export const getCustomerBookings = cache(async (customerId: number): Promise => { +export const getCustomerBookings = cache(async (customerId: number): Promise => { const payload = await getCMS(); const result = await payload.find({ collection: "booking-requests", @@ -16,10 +16,10 @@ export const getCustomerBookings = cache(async (customerId: number): Promise { +export async function getCustomerBookingById(customerId: number, id: number): Promise { const payload = await getCMS(); const result = await payload.find({ collection: "booking-requests", @@ -27,5 +27,5 @@ export async function getCustomerBookingById(customerId: number, id: number): Pr depth: 2, limit: 1, }); - return result.docs[0] ?? null; + return result.docs[0] ? customerBookingView(result.docs[0]) : null; } diff --git a/lib/booking/slots.ts b/lib/booking/slots.ts index 883a304..f95b5f9 100644 --- a/lib/booking/slots.ts +++ b/lib/booking/slots.ts @@ -1,4 +1,4 @@ -import type { Payload } from "payload"; +import type { Payload, PayloadRequest } from "payload"; import { WEEKDAYS } from "@/collections/Availability"; 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( payload: Payload, - args: { durationMinutes: number; from: Date; to: Date }, + args: { durationMinutes: number; from: Date; to: Date; req?: PayloadRequest }, ): Promise> { - const { durationMinutes, from, to } = args; + const { durationMinutes, from, to, req } = args; const result = new Map(); + 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([ - payload.find({ collection: "availability", where: { active: { equals: true } }, limit: 100 }), + payload.find({ collection: "availability", where: { active: { equals: true } }, pagination: false, req }), payload.find({ collection: "availability-overrides", where: { and: [{ date: { greater_than_equal: from.toISOString() } }, { date: { less_than_equal: to.toISOString() } }] }, - limit: 200, + pagination: false, req, }), payload.find({ collection: "booking-requests", @@ -84,7 +87,7 @@ export async function getAvailableSlots( { 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); if (slotStart < now) 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; diff --git a/lib/booking/transaction.ts b/lib/booking/transaction.ts new file mode 100644 index 0000000..e175504 --- /dev/null +++ b/lib/booking/transaction.ts @@ -0,0 +1,17 @@ +import { commitTransaction, createLocalReq, initTransaction, killTransaction, type Payload, type PayloadRequest } from "payload"; +import { acquireBookingDayLock } from "./lock"; + +export async function withBookingTransaction(payload: Payload, key: string, action: (req: PayloadRequest) => Promise): Promise { + 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; + } +} diff --git a/lib/calendar/timezone.ts b/lib/calendar/timezone.ts index 4f793f8..abe22e9 100644 --- a/lib/calendar/timezone.ts +++ b/lib/calendar/timezone.ts @@ -2,6 +2,15 @@ const BERLIN_TZ = "Europe/Berlin"; 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 { const { year, month, day } = berlinParts(date); return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; diff --git a/lib/email/transport.ts b/lib/email/transport.ts index eac57ab..5825a26 100644 --- a/lib/email/transport.ts +++ b/lib/email/transport.ts @@ -15,6 +15,9 @@ export function getMailTransport(): nodemailer.Transporter { host: SMTP_HOST, port: Number(SMTP_PORT), secure: Number(SMTP_PORT) === 465, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 20000, auth: { user: SMTP_USER, pass: SMTP_PASSWORD }, }); return cached; diff --git a/lib/meeting/access.ts b/lib/meeting/access.ts new file mode 100644 index 0000000..5d57c50 --- /dev/null +++ b/lib/meeting/access.ts @@ -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 }; +} diff --git a/lib/meeting/livekit.ts b/lib/meeting/livekit.ts index 2349ad3..b02bf48 100644 --- a/lib/meeting/livekit.ts +++ b/lib/meeting/livekit.ts @@ -17,9 +17,9 @@ function getLiveKitApiUrl(): string { } /** 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"); - 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: { @@ -28,12 +28,18 @@ export async function createLiveKitToken(args: { name: string; role: MeetingRole; clientId?: string; + accessVersion?: string; + expiresAt: number; }): Promise { - 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"), { identity: args.participantId, 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 }), }); token.addGrant({ diff --git a/lib/meeting/revoke.ts b/lib/meeting/revoke.ts new file mode 100644 index 0000000..80b9775 --- /dev/null +++ b/lib/meeting/revoke.ts @@ -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 = 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; +}; diff --git a/lib/meeting/status.ts b/lib/meeting/status.ts index 0df4611..7cc5cbe 100644 --- a/lib/meeting/status.ts +++ b/lib/meeting/status.ts @@ -36,6 +36,8 @@ export function getMeetingStatus({ const closeAt = end.getTime() + closeAfterMinutes * 60_000; const t = now.getTime(); + if (![openAt, closeAt, t].every(Number.isFinite) || end <= start) return "ended"; + if (t >= closeAt) return "ended"; if (t >= start.getTime()) return "live"; if (t >= openAt) return "open"; diff --git a/lib/meeting/token.ts b/lib/meeting/token.ts index 570195b..4a6efbb 100644 --- a/lib/meeting/token.ts +++ b/lib/meeting/token.ts @@ -17,6 +17,8 @@ export type MeetingTokenPayload = { clientId?: string; name: string; role: MeetingRole; + accessVersion?: string; + testRoom?: boolean; exp: number; // unix ms }; @@ -41,6 +43,8 @@ export function createMeetingToken(args: { role: MeetingRole; clientId?: string; ttlMs?: number; + accessVersion?: string; + testRoom?: boolean; }): { token: string; participantId: string } { const participantId = randomUUID(); const clientId = @@ -53,6 +57,8 @@ export function createMeetingToken(args: { clientId, name: args.name, role: args.role, + accessVersion: args.accessVersion, + testRoom: args.testRoom, exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default }; 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; if ( !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" || payload.eventSlug.length > 200 || typeof payload.participantId !== "string" || diff --git a/package-lock.json b/package-lock.json index b4a180c..9c6da15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -566,395 +566,6 @@ "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": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", @@ -5999,9 +5610,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", - "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", "peer": true, "optionalDependencies": { diff --git a/package.json b/package.json index 6aa2e3c..6dbd688 100644 --- a/package.json +++ b/package.json @@ -46,5 +46,11 @@ "eslint-config-next": "^16.3.5", "tailwindcss": "^4", "typescript": "^5" + }, + "overrides": { + "dompurify": "^3.4.15", + "@esbuild-kit/core-utils": { + "esbuild": "^0.25.12" + } } } diff --git a/tests/security.test.ts b/tests/security.test.ts new file mode 100644 index 0000000..0204eff --- /dev/null +++ b/tests/security.test.ts @@ -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[0]), false); +}); + +function hookArgs(data: Record, originalDoc: Record, payload: Record) { + return { data, originalDoc, req: { transactionID: "fixture", payload: { ...payload, + db: { sessions: { fixture: { db: { execute: async () => undefined } } } }, + } } as unknown as PayloadRequest } as Parameters[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[0]); + assert.equal(unchanged.meetingPassword, undefined); + const changed = await hook({ data: { regeneratePassword: true }, originalDoc: event } as unknown as Parameters[0]); + assert.ok(changed.meetingPassword); + assert.notEqual(changed.meetingPassword, event.meetingPassword); +});