diff --git a/.env.example b/.env.example index 8bc44b2..ae4944f 100644 --- a/.env.example +++ b/.env.example @@ -51,13 +51,14 @@ SMTP_USER= SMTP_PASSWORD= SMTP_FROM="ANOUMA " -# ── WebRTC (STUN/TURN) ────────────────────────────────────────────────── -# P2P works with just STUN for most networks. Add a TURN server later for -# restrictive NATs/firewalls — no code changes needed, just set these. -STUN_SERVER=stun:stun.l.google.com:19302 -TURN_SERVER= -TURN_USERNAME= -TURN_PASSWORD= +# ── LiveKit SFU / embedded TURN ───────────────────────────────────────── +# Public HTTPS endpoint of the self-hosted LiveKit server. The app converts +# https:// to wss:// for browsers and uses the same endpoint for its API. +# Put a trusted TLS reverse proxy in front of LiveKit (normally on :7880). +LIVEKIT_URL=https://livekit.example.com +# Keep these values private. setup.sh generates them automatically. +LIVEKIT_API_KEY=LKreplace-with-setup-secret +LIVEKIT_API_SECRET=replace-with-setup-secret # ── SEO ────────────────────────────────────────────────────────────────── # Optional Google Search Console domain-verification token (Search Console → diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index dbaf71d..f0d6e06 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -130,26 +130,49 @@ In Nginx Proxy Manager, add a Proxy Host: | Scheme | `http` | | Forward Host | `app` (the Compose service name) | | Forward Port | `3000` | -| **Websockets Support** | **enabled** — required for the video-call signaling (`/ws/signaling`) | +| **Websockets Support** | **enabled** — required for the LiveKit signal connection | -## WebRTC: signaling, STUN, TURN +Für LiveKit legst du zusätzlich einen zweiten Proxy Host an: + +| Field | Value | +| --- | --- | +| Domain | z. B. `livekit.anouma.org` | +| Scheme | `http` | +| Forward Host | der Server, auf dem LiveKit läuft | +| Forward Port | `7880` | +| **Websockets Support** | **enabled** | + +In DNS muss die LiveKit-Domain auf denselben Server zeigen. Das Zertifikat +muss von einer öffentlichen CA stammen; selbst signierte Zertifikate werden +von Browsern für LiveKit nicht akzeptiert. + +## WebRTC: LiveKit SFU und eingebettetes TURN ``` -Client A Client B - │ │ - │ WebSocket (wss://…/ws/signaling) │ - ▼ ▼ - Signaling server (in the "app" container) - │ - ▼ - relays only small JSON offer/answer/ICE - messages — never touches audio/video/screen - -Client A ═══════════ P2P WebRTC ═══════════ Client B - Audio / Video / Screen +Client A ── WebRTC ──┐ + ├── LiveKit SFU ── WebRTC ── Client B +Client C ── WebRTC ──┘ │ + └── embedded TURN fallback ``` -STUN is on by default (`STUN_SERVER` in `.env`). Add a TURN server for restrictive NATs/firewalls by setting `TURN_SERVER`/`TURN_USERNAME`/`TURN_PASSWORD` and redeploying — no code changes needed. +`docker-compose.yml` startet LiveKit mit Host-Networking, dem UDP-Medienbereich +`50000–60000`, dem TCP-Fallback `7881` und eingebettetem TURN/UDP auf `3478`. +Die API-Keys werden von `setup.sh` generiert und nur an den LiveKit-Container +und die serverseitige Token-Route gegeben. Für den Browser ist ausschließlich +`LIVEKIT_URL` als `wss://`-Endpoint sichtbar. + +Firewall/NAT-Freigaben auf dem Server: + +```text +TCP 443 LiveKit über den TLS-Reverse-Proxy +TCP 7881 LiveKit-TCP-Fallback +UDP 3478 eingebettetes TURN +UDP 50000-60000 LiveKit-Medien +``` + +Für genau einen parallelen kleinen Call ist kein Redis und keine GPU nötig. +Bei mehreren LiveKit-Instanzen oder deutlich höherer Last kommt Redis bzw. ein +Load-Balancer dazu. ## Environment variables @@ -161,7 +184,7 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file | `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards | | `NEXT_PUBLIC_SERVER_URL` | Public URL of the site | | `SMTP_*` | The existing ANOUMA mail system | -| `STUN_SERVER`, `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` | WebRTC ICE servers | +| `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | eigener LiveKit-SFU und serverseitige Token-Erzeugung | | `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names | | `BACKUP_RETENTION_DAYS` | Deploy-backup retention | | `REGISTRY`, `REGISTRY_REPO`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD` | Where `deploy.sh` pulls the image from | @@ -182,7 +205,7 @@ Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volu - **app**: `GET /api/health` (built into the image's `HEALTHCHECK`) — checks that the app can actually query Postgres, not just that the process is listening. - **postgres**: `pg_isready`. -`docker compose ps` (from `/opt/anouma`) shows both statuses. +`docker compose ps` (from `/opt/anouma`) shows the app, Postgres and LiveKit services. ## Security @@ -201,6 +224,6 @@ Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volu | Release workflow fails at "Pull the tested image" | The tagged commit was never pushed to `main`, or `main`'s CI/build-image run for it hasn't finished yet — push to `main`, wait for CI to go green, then tag | | Release workflow fails at "Deploy to production" | Check the Gitea secrets are all set correctly, and that the server's SSH port is reachable from the runner | | App container unhealthy after a deploy | `ssh` directly to the server (not via the restricted key) and run `docker compose logs app` from `/opt/anouma` | -| Video calls connect but no audio/video | Likely a restrictive NAT — configure a TURN server (see above) | -| Reverse proxy shows a 502/connection reset on the call page | Websockets Support isn't enabled on the Nginx Proxy Manager proxy host | +| Video calls connect but no audio/video | Check LiveKit logs, the UDP 3478/50000–60000 firewall rules and the public `LIVEKIT_URL` | +| Browser cannot connect to LiveKit | Check the LiveKit proxy host, trusted TLS certificate and that Websockets Support is enabled | | Need to redeploy the same version | `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`, or push a new patch tag | diff --git a/Dockerfile b/Dockerfile index da20f89..5d1175c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# Multi-stage build for the ANOUMA app (Next.js + Payload CMS + WebRTC -# signaling, all served by the custom server.ts — see next.config.ts for why +# Multi-stage build for the ANOUMA app (Next.js + Payload CMS + LiveKit SFU +# integration, all served by the custom server.ts — see next.config.ts for why # this can't use Next's "standalone" output). DATABASE_URI/PAYLOAD_SECRET/etc. # are only needed at *runtime*, not at build time (see docker-compose.yml and # .env.example) — this image builds without a database connection. diff --git a/README.md b/README.md index 2184658..1ab93f6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Anouma -Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload CMS](https://payloadcms.com) (PostgreSQL) für Termine, Beiträge, Angebote und Seiteninhalte, plus Online-Termine mit P2P-Video-Call (WebRTC), Anmeldungen und E-Mail-Erinnerungen. +Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload CMS](https://payloadcms.com) (PostgreSQL) für Termine, Beiträge, Angebote und Seiteninhalte, plus Online-Termine mit selbst gehostetem [LiveKit](https://livekit.io) SFU-Video-Call, Anmeldungen und E-Mail-Erinnerungen. ## Projektstruktur @@ -10,11 +10,11 @@ Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload C - `app/global-not-found.tsx` — statische 404-Seite (siehe unten, warum sie nötig ist) - `collections/`, `globals/`, `access/`, `fields/`, `payload.config.ts` — die CMS-Konfiguration - `lib/payload/` — Local-API-Zugriffe für die öffentliche Website (mit `React.cache` pro Request memoisiert) -- `lib/meeting/` — Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, WebSocket-Signaling, Reminder-Logik +- `lib/meeting/` — Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, LiveKit-SFU-Token und Reminder-Logik - `lib/email/` — SMTP-Versand (nodemailer) und das E-Mail-Template für Reminder - `lib/texte.ts` — die ursprünglichen Anouma-Texte, nur noch als Seed-Quelle verwendet - `scripts/seed.ts` — überträgt die vorhandenen Inhalte ins CMS -- `server.ts` — eigener Node-Server (statt `next start`), weil daran der WebRTC-Signaling-WebSocket hängt +- `server.ts` — eigener Node-Server (statt `next start`), passend zur bestehenden Payload-Konfiguration Zwei bzw. drei Root-Layouts (`(frontend)`, `(payload)`, `(call)`) bedeuten: Next.js kann daraus keine einzelne 404-Seite komponieren, deshalb gibt es `app/global-not-found.tsx` (siehe `experimental.globalNotFound` in `next.config.ts`). @@ -24,8 +24,8 @@ Zwei bzw. drei Root-Layouts (`(frontend)`, `(payload)`, `(call)`) bedeuten: Next - Öffentliche Beitrittsseite: `/termine/[slug]/beitreten` (Name + Passwort, beide Pflicht) → bei Erfolg `/termine/[slug]/call`. - Ist die aufrufende Person im selben Browser als Admin eingeloggt, wird sie automatisch als **Host** erkannt (kein Passwort nötig, größeres Beitritts-Zeitfenster, exklusive Steuerung: Bildschirmfreigabe, Teilnehmer entfernen). - Die Zeitfenster (wie früh Host/Teilnehmer beitreten dürfen, wie lange das Meeting nach Ende offen bleibt) sind zentral im CMS unter **Video-Termin-Einstellungen** konfigurierbar. -- Server-seitige Validierung: `/api/meetings/[slug]/join` prüft Termin, Zeitfenster und Passwort und stellt danach erst ein kurzlebiges, signiertes Sitzungs-Token aus (`MEETING_SESSION_SECRET`). Das WebSocket-Signaling (`/ws/signaling`, siehe `server.ts`) prüft dieses Token erneut, bevor jemand einem Raum beitreten darf. -- Reines P2P-WebRTC: Der Server relayt nur kleine Signaling-Nachrichten (Angebot/Antwort/ICE), niemals Audio/Video/Bildschirmfreigabe. Es wird nichts aufgezeichnet oder gespeichert. +- Server-seitige Validierung: `/api/meetings/[slug]/join` prüft Termin, Zeitfenster und Passwort und stellt danach erst ein kurzlebiges, signiertes Sitzungs-Token aus (`MEETING_SESSION_SECRET`). Ein zweiter Endpoint tauscht dieses App-Token gegen ein kurzlebiges LiveKit-Token aus. +- LiveKit läuft als SFU auf dem eigenen Server: Audio, Video und Bildschirm werden nicht zwischen allen Browsern vermasht, sondern einmal zum SFU gesendet und gezielt an die anderen Teilnehmer verteilt. Der Server zeichnet nichts auf oder speichert Medien. - Teilnehmer-Anmeldungen laufen über die Collection **Anmeldungen** (`event-registrations`), öffentlich erreichbar über `/api/meetings/[slug]/register`. ### E-Mail-Erinnerungen @@ -59,7 +59,7 @@ git tag v1.2.0 && git push origin v1.2.0 Gitea Actions testet, baut das Image, pusht es in die Registry und deployt es per SSH auf den Server — inklusive Backup, Healthcheck und automatischem Rollback bei Fehlschlag. Es gibt kein separates Update-Script. -Für alle Details (Architektur, Gitea Secrets, CI/Release-Workflows, NPM-Reverse-Proxy-Einrichtung, Environment-Variablen, WebRTC/STUN/TURN, Backups, Rollback, Security) siehe **[DEPLOYMENT.md](./DEPLOYMENT.md)**. +Für alle Details (Architektur, Gitea Secrets, CI/Release-Workflows, Reverse-Proxy- und LiveKit-Einrichtung, Environment-Variablen, Backups, Rollback, Security) siehe **[DEPLOYMENT.md](./DEPLOYMENT.md)**. ## Lokale Entwicklung (ohne Docker für die App) diff --git a/app/(frontend)/konto/kalender/page.tsx b/app/(frontend)/konto/kalender/page.tsx index 0e6b41f..3f79d97 100644 --- a/app/(frontend)/konto/kalender/page.tsx +++ b/app/(frontend)/konto/kalender/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { getCurrentCustomer } from "@/lib/auth/customer"; import { getCustomerBookings } from "@/lib/booking/queries"; import { PersonalCalendar } from "@/components/booking/PersonalCalendar"; +import { getMeetingSettingsGlobal } from "@/lib/payload/globals"; export const metadata: Metadata = { title: "Mein Kalender" }; @@ -10,12 +11,13 @@ export default async function KontoKalenderPage() { if (!customer) return null; const bookings = await getCustomerBookings(customer.id); + const meetingSettings = await getMeetingSettingsGlobal(); return (

Mein Kalender

- +
); diff --git a/app/(frontend)/konto/page.tsx b/app/(frontend)/konto/page.tsx index ffed82e..f541ed4 100644 --- a/app/(frontend)/konto/page.tsx +++ b/app/(frontend)/konto/page.tsx @@ -3,6 +3,8 @@ import { getCurrentCustomer } from "@/lib/auth/customer"; import { getCustomerBookings } from "@/lib/booking/queries"; import { BookingCard } from "@/components/booking/BookingCard"; import { ArrowLink } from "@/components/ArrowLink"; +import { getMeetingSettingsGlobal } from "@/lib/payload/globals"; +import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess"; export const metadata: Metadata = { title: "Mein Konto" }; @@ -11,6 +13,7 @@ export default async function KontoOverviewPage() { if (!customer) return null; // Layout already redirects; keeps TS happy. 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"); @@ -53,7 +56,7 @@ export default async function KontoOverviewPage() { ) : (
{confirmed.map((b) => ( - + ))}
)} diff --git a/app/(frontend)/konto/termine/page.tsx b/app/(frontend)/konto/termine/page.tsx index 7053119..42c4809 100644 --- a/app/(frontend)/konto/termine/page.tsx +++ b/app/(frontend)/konto/termine/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { getCurrentCustomer } from "@/lib/auth/customer"; import { getCustomerBookings } from "@/lib/booking/queries"; import { BookingCard } from "@/components/booking/BookingCard"; +import { getMeetingSettingsGlobal } from "@/lib/payload/globals"; +import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess"; export const metadata: Metadata = { title: "Meine Termine" }; @@ -10,6 +12,7 @@ export default async function KontoTerminePage() { if (!customer) return null; const bookings = await getCustomerBookings(customer.id); + const meetingSettings = await getMeetingSettingsGlobal(); return (
@@ -21,7 +24,7 @@ export default async function KontoTerminePage() { ) : (
{bookings.map((b) => ( - + ))}
)} diff --git a/app/(frontend)/termine/[slug]/beitreten/page.tsx b/app/(frontend)/termine/[slug]/beitreten/page.tsx index d32ddd3..8eaeb65 100644 --- a/app/(frontend)/termine/[slug]/beitreten/page.tsx +++ b/app/(frontend)/termine/[slug]/beitreten/page.tsx @@ -5,6 +5,7 @@ import { JoinForm } from "@/components/meeting/JoinForm"; import { OrganicBlob } from "@/components/OrganicBlob"; import { getEventForJoin } from "@/lib/payload/content"; import { combineDateAndTime } from "@/lib/meeting/status"; +import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone"; export const dynamic = "force-dynamic"; @@ -21,8 +22,8 @@ export default async function BeitretenPage({ params }: Args) { if (!event || !event.isOnline) notFound(); const start = combineDateAndTime(event.date, event.startTime); - const dateLabel = start.toLocaleDateString("de-DE", { day: "2-digit", month: "long" }); - const timeLabel = start.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); + const dateLabel = formatBerlinDate(start, { day: "2-digit", month: "long" }); + const timeLabel = formatBerlinTime(start, { hour: "2-digit", minute: "2-digit" }); return (
diff --git a/app/(frontend)/termine/[slug]/page.tsx b/app/(frontend)/termine/[slug]/page.tsx index 1f61041..2ba1cb7 100644 --- a/app/(frontend)/termine/[slug]/page.tsx +++ b/app/(frontend)/termine/[slug]/page.tsx @@ -76,7 +76,7 @@ export default async function EventDetailPage({ params }: Args) { const { user } = await payload.auth({ headers: await headers() }); // Must check the collection, not just presence — a logged-in customer // (collection "customers") must never be treated as host. - const isHost = user?.collection === "users"; + const isHost = user?.collection === "users" && user.role === "admin"; const settings = await payload.findGlobal({ slug: "meeting-settings" }); const start = combineDateAndTime(event.date, event.startTime); const end = event.endTime diff --git a/app/(frontend)/termine/page.tsx b/app/(frontend)/termine/page.tsx index 6f86114..2666a3b 100644 --- a/app/(frontend)/termine/page.tsx +++ b/app/(frontend)/termine/page.tsx @@ -21,7 +21,9 @@ export default async function TerminePage() { const events = await getAllEvents(); const today = new Date(); today.setHours(0, 0, 0, 0); - const upcoming = events.filter((e) => new Date(e.date) >= today).reverse(); + const upcoming = events + .filter((e) => new Date(e.date) >= today) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); const past = events.filter((e) => new Date(e.date) < today); const siteUrl = await getSiteUrl(); diff --git a/app/(payload)/api/admin/meetings/start/route.ts b/app/(payload)/api/admin/meetings/start/route.ts new file mode 100644 index 0000000..77cf9f3 --- /dev/null +++ b/app/(payload)/api/admin/meetings/start/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { getPayload } from "payload"; +import config from "@payload-config"; +import { createMeetingToken } from "@/lib/meeting/token"; + +export const dynamic = "force-dynamic"; + +type Body = { eventSlug?: unknown }; + +/** Creates a host session for an online event from the authenticated CRM. */ +export async function POST(request: Request) { + let body: Body; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); + } + + const eventSlug = typeof body.eventSlug === "string" ? body.eventSlug.trim() : ""; + if (!eventSlug) return NextResponse.json({ error: "Online-Termin fehlt." }, { status: 400 }); + + try { + const payload = await getPayload({ config }); + const { user } = await payload.auth({ headers: request.headers }); + if (!user || user.collection !== "users" || user.role !== "admin") { + return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 }); + } + + const { docs } = await payload.find({ + collection: "events", + where: { slug: { equals: eventSlug } }, + limit: 1, + }); + const event = docs[0]; + if (!event || !event.isOnline) { + return NextResponse.json({ error: "Dieser Termin ist kein Online-Termin." }, { status: 404 }); + } + + const { token } = createMeetingToken({ + eventSlug, + name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host", + role: "host", + }); + + // Keep the token in the URL fragment: browsers do not send fragments to + // the server, so the temporary host credential does not enter request or + // reverse-proxy logs. The CRM copies this test link without navigating. + const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin; + const testUrl = `${baseUrl.replace(/\/$/, "")}/termine/${encodeURIComponent(eventSlug)}/call#token=${encodeURIComponent(token)}`; + + return NextResponse.json({ testUrl }); + } catch (error) { + console.error("admin meeting start failed", error); + return NextResponse.json({ error: "Der Video-Call konnte nicht gestartet werden." }, { status: 500 }); + } +} diff --git a/app/(payload)/api/booking/request/route.ts b/app/(payload)/api/booking/request/route.ts index 031a6c7..9817eca 100644 --- a/app/(payload)/api/booking/request/route.ts +++ b/app/(payload)/api/booking/request/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 } from "@/lib/calendar/timezone"; export const dynamic = "force-dynamic"; @@ -59,16 +60,13 @@ export async function POST(request: Request) { // Re-validate the slot is actually free right now (defense against a // stale slot list) — the hard, race-safe check still runs again when // Anna confirms (see collections/BookingRequests.ts). - const dayStart = new Date(start); - dayStart.setHours(0, 0, 0, 0); - const dayEnd = new Date(start); - dayEnd.setHours(23, 59, 59, 999); + const dayBounds = berlinDayBounds(start); const slotsByDay = await getAvailableSlots(payload, { durationMinutes: offer.durationMinutes, - from: dayStart, - to: dayEnd, + from: dayBounds.start, + to: dayBounds.end, }); - const key = dayStart.toISOString().slice(0, 10); + 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 }); diff --git a/app/(payload)/api/booking/slots/route.ts b/app/(payload)/api/booking/slots/route.ts index 252ae73..95396f8 100644 --- a/app/(payload)/api/booking/slots/route.ts +++ b/app/(payload)/api/booking/slots/route.ts @@ -29,11 +29,21 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 }); } - const from = fromParam ? new Date(fromParam) : new Date(); + const from = fromParam ? new Date(`${fromParam}T00:00:00`) : new Date(); + if (Number.isNaN(from.getTime())) { + return NextResponse.json({ error: "Ungültiges Startdatum." }, { status: 400 }); + } from.setHours(0, 0, 0, 0); - const to = toParam ? new Date(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000); + const to = toParam ? new Date(`${toParam}T00:00:00`) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000); + if (Number.isNaN(to.getTime())) { + return NextResponse.json({ error: "Ungültiges Enddatum." }, { status: 400 }); + } to.setHours(23, 59, 59, 999); + if (to < from) { + return NextResponse.json({ error: "Das Enddatum muss nach dem Startdatum liegen." }, { status: 400 }); + } + // Cap the range so this can't be abused to run an expensive scan. const maxRangeMs = 62 * 24 * 60 * 60 * 1000; if (to.getTime() - from.getTime() > maxRangeMs) { diff --git a/app/(payload)/api/meetings/[slug]/connect/route.ts b/app/(payload)/api/meetings/[slug]/connect/route.ts new file mode 100644 index 0000000..b994279 --- /dev/null +++ b/app/(payload)/api/meetings/[slug]/connect/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { getPayload } from "payload"; +import config from "@payload-config"; +import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit"; +import { isMeetingParticipantKicked } from "@/lib/meeting/kicks"; +import { verifyMeetingToken } from "@/lib/meeting/token"; + +export const dynamic = "force-dynamic"; + +type Args = { params: Promise<{ slug: string }> }; + +/** Exchanges the app's short-lived meeting credential for a LiveKit token. */ +export async function POST(request: Request, { params }: Args) { + const { slug } = await params; + const authorization = request.headers.get("authorization") ?? ""; + const appToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : ""; + const meetingToken = verifyMeetingToken(appToken); + if (!meetingToken || meetingToken.eventSlug !== slug) { + return NextResponse.json({ error: "Die Meeting-Sitzung ist ungültig." }, { status: 401 }); + } + if (isMeetingParticipantKicked(slug, meetingToken.participantId, meetingToken.clientId)) { + return NextResponse.json({ error: "Du wurdest aus diesem Meeting entfernt." }, { status: 403 }); + } + + try { + 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 participantToken = await createLiveKitToken({ + eventSlug: slug, + participantId: meetingToken.participantId, + name: meetingToken.name, + role: meetingToken.role, + clientId: meetingToken.clientId, + }); + return NextResponse.json({ serverUrl: getLiveKitClientUrl(), participantToken }); + } catch (error) { + console.error("meeting LiveKit connect failed", error); + return NextResponse.json({ error: "Der Video-Server ist gerade nicht erreichbar." }, { status: 503 }); + } +} diff --git a/app/(payload)/api/meetings/[slug]/join/route.ts b/app/(payload)/api/meetings/[slug]/join/route.ts index 4f6d0aa..64a4860 100644 --- a/app/(payload)/api/meetings/[slug]/join/route.ts +++ b/app/(payload)/api/meetings/[slug]/join/route.ts @@ -4,7 +4,8 @@ import { getPayload } from "payload"; import config from "@payload-config"; import { createMeetingToken } from "@/lib/meeting/token"; import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status"; -import { getIceServers } from "@/lib/meeting/iceServers"; +import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit"; +import { isMeetingParticipantKicked } from "@/lib/meeting/kicks"; export const dynamic = "force-dynamic"; @@ -30,9 +31,12 @@ export async function POST(request: Request, { params }: Args) { const name = typeof body.name === "string" ? body.name.trim() : ""; const password = typeof body.password === "string" ? body.password.trim() : ""; const clientId = typeof body.clientId === "string" ? body.clientId.trim() : undefined; - if (!name) { + if (!name || name.length > 120) { return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 }); } + if (password.length > 200) { + return NextResponse.json({ error: "Das Meeting-Passwort ist ungültig." }, { status: 400 }); + } try { const payload = await getPayload({ config }); @@ -50,7 +54,7 @@ export async function POST(request: Request, { params }: Args) { const { user } = await payload.auth({ headers: request.headers }); // Must check the collection, not just presence — a logged-in customer // (collection "customers") must never be treated as host. - const isHost = user?.collection === "users"; + const isHost = user?.collection === "users" && user.role === "admin"; if (!isHost) { if (!password) { @@ -88,7 +92,18 @@ export async function POST(request: Request, { params }: Args) { role: isHost ? "host" : "participant", // 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, see signalingServer.ts). + // hosts can't be kicked in the first place. + clientId: isHost ? undefined : clientId, + }); + + if (!isHost && isMeetingParticipantKicked(slug, participantId, clientId)) { + return NextResponse.json({ error: "Du wurdest aus diesem Meeting entfernt." }, { status: 403 }); + } + const participantToken = await createLiveKitToken({ + eventSlug: slug, + participantId, + name, + role: isHost ? "host" : "participant", clientId: isHost ? undefined : clientId, }); @@ -97,7 +112,8 @@ export async function POST(request: Request, { params }: Args) { participantId, role: isHost ? "host" : "participant", eventTitle: event.title, - iceServers: getIceServers(), + participantToken, + serverUrl: getLiveKitClientUrl(), }); } catch (error) { console.error("meeting join failed", error); diff --git a/app/(payload)/api/meetings/[slug]/kick/route.ts b/app/(payload)/api/meetings/[slug]/kick/route.ts new file mode 100644 index 0000000..87c6d7d --- /dev/null +++ b/app/(payload)/api/meetings/[slug]/kick/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { getLiveKitRoomName, getLiveKitRoomService } from "@/lib/meeting/livekit"; +import { markMeetingParticipantKicked } from "@/lib/meeting/kicks"; +import { verifyMeetingToken } from "@/lib/meeting/token"; + +export const dynamic = "force-dynamic"; + +type Args = { params: Promise<{ slug: string }> }; + +export async function POST(request: Request, { params }: Args) { + const { slug } = await params; + const authorization = request.headers.get("authorization") ?? ""; + const appToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : ""; + const host = verifyMeetingToken(appToken); + if (!host || host.eventSlug !== slug || host.role !== "host") { + return NextResponse.json({ error: "Nur der Host darf Teilnehmer entfernen." }, { status: 403 }); + } + + let body: { participantId?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); + } + const participantId = typeof body.participantId === "string" ? body.participantId.trim() : ""; + if (!participantId || participantId.length > 100 || participantId === host.participantId) { + return NextResponse.json({ error: "Ungültiger Teilnehmer." }, { status: 400 }); + } + + try { + const roomService = getLiveKitRoomService(); + const room = getLiveKitRoomName(slug); + const participant = (await roomService.listParticipants(room)).find((item) => item.identity === participantId); + if (!participant) return NextResponse.json({ error: "Teilnehmer nicht gefunden." }, { status: 404 }); + + let clientId: string | undefined; + try { + const metadata = JSON.parse(participant.metadata || "{}") as { clientId?: unknown }; + if (typeof metadata.clientId === "string" && metadata.clientId.length <= 100) clientId = metadata.clientId; + } catch { + // Metadata is optional; the participant identity is still revoked below. + } + markMeetingParticipantKicked(slug, participantId, clientId); + await roomService.removeParticipant(room, participantId); + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("meeting participant kick failed", error); + return NextResponse.json({ error: "Der Teilnehmer konnte nicht entfernt werden." }, { status: 503 }); + } +} diff --git a/app/(payload)/api/meetings/[slug]/register/route.ts b/app/(payload)/api/meetings/[slug]/register/route.ts index 9184360..e1b0302 100644 --- a/app/(payload)/api/meetings/[slug]/register/route.ts +++ b/app/(payload)/api/meetings/[slug]/register/route.ts @@ -1,14 +1,40 @@ import { NextResponse } from "next/server"; import { getPayload } from "payload"; import config from "@payload-config"; +import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit"; export const dynamic = "force-dynamic"; +// Registration capacity is checked before the insert. Serialize registrations +// for the same event so two simultaneous requests cannot both observe the +// same free seat in this single-process deployment. +const registrationLocks = new Map>(); + +async function withEventRegistrationLock(eventId: string, action: () => Promise): Promise { + const previous = registrationLocks.get(eventId) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + registrationLocks.set(eventId, current); + await previous; + try { + return await action(); + } finally { + release(); + if (registrationLocks.get(eventId) === current) registrationLocks.delete(eventId); + } +} + type Args = { params: Promise<{ slug: string }> }; export async function POST(request: Request, { params }: Args) { const { slug } = await params; + if (!checkRateLimit(`event-registration:ip:${getClientIp(request)}`, { max: 20, windowMs: 60 * 60 * 1000 })) { + return NextResponse.json({ error: "Zu viele Anmeldungen. Bitte versuche es später erneut." }, { status: 429 }); + } + let body: { name?: unknown; email?: unknown }; try { body = await request.json(); @@ -17,8 +43,8 @@ export async function POST(request: Request, { params }: Args) { } const name = typeof body.name === "string" ? body.name.trim() : ""; - const email = typeof body.email === "string" ? body.email.trim() : ""; - if (!name || !email || !email.includes("@")) { + const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; + if (!name || name.length > 120 || !email || email.length > 254 || !email.includes("@")) { return NextResponse.json({ error: "Bitte gib deinen Namen und eine gültige E-Mail-Adresse an." }, { status: 400 }); } @@ -31,41 +57,46 @@ export async function POST(request: Request, { params }: Args) { limit: 1, }); const event = docs[0]; - if (!event) { + if (!event || event._status !== "published" || !event.registrationRequired) { return NextResponse.json({ error: "Dieser Termin wurde nicht gefunden." }, { status: 404 }); } - - const { docs: existingForEmail } = await payload.find({ - collection: "event-registrations", - where: { - and: [ - { event: { equals: event.id } }, - { email: { equals: email } }, - { status: { equals: "registered" } }, - ], - }, - limit: 1, - }); - if (existingForEmail[0]) { - return NextResponse.json({ ok: true, alreadyRegistered: true }); + if (!checkRateLimit(`event-registration:email:${event.id}:${email}`, { max: 3, windowMs: 60 * 60 * 1000 })) { + return NextResponse.json({ error: "Zu viele Anmeldeversuche für diese Adresse. Bitte versuche es später erneut." }, { status: 429 }); } - if (event.maxParticipants) { - const { totalDocs } = await payload.count({ + return await withEventRegistrationLock(String(event.id), async () => { + const { docs: existingForEmail } = await payload.find({ collection: "event-registrations", - where: { and: [{ event: { equals: event.id } }, { status: { equals: "registered" } }] }, + where: { + and: [ + { event: { equals: event.id } }, + { email: { equals: email } }, + { status: { equals: "registered" } }, + ], + }, + limit: 1, }); - if (totalDocs >= event.maxParticipants) { - return NextResponse.json({ error: "Dieser Termin ist bereits ausgebucht." }, { status: 409 }); + if (existingForEmail[0]) { + return NextResponse.json({ ok: true, alreadyRegistered: true }); } - } - await payload.create({ - collection: "event-registrations", - data: { event: event.id, name, email, status: "registered" }, + if (event.maxParticipants) { + const { totalDocs } = await payload.count({ + collection: "event-registrations", + where: { and: [{ event: { equals: event.id } }, { status: { equals: "registered" } }] }, + }); + if (totalDocs >= event.maxParticipants) { + return NextResponse.json({ error: "Dieser Termin ist bereits ausgebucht." }, { status: 409 }); + } + } + + await payload.create({ + collection: "event-registrations", + data: { event: event.id, name, email, status: "registered" }, + }); + + return NextResponse.json({ ok: true }); }); - - return NextResponse.json({ ok: true }); } catch (error) { console.error("event registration failed", error); return NextResponse.json({ error: "Die Anmeldung ist gerade nicht möglich. Bitte versuche es später erneut." }, { status: 500 }); diff --git a/collections/BookingRequests.ts b/collections/BookingRequests.ts index 2f1dff5..74265f6 100644 --- a/collections/BookingRequests.ts +++ b/collections/BookingRequests.ts @@ -12,6 +12,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"; export const APPOINTMENT_TYPES = [ { label: "Vor Ort", value: "onsite" }, @@ -26,15 +27,15 @@ export const BOOKING_STATUSES = [ ] as const; function dateKey(iso: string): string { - return new Date(iso).toISOString().slice(0, 10); + return berlinDateKey(new Date(iso)); } function fmtDate(iso: string): string { - return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" }); + return formatBerlinDate(new Date(iso), { day: "2-digit", month: "long", year: "numeric" }); } function fmtTime(iso: string): string { - return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); + return formatBerlinTime(new Date(iso), { hour: "2-digit", minute: "2-digit" }); } const appointmentTypeLabel = (value: string) => @@ -70,8 +71,9 @@ const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDo await acquireBookingDayLock(req, dateKey(date)); - const dayStart = new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString(); - const dayEnd = new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString(); + 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 } }, diff --git a/collections/EventRegistrations.ts b/collections/EventRegistrations.ts index f1867b5..c1d3447 100644 --- a/collections/EventRegistrations.ts +++ b/collections/EventRegistrations.ts @@ -14,8 +14,11 @@ export const EventRegistrations: CollectionConfig = { description: "Anmeldungen zu Terminen — wird beim öffentlichen Anmeldeformular befüllt.", }, access: { - // Visitors register themselves through the public event page. - create: () => true, + // Public registrations must go through /api/meetings/[slug]/register, + // which verifies publication/registrationRequired, normalizes the email, + // and enforces the capacity check. The Local API used by that route + // intentionally overrides this access rule after those checks. + create: () => false, read: isAdmin, update: isAdmin, delete: isAdmin, diff --git a/components/FormField.tsx b/components/FormField.tsx index 5b77a5a..a2f2a22 100644 --- a/components/FormField.tsx +++ b/components/FormField.tsx @@ -9,7 +9,7 @@ import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "rea * validation attributes, autoComplete, etc. all keep working exactly as * before. */ -const fieldClass = +export const fieldClass = "w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none"; function FieldLabel({ label, htmlFor, hidden = false }: { label: string; htmlFor: string; hidden?: boolean }) { diff --git a/components/admin/AdminCalendarView.module.css b/components/admin/AdminCalendarView.module.css index 6ffa729..851aa21 100644 --- a/components/admin/AdminCalendarView.module.css +++ b/components/admin/AdminCalendarView.module.css @@ -155,13 +155,21 @@ padding: 6px 10px; border-radius: 8px; background: #f7f4f1; - text-decoration: none; color: #1f1f1f; font-size: 13px; } .entry:hover { background: #efe7e1; } +.entryLink { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + color: inherit; + text-decoration: none; +} .badge { font-size: 10px; diff --git a/components/admin/AdminCalendarView.tsx b/components/admin/AdminCalendarView.tsx index a53f774..9db87de 100644 --- a/components/admin/AdminCalendarView.tsx +++ b/components/admin/AdminCalendarView.tsx @@ -1,6 +1,8 @@ import type { AdminViewServerProps } from "payload"; import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar"; +import { berlinParts } from "@/lib/calendar/timezone"; import { CalendarFeedPanel } from "./CalendarFeedPanel"; +import { AdminCallButton } from "./AdminCallButton"; import styles from "./AdminCalendarView.module.css"; const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; @@ -27,7 +29,8 @@ const BADGE_CLASS: Record = { }; function dateKey(d: Date) { - return d.toISOString().slice(0, 10); + const { year, month, day } = berlinParts(d); + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } function parseMonthParam(value: string | null): Date { if (value && /^\d{4}-\d{2}$/.test(value)) { @@ -40,7 +43,11 @@ function monthParam(d: Date) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } function fmtTime(iso: string) { - return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); + return new Date(iso).toLocaleTimeString("de-DE", { + timeZone: "Europe/Berlin", + hour: "2-digit", + minute: "2-digit", + }); } export async function AdminCalendarView({ initPageResult }: AdminViewServerProps) { @@ -48,7 +55,9 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps const searchParams = req.searchParams; const month = parseMonthParam(searchParams.get("month")); const selectedDayParam = searchParams.get("day"); - const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T00:00:00`) : null; + // Noon UTC stays on the same Berlin calendar day even when the server runs + // in UTC and the selected date is around a DST transition. + const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T12:00:00Z`) : null; const gridStart = new Date(month.getFullYear(), month.getMonth(), 1); const firstWeekday = (gridStart.getDay() + 6) % 7; @@ -82,7 +91,10 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" }); const dayItems = selectedDay ? (itemsByDay.get(dateKey(selectedDay)) ?? []) : []; - const hours = Array.from({ length: 13 }, (_, i) => i + 8); // 08:00–20:00 + const startHours = dayItems.length ? dayItems.map((item) => berlinParts(new Date(item.startTime)).hour) : []; + const firstHour = Math.min(8, ...startHours); + const lastHour = Math.max(20, ...startHours); + const hours = Array.from({ length: lastHour - firstHour + 1 }, (_, i) => i + firstHour); return (
@@ -156,23 +168,26 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps {selectedDay && dayItems.length > 0 && hours.map((hour) => { - const hourEntries = dayItems.filter((item) => new Date(item.startTime).getHours() === hour); + const hourEntries = dayItems.filter((item) => berlinParts(new Date(item.startTime)).hour === hour); return ( diff --git a/components/admin/AdminCallButton.tsx b/components/admin/AdminCallButton.tsx new file mode 100644 index 0000000..778e07c --- /dev/null +++ b/components/admin/AdminCallButton.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useState } from "react"; + +export function AdminCallButton({ eventSlug }: { eventSlug: string }) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [url, setUrl] = useState(null); + + async function startCall() { + setLoading(true); + setError(null); + setCopied(false); + try { + const response = await fetch("/api/admin/meetings/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ eventSlug }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + setError(data.error || "Der Video-Call konnte nicht gestartet werden."); + return; + } + setUrl(data.testUrl); + try { + await navigator.clipboard.writeText(data.testUrl); + setCopied(true); + } catch { + setError("Der Link wurde erzeugt. Bitte kopiere ihn aus dem Feld."); + } + } catch { + setError("Verbindung fehlgeschlagen."); + } finally { + setLoading(false); + } + } + + return ( + + + {url && !copied && ( + event.currentTarget.select()} + className="w-56 rounded border border-anouma-taupe/30 bg-white px-2 py-1 text-xs text-anouma-plum" + /> + )} + {error && {error}} + + ); +} diff --git a/components/booking/BookingCard.tsx b/components/booking/BookingCard.tsx index e0a65fc..66b74e6 100644 --- a/components/booking/BookingCard.tsx +++ b/components/booking/BookingCard.tsx @@ -4,15 +4,10 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge"; import type { BookingRequest, Event, Offer } from "@/payload-types"; +import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone"; +import type { MeetingStatus } from "@/lib/meeting/status"; -function fmtDate(iso: string) { - return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long", year: "numeric" }); -} -function fmtTime(iso: string) { - return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); -} - -export function BookingCard({ booking }: { booking: BookingRequest }) { +export function BookingCard({ booking, meetingStatus }: { booking: BookingRequest; meetingStatus?: MeetingStatus | null }) { const router = useRouter(); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -46,7 +41,7 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {

{offer?.title ?? "Termin"}

- {fmtDate(booking.date)} · {fmtTime(booking.startTime)} – {fmtTime(booking.endTime)} + {formatBerlinDate(new Date(booking.date), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.startTime), { hour: "2-digit", minute: "2-digit" })} – {formatBerlinTime(new Date(booking.endTime), { hour: "2-digit", minute: "2-digit" })}

@@ -62,8 +57,8 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {

Alternativer Termin

- {fmtDate(booking.proposedAlternative.date!)} · {fmtTime(booking.proposedAlternative.startTime!)} –{" "} - {fmtTime(booking.proposedAlternative.endTime!)} + {formatBerlinDate(new Date(booking.proposedAlternative.date!), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.proposedAlternative.startTime!), { hour: "2-digit", minute: "2-digit" })} –{" "} + {formatBerlinTime(new Date(booking.proposedAlternative.endTime!), { hour: "2-digit", minute: "2-digit" })}

-
- - {errorMessage && ( -

{errorMessage}

- )} - -
-
- - {remoteList.map((r) => ( - - ))} +
+
+

{session.eventTitle}

LiveKit-SFU · {participants.length} Teilnehmer

+
+ {(["grid", "speaker", "screen"] as LayoutMode[]).map((mode) => )} +
- - {showParticipants && ( - - )} +
+ {errorMessage &&

{errorMessage}

} +
+
+ {layout === "grid" &&
{participants.map((participant) => tile(participant, { videoTrack: participant.screenTrack ?? participant.cameraTrack, name: participant.screenTrack ? `${participant.name} · Bildschirm` : participant.name }))}
} + {layout === "speaker" && activeSpeaker &&
{tile(activeSpeaker, { videoTrack: activeSpeaker.screenTrack ?? activeSpeaker.cameraTrack })}
{participants.filter((participant) => participant.identity !== activeSpeaker.identity).map((participant) => tile(participant, { compact: true }))}
} + {layout === "screen" &&
{screenOwner ?
{tile(screenOwner, { videoTrack: screenOwner.screenTrack, name: `${screenOwner.name} · Bildschirm` })}
:
Noch niemand teilt den Bildschirm.
}
{participants.map((participant) => tile(participant, { compact: true }))}
} +
+ {showParticipants && }
- -
- - - {isHost && ( - - )} - -
-
+
+ void toggleMic()} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" /> + void toggleCamera()} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" /> + {session.role === "host" && void toggleScreenShare()} label={sharingScreen ? "Bildschirmfreigabe beenden" : "Bildschirm teilen"} icon="screen" />} + +
+ ); } -function ControlButton({ - active, - onClick, - label, - icon, -}: { - active: boolean; - onClick: () => void; - label: string; - icon: "mic" | "cam" | "screen"; -}) { +function ControlButton({ active, onClick, label, icon }: { active: boolean; onClick: () => void; label: string; icon: "mic" | "cam" | "screen" }) { return ( - ); } - -function Icon({ name, off }: { name: "mic" | "cam" | "screen"; off: boolean }) { - if (name === "mic") { - return ( - - ); - } - if (name === "cam") { - return ( - - ); - } - return ( - - ); -} diff --git a/components/meeting/JoinForm.tsx b/components/meeting/JoinForm.tsx index 8c434e1..352abfc 100644 --- a/components/meeting/JoinForm.tsx +++ b/components/meeting/JoinForm.tsx @@ -2,7 +2,7 @@ import { useState, type FormEvent } from "react"; import { useRouter } from "next/navigation"; -import { TextField, FormError } from "@/components/FormField"; +import { fieldClass, TextField, FormError } from "@/components/FormField"; export const MEETING_SESSION_KEY = "anouma-meeting-session"; const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id"; @@ -28,18 +28,20 @@ function getOrCreateClientId(): string { export type StoredMeetingSession = { token: string; + participantToken?: string; + serverUrl?: string; participantId: string; role: "host" | "participant"; name: string; eventTitle: string; eventSlug: string; - iceServers: RTCIceServer[]; }; export function JoinForm({ slug }: { slug: string }) { const router = useRouter(); const [name, setName] = useState(""); const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -63,12 +65,13 @@ export function JoinForm({ slug }: { slug: string }) { const session: StoredMeetingSession = { token: data.token, + participantToken: data.participantToken, + serverUrl: data.serverUrl, participantId: data.participantId, role: data.role, name, eventTitle: data.eventTitle, eventSlug: slug, - iceServers: data.iceServers, }; sessionStorage.setItem(MEETING_SESSION_KEY, JSON.stringify(session)); router.push(`/termine/${slug}/call`); @@ -90,16 +93,31 @@ export function JoinForm({ slug }: { slug: string }) { placeholder="Name eingeben" autoComplete="name" /> - setPassword(e.target.value)} - placeholder="Passwort eingeben" - autoComplete="off" - /> +
+
+ + +
+ setPassword(e.target.value)} + placeholder="Passwort eingeben" + autoComplete="off" + /> +
{error && {error}} +
+ + +
+ + {error &&

{error}

} + +
+
+
+ + ); +} diff --git a/components/meeting/SfuVideoTile.tsx b/components/meeting/SfuVideoTile.tsx new file mode 100644 index 0000000..504016a --- /dev/null +++ b/components/meeting/SfuVideoTile.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { Track } from "livekit-client"; + +export function SfuVideoTile({ + videoTrack, + audioTrack, + name, + isLocal = false, + isHost = false, + muted = false, + videoOff = false, + speaking = false, + compact = false, +}: { + videoTrack?: Track | null; + audioTrack?: Track | null; + name: string; + isLocal?: boolean; + isHost?: boolean; + muted?: boolean; + videoOff?: boolean; + speaking?: boolean; + compact?: boolean; +}) { + const videoRef = useRef(null); + const audioRef = useRef(null); + + useEffect(() => { + const videoElement = videoRef.current; + if (!videoElement || !videoTrack || videoOff) return; + videoTrack.attach(videoElement); + return () => { + videoTrack.detach(videoElement); + }; + }, [videoTrack, videoOff]); + + useEffect(() => { + const audioElement = audioRef.current; + if (!audioElement || !audioTrack || isLocal) return; + audioTrack.attach(audioElement); + return () => { + audioTrack.detach(audioElement); + }; + }, [audioTrack, isLocal]); + + return ( +
+ {videoTrack && !videoOff ? ( +
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml index 95eaf3c..9a09f6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,29 @@ services: # HEALTHCHECK instruction (GET /api/health, which itself checks Postgres # connectivity) — nothing to duplicate here. + # Self-hosted LiveKit SFU. Host networking is intentional: WebRTC media + # needs the UDP port range below to be reachable directly from the internet. + livekit: + image: livekit/livekit-server:latest + restart: unless-stopped + network_mode: host + env_file: + - .env + environment: + LIVEKIT_CONFIG: | + port: 7880 + log_level: info + rtc: + tcp_port: 7881 + port_range_start: 50000 + port_range_end: 60000 + use_external_ip: true + keys: + ${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET} + turn: + enabled: true + udp_port: 3478 + # Optional external network to a reverse proxy (e.g. Nginx Proxy Manager) # is attached via docker-compose.override.yml, generated by setup.sh only # when such a network is actually detected — see DEPLOYMENT.md. Nothing diff --git a/globals/MeetingSettings.ts b/globals/MeetingSettings.ts index 30077f1..4ab6de5 100644 --- a/globals/MeetingSettings.ts +++ b/globals/MeetingSettings.ts @@ -18,7 +18,7 @@ export const MeetingSettings: GlobalConfig = { type: "number", label: "Teilnehmer dürfen beitreten (Minuten vor Beginn)", required: true, - defaultValue: 30, + defaultValue: 10, min: 0, }, { diff --git a/lib/booking/adminCalendar.ts b/lib/booking/adminCalendar.ts index 812aca3..0f1987a 100644 --- a/lib/booking/adminCalendar.ts +++ b/lib/booking/adminCalendar.ts @@ -11,6 +11,7 @@ export type AdminCalendarItem = { endTime: string; status: string; // booking status, or "public" for a standalone Event appointmentType?: "onsite" | "online"; + callEventSlug?: string; href: string; }; @@ -61,6 +62,10 @@ export async function getAdminCalendarItems( endTime: b.endTime, status: b.status, appointmentType: b.appointmentType, + callEventSlug: + b.appointmentType === "online" && typeof b.linkedEvent === "object" && b.linkedEvent?.slug + ? b.linkedEvent.slug + : undefined, href: `/admin/collections/booking-requests/${b.id}`, }; }); @@ -74,6 +79,7 @@ export async function getAdminCalendarItems( endTime: e.endTime ?? e.date, status: "public", appointmentType: e.isOnline ? "online" : "onsite", + callEventSlug: e.isOnline && e.slug ? e.slug : undefined, href: `/admin/collections/events/${e.id}`, })); diff --git a/lib/booking/meetingAccess.ts b/lib/booking/meetingAccess.ts new file mode 100644 index 0000000..2f65afe --- /dev/null +++ b/lib/booking/meetingAccess.ts @@ -0,0 +1,23 @@ +import type { BookingRequest, Event, MeetingSetting } from "@/payload-types"; +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; + if (booking.status !== "confirmed" || booking.appointmentType !== "online" || !event?.isOnline) return null; + + const start = combineDateAndTime(booking.date, booking.startTime); + const end = booking.endTime + ? combineDateAndTime(booking.date, booking.endTime) + : new Date(start.getTime() + 60 * 60_000); + return getMeetingStatus({ + start, + end, + joinWindowMinutes: settings.participantJoinWindowMinutes, + closeAfterMinutes: settings.meetingCloseAfterMinutes, + }); +} + +export function canJoinBookingMeeting(booking: BookingRequest, settings: MeetingSetting): boolean { + const status = getBookingMeetingStatus(booking, settings); + return status !== null && canJoinMeeting(status); +} diff --git a/lib/booking/slots.ts b/lib/booking/slots.ts index dd11a0e..883a304 100644 --- a/lib/booking/slots.ts +++ b/lib/booking/slots.ts @@ -1,10 +1,9 @@ import type { Payload } from "payload"; import { WEEKDAYS } from "@/collections/Availability"; +import { berlinParts, berlinWallTimeToUTC, type CalendarDate } from "@/lib/calendar/timezone"; export type TimeSlot = { start: Date; end: Date }; -const BERLIN_TZ = "Europe/Berlin"; - const WEEKDAY_BY_INDEX = [ "sunday", "monday", @@ -15,44 +14,12 @@ const WEEKDAY_BY_INDEX = [ "saturday", ] as const; -type CalendarDate = { year: number; month: number; day: number }; - // Availability/AvailabilityOverrides times are entered by the admin in their // browser (Europe/Berlin) and stored as UTC instants. All wall-clock reads // and writes here go through Intl's Berlin timezone data (not local Date // accessors, and not manual UTC+1/+2 arithmetic), so the server process's // own timezone (this app runs in UTC in production) never leaks into the // computed availability — see lib/calendar/timezone.ts for the same pattern. -function berlinParts(date: Date): CalendarDate & { hour: number; minute: number } { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: BERLIN_TZ, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }).formatToParts(date); - const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0"); - return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour"), minute: get("minute") }; -} - -function berlinOffsetMinutes(date: Date): number { - const tzName = new Intl.DateTimeFormat("en-US", { timeZone: BERLIN_TZ, timeZoneName: "shortOffset" }) - .formatToParts(date) - .find((p) => p.type === "timeZoneName")?.value ?? "GMT+1"; - const match = tzName.match(/GMT([+-]\d+)(?::(\d+))?/); - const hours = match ? Number(match[1]) : 1; - const minutes = match?.[2] ? Number(match[2]) : 0; - return hours * 60 + (hours < 0 ? -minutes : minutes); -} - -/** Converts a Europe/Berlin wall-clock date+time into the UTC instant it represents. */ -function berlinWallTimeToUTC(date: CalendarDate, hour: number, minute: number): Date { - const guess = Date.UTC(date.year, date.month - 1, date.day, hour, minute); - return new Date(guess - berlinOffsetMinutes(new Date(guess)) * 60_000); -} - function dateKey(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/calendar/timezone.ts b/lib/calendar/timezone.ts index d0160bb..4f793f8 100644 --- a/lib/calendar/timezone.ts +++ b/lib/calendar/timezone.ts @@ -1,5 +1,59 @@ const BERLIN_TZ = "Europe/Berlin"; +export type CalendarDate = { year: number; month: number; day: number }; + +export function berlinDateKey(date: Date): string { + const { year, month, day } = berlinParts(date); + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +export function formatBerlinDate(date: Date, options: Intl.DateTimeFormatOptions = {}): string { + return new Intl.DateTimeFormat("de-DE", { ...options, timeZone: BERLIN_TZ }).format(date); +} + +export function formatBerlinTime(date: Date, options: Intl.DateTimeFormatOptions = {}): string { + return new Intl.DateTimeFormat("de-DE", { ...options, timeZone: BERLIN_TZ }).format(date); +} + +export function berlinParts(date: Date): CalendarDate & { hour: number; minute: number } { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: BERLIN_TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const get = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? "0"); + return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour"), minute: get("minute") }; +} + +export function berlinOffsetMinutes(date: Date): number { + const tzName = new Intl.DateTimeFormat("en-US", { timeZone: BERLIN_TZ, timeZoneName: "shortOffset" }) + .formatToParts(date) + .find((part) => part.type === "timeZoneName")?.value ?? "GMT+1"; + const match = tzName.match(/GMT([+-]\d+)(?::(\d+))?/); + const hours = match ? Number(match[1]) : 1; + const minutes = match?.[2] ? Number(match[2]) : 0; + return hours * 60 + (hours < 0 ? -minutes : minutes); +} + +/** Converts a Berlin wall-clock date/time into the UTC instant it represents. */ +export function berlinWallTimeToUTC(date: CalendarDate, hour: number, minute: number): Date { + const guess = Date.UTC(date.year, date.month - 1, date.day, hour, minute); + return new Date(guess - berlinOffsetMinutes(new Date(guess)) * 60_000); +} + +export function berlinDayBounds(date: Date): { key: string; start: Date; end: Date } { + const current = berlinParts(date); + const nextCalendarDay = new Date(Date.UTC(current.year, current.month - 1, current.day + 1)); + const next = berlinParts(nextCalendarDay); + const start = berlinWallTimeToUTC(current, 0, 0); + const nextStart = berlinWallTimeToUTC(next, 0, 0); + return { key: berlinDateKey(date), start, end: new Date(nextStart.getTime() - 1) }; +} + /** * Converts a JS Date (a UTC instant) into its Europe/Berlin wall-clock * representation for iCalendar's local `TZID` date-time form diff --git a/lib/format.ts b/lib/format.ts index 0958a4f..a226718 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -1,14 +1,16 @@ +import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone"; + /** Day + short month, e.g. { day: "15", month: "SEP" } — for the date badge on event cards. */ export function formatEventDateParts(iso: string) { const date = new Date(iso); return { - day: date.toLocaleDateString("de-DE", { day: "2-digit" }), - month: date.toLocaleDateString("de-DE", { month: "short" }).toUpperCase().replace(".", ""), + day: formatBerlinDate(date, { day: "2-digit" }), + month: formatBerlinDate(date, { month: "short" }).toUpperCase().replace(".", ""), }; } export function formatFullDate(iso: string) { - return new Date(iso).toLocaleDateString("de-DE", { + return formatBerlinDate(new Date(iso), { weekday: "long", day: "2-digit", month: "long", @@ -17,7 +19,7 @@ export function formatFullDate(iso: string) { } export function formatTime(iso: string) { - return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); + return formatBerlinTime(new Date(iso), { hour: "2-digit", minute: "2-digit" }); } export function formatTimeRange(start?: string | null, end?: string | null) { diff --git a/lib/meeting/iceServers.ts b/lib/meeting/iceServers.ts deleted file mode 100644 index a765047..0000000 --- a/lib/meeting/iceServers.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Builds the WebRTC ICE server list from environment variables so a TURN - * server can be added later without any code changes (see DEPLOYMENT.md). - * P2P works fine with just STUN for most networks; TURN becomes necessary - * behind restrictive NATs/firewalls. - */ -export function getIceServers(): RTCIceServer[] { - const servers: RTCIceServer[] = [ - { urls: process.env.STUN_SERVER || "stun:stun.l.google.com:19302" }, - ]; - - const turnServer = process.env.TURN_SERVER; - if (turnServer) { - servers.push({ - urls: turnServer, - username: process.env.TURN_USERNAME || undefined, - credential: process.env.TURN_PASSWORD || undefined, - }); - } - - return servers; -} diff --git a/lib/meeting/kicks.ts b/lib/meeting/kicks.ts new file mode 100644 index 0000000..1a474d9 --- /dev/null +++ b/lib/meeting/kicks.ts @@ -0,0 +1,33 @@ +const KICK_RETENTION_MS = 24 * 60 * 60_000; +type KickList = { ids: Set; expiresAt: number }; +const kickedParticipantIds = new Map(); +const kickedClientIds = new Map(); + +function activeList(map: Map, eventSlug: string): Set | undefined { + const entry = map.get(eventSlug); + if (!entry) return undefined; + if (entry.expiresAt <= Date.now()) { + map.delete(eventSlug); + return undefined; + } + return entry.ids; +} + +export function isMeetingParticipantKicked(eventSlug: string, participantId: string, clientId?: string): boolean { + return Boolean( + activeList(kickedParticipantIds, eventSlug)?.has(participantId) || + (clientId && activeList(kickedClientIds, eventSlug)?.has(clientId)), + ); +} + +export function markMeetingParticipantKicked(eventSlug: string, participantId: string, clientId?: string): void { + const expiresAt = Date.now() + KICK_RETENTION_MS; + const participantIds = activeList(kickedParticipantIds, eventSlug) ?? new Set(); + participantIds.add(participantId); + kickedParticipantIds.set(eventSlug, { ids: participantIds, expiresAt }); + if (clientId) { + const clientIds = activeList(kickedClientIds, eventSlug) ?? new Set(); + clientIds.add(clientId); + kickedClientIds.set(eventSlug, { ids: clientIds, expiresAt }); + } +} diff --git a/lib/meeting/livekit.ts b/lib/meeting/livekit.ts new file mode 100644 index 0000000..2349ad3 --- /dev/null +++ b/lib/meeting/livekit.ts @@ -0,0 +1,54 @@ +import { createHmac } from "node:crypto"; +import { AccessToken, RoomServiceClient, TrackSource } from "livekit-server-sdk"; +import type { MeetingRole } from "./token"; + +function requiredEnv(name: "LIVEKIT_URL" | "LIVEKIT_API_KEY" | "LIVEKIT_API_SECRET"): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is not set`); + return value; +} + +export function getLiveKitClientUrl(): string { + return requiredEnv("LIVEKIT_URL").replace(/^http:/, "ws:").replace(/^https:/, "wss:").replace(/\/$/, ""); +} + +function getLiveKitApiUrl(): string { + return requiredEnv("LIVEKIT_URL").replace(/^ws:/, "http:").replace(/^wss:/, "https:").replace(/\/$/, ""); +} + +/** Stable opaque room name; the public event slug is never sent to LiveKit logs. */ +export function getLiveKitRoomName(eventSlug: string): string { + const secret = requiredEnv("LIVEKIT_API_SECRET"); + return `anouma-${createHmac("sha256", secret).update(eventSlug).digest("hex").slice(0, 32)}`; +} + +export async function createLiveKitToken(args: { + eventSlug: string; + participantId: string; + name: string; + role: MeetingRole; + clientId?: string; +}): Promise { + const room = getLiveKitRoomName(args.eventSlug); + const token = new AccessToken(requiredEnv("LIVEKIT_API_KEY"), requiredEnv("LIVEKIT_API_SECRET"), { + identity: args.participantId, + name: args.name, + ttl: "2h", + metadata: JSON.stringify({ role: args.role, clientId: args.clientId }), + }); + token.addGrant({ + roomJoin: true, + room, + canPublish: true, + canSubscribe: true, + canPublishData: true, + canPublishSources: args.role === "host" + ? [TrackSource.CAMERA, TrackSource.MICROPHONE, TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO] + : [TrackSource.CAMERA, TrackSource.MICROPHONE], + }); + return token.toJwt(); +} + +export function getLiveKitRoomService(): RoomServiceClient { + return new RoomServiceClient(getLiveKitApiUrl(), requiredEnv("LIVEKIT_API_KEY"), requiredEnv("LIVEKIT_API_SECRET")); +} diff --git a/lib/meeting/protocol.ts b/lib/meeting/protocol.ts deleted file mode 100644 index fa7124d..0000000 --- a/lib/meeting/protocol.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { MeetingRole } from "./token"; - -export type ParticipantSummary = { - participantId: string; - name: string; - role: MeetingRole; -}; - -export type ClientToServerMessage = - | { type: "auth"; token: string } - | { type: "offer"; to: string; payload: RTCSessionDescriptionInit } - | { type: "answer"; to: string; payload: RTCSessionDescriptionInit } - | { type: "ice-candidate"; to: string; payload: RTCIceCandidateInit } - | { type: "kick"; targetParticipantId: string }; - -export type ServerToClientMessage = - | { type: "welcome"; participantId: string; role: MeetingRole; participants: ParticipantSummary[] } - | ({ type: "peer-joined" } & ParticipantSummary) - | { type: "peer-left"; participantId: string } - | { type: "offer"; from: string; payload: RTCSessionDescriptionInit } - | { type: "answer"; from: string; payload: RTCSessionDescriptionInit } - | { type: "ice-candidate"; from: string; payload: RTCIceCandidateInit } - | { type: "kicked" } - | { type: "auth-error"; reason: string }; diff --git a/lib/meeting/reminders.ts b/lib/meeting/reminders.ts index 9694bff..cdda2ef 100644 --- a/lib/meeting/reminders.ts +++ b/lib/meeting/reminders.ts @@ -2,6 +2,7 @@ import { getPayload } from "payload"; import config from "@payload-config"; import { combineDateAndTime } from "./status"; import { sendReminderEmail } from "@/lib/email/sendReminderEmail"; +import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone"; const REMINDER_TOLERANCE_MS = 10 * 60 * 1000; // cron may run every few minutes @@ -10,11 +11,11 @@ function isDue(reminderAt: number, now: number): boolean { } function formatDate(date: Date): string { - return date.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" }); + return formatBerlinDate(date, { day: "2-digit", month: "long", year: "numeric" }); } function formatTime(date: Date): string { - return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); + return formatBerlinTime(date, { hour: "2-digit", minute: "2-digit" }); } /** diff --git a/lib/meeting/rooms.ts b/lib/meeting/rooms.ts deleted file mode 100644 index b119509..0000000 --- a/lib/meeting/rooms.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { WebSocket } from "ws"; -import type { MeetingRole } from "./token"; - -export type Participant = { - ws: WebSocket; - participantId: string; - clientId?: string; - name: string; - role: MeetingRole; -}; - -type Room = { - participants: Map; - kickedParticipantIds: Set; - // Keyed by the client-declared, localStorage-persisted clientId (see - // token.ts) rather than the per-connection participantId, so a kick - // survives the participant simply reconnecting (which always mints a - // fresh participantId) — see isKicked below. - kickedClientIds: Set; -}; - -// In-memory only — this server process is the single source of truth for -// signaling (no media is ever stored or relayed through it). If this app is -// ever scaled to multiple instances, this state needs to move to a shared -// store (e.g. Redis pub/sub) alongside a real SFU. -const rooms = new Map(); - -function getOrCreateRoom(eventSlug: string): Room { - let room = rooms.get(eventSlug); - if (!room) { - room = { participants: new Map(), kickedParticipantIds: new Set(), kickedClientIds: new Set() }; - rooms.set(eventSlug, room); - } - return room; -} - -export function isKicked(eventSlug: string, participantId: string, clientId?: string): boolean { - const room = rooms.get(eventSlug); - if (!room) return false; - if (room.kickedParticipantIds.has(participantId)) return true; - return clientId ? room.kickedClientIds.has(clientId) : false; -} - -export function joinRoom(eventSlug: string, participant: Participant): Participant[] { - const room = getOrCreateRoom(eventSlug); - const existing = [...room.participants.values()]; - // Two connections claiming the same participantId (e.g. two tabs replaying - // the same stored session) would otherwise silently overwrite the map - // entry here and orphan the first socket — close it first so it doesn't - // leak as an unreachable broadcast target. - room.participants.get(participant.participantId)?.ws.close(4002, "replaced-by-new-connection"); - room.participants.set(participant.participantId, participant); - return existing; -} - -export function leaveRoom(eventSlug: string, participantId: string) { - const room = rooms.get(eventSlug); - if (!room) return; - room.participants.delete(participantId); - if (room.participants.size === 0) rooms.delete(eventSlug); -} - -export function kickFromRoom(eventSlug: string, participantId: string) { - const room = rooms.get(eventSlug); - if (!room) return; - room.kickedParticipantIds.add(participantId); - const target = room.participants.get(participantId); - if (target?.clientId) room.kickedClientIds.add(target.clientId); - room.participants.delete(participantId); -} - -export function getParticipant(eventSlug: string, participantId: string): Participant | undefined { - return rooms.get(eventSlug)?.participants.get(participantId); -} - -export function getRoomParticipants(eventSlug: string): Participant[] { - return [...(rooms.get(eventSlug)?.participants.values() ?? [])]; -} - -export function broadcast( - eventSlug: string, - message: unknown, - opts: { exclude?: string } = {}, -) { - const room = rooms.get(eventSlug); - if (!room) return; - const data = JSON.stringify(message); - for (const p of room.participants.values()) { - if (opts.exclude && p.participantId === opts.exclude) continue; - if (p.ws.readyState === p.ws.OPEN) p.ws.send(data); - } -} diff --git a/lib/meeting/signalingServer.ts b/lib/meeting/signalingServer.ts deleted file mode 100644 index 7226bed..0000000 --- a/lib/meeting/signalingServer.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { WebSocket, WebSocketServer } from "ws"; -import { verifyMeetingToken } from "./token"; -import { - broadcast, - getParticipant, - isKicked, - joinRoom, - kickFromRoom, - leaveRoom, - type Participant, -} from "./rooms"; -import type { ClientToServerMessage, ParticipantSummary } from "./protocol"; - -// A connection has this long to send its "auth" message before being -// dropped. The token travels as the first WS message rather than a -// `?token=` query parameter so it doesn't end up persisted in reverse-proxy -// or access logs; it's still HMAC-signed and short-lived either way. -const AUTH_TIMEOUT_MS = 10_000; - -function participantSummary(p: Participant): ParticipantSummary { - return { participantId: p.participantId, name: p.name, role: p.role }; -} - -export function attachSignalingServer(wss: WebSocketServer) { - wss.on("connection", (ws: WebSocket) => { - let self: Participant | null = null; - let eventSlug: string | null = null; - - const authTimeout = setTimeout(() => { - if (!self) ws.close(4000, "auth-timeout"); - }, AUTH_TIMEOUT_MS); - - function handleAuth(token: string) { - const payload = verifyMeetingToken(token); - if (!payload) { - ws.send(JSON.stringify({ type: "auth-error", reason: "invalid-token" })); - ws.close(4000, "invalid-token"); - return; - } - if (isKicked(payload.eventSlug, payload.participantId, payload.clientId)) { - ws.close(4001, "removed-by-host"); - return; - } - - eventSlug = payload.eventSlug; - self = { - ws, - participantId: payload.participantId, - clientId: payload.clientId, - name: payload.name, - role: payload.role, - }; - - const existing = joinRoom(eventSlug, self); - - ws.send( - JSON.stringify({ - type: "welcome", - participantId: self.participantId, - role: self.role, - participants: existing.map(participantSummary), - }), - ); - - broadcast(eventSlug, { type: "peer-joined", ...participantSummary(self) }, { - exclude: self.participantId, - }); - } - - ws.on("message", (raw) => { - let message: ClientToServerMessage; - try { - message = JSON.parse(raw.toString()); - } catch { - return; - } - - if (message.type === "auth") { - clearTimeout(authTimeout); - if (self) return; // already authenticated on this connection - handleAuth(message.token); - return; - } - - if (!self || !eventSlug) return; // no messages accepted before auth - - if (message.type === "offer" || message.type === "answer" || message.type === "ice-candidate") { - const target = getParticipant(eventSlug, message.to); - if (target && target.ws.readyState === target.ws.OPEN) { - target.ws.send( - JSON.stringify({ type: message.type, from: self.participantId, payload: message.payload }), - ); - } - return; - } - - if (message.type === "kick") { - if (self.role !== "host") return; // server-side authority, not client-claimed - const target = getParticipant(eventSlug, message.targetParticipantId); - if (!target) return; - kickFromRoom(eventSlug, target.participantId); - target.ws.send(JSON.stringify({ type: "kicked" })); - target.ws.close(4001, "removed-by-host"); - broadcast(eventSlug, { type: "peer-left", participantId: target.participantId }); - } - }); - - ws.on("close", () => { - clearTimeout(authTimeout); - if (!self || !eventSlug) return; - leaveRoom(eventSlug, self.participantId); - broadcast(eventSlug, { type: "peer-left", participantId: self.participantId }); - }); - }); -} diff --git a/lib/meeting/status.ts b/lib/meeting/status.ts index c6db9b7..0df4611 100644 --- a/lib/meeting/status.ts +++ b/lib/meeting/status.ts @@ -1,3 +1,5 @@ +import { berlinParts, berlinWallTimeToUTC } from "@/lib/calendar/timezone"; + export type MeetingStatus = "scheduled" | "open" | "live" | "ended"; /** @@ -9,9 +11,10 @@ export function combineDateAndTime(dateISO: string, timeISO?: string | null): Da const date = new Date(dateISO); if (!timeISO) return date; const time = new Date(timeISO); - const combined = new Date(date); - combined.setHours(time.getHours(), time.getMinutes(), 0, 0); - return combined; + if (Number.isNaN(date.getTime()) || Number.isNaN(time.getTime())) return new Date(NaN); + const dateParts = berlinParts(date); + const timeParts = berlinParts(time); + return berlinWallTimeToUTC(dateParts, timeParts.hour, timeParts.minute); } export type MeetingWindow = { diff --git a/lib/meeting/token.ts b/lib/meeting/token.ts index 2713f7e..570195b 100644 --- a/lib/meeting/token.ts +++ b/lib/meeting/token.ts @@ -61,7 +61,8 @@ export function createMeetingToken(args: { } /** Verifies signature + expiry. Returns null if invalid/expired/tampered. */ -export function verifyMeetingToken(token: string): MeetingTokenPayload | null { +export function verifyMeetingToken(token: unknown): MeetingTokenPayload | null { + if (typeof token !== "string" || token.length > 4096) return null; const parts = token.split("."); if (parts.length !== 2) return null; const [body, signature] = parts; @@ -73,8 +74,17 @@ export function verifyMeetingToken(token: string): MeetingTokenPayload | null { try { const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload; - if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null; - if (!payload.eventSlug || !payload.participantId || !payload.name || !payload.role) return null; + if ( + !Number.isFinite(payload.exp) || + payload.exp < Date.now() || + typeof payload.eventSlug !== "string" || + payload.eventSlug.length > 200 || + typeof payload.participantId !== "string" || + payload.participantId.length > 100 || + typeof payload.name !== "string" || + payload.name.length > 120 || + (payload.role !== "host" && payload.role !== "participant") + ) return null; return payload; } catch { return null; diff --git a/lib/payload/globals.ts b/lib/payload/globals.ts index 4628fea..32d0859 100644 --- a/lib/payload/globals.ts +++ b/lib/payload/globals.ts @@ -1,6 +1,6 @@ import { cache } from "react"; import { getCMS } from "@/lib/payload/getPayload"; -import type { About, AktuellesIntro, AngeboteIntro, Booking, BookingSetting, Contact, CookieSetting, Home, SeoSetting } from "@/payload-types"; +import type { About, AktuellesIntro, AngeboteIntro, Booking, BookingSetting, Contact, CookieSetting, Home, MeetingSetting, SeoSetting } from "@/payload-types"; export const getHomeGlobal = cache(async (): Promise => { const payload = await getCMS(); @@ -37,6 +37,11 @@ export const getBookingSettingsGlobal = cache(async (): Promise return payload.findGlobal({ slug: "booking-settings" }); }); +export const getMeetingSettingsGlobal = cache(async (): Promise => { + const payload = await getCMS(); + return payload.findGlobal({ slug: "meeting-settings" }); +}); + export const getSEOSettingsGlobal = cache(async (): Promise => { const payload = await getCMS(); return payload.findGlobal({ slug: "seo-settings" }); diff --git a/next.config.ts b/next.config.ts index ae4d576..5097885 100644 --- a/next.config.ts +++ b/next.config.ts @@ -23,9 +23,8 @@ const baseSecurityHeaders = [ // Deliberately NOT applied to /admin or /api: Payload's admin bundle isn't // audited here for CSP compatibility, and API routes serve data, not HTML, // so a content policy doesn't apply to them anyway. connect-src stays broad -// (rather than 'self') because the WebRTC call pages connect to whatever -// STUN/TURN server is configured via env vars at runtime (see -// lib/meeting/iceServers.ts) — that host isn't known here. style-src needs +// because the WebRTC call pages connect to the separately hosted LiveKit SFU +// at runtime. style-src needs // 'unsafe-inline' because framer-motion drives its animations through // inline style attributes. script-src allows 'unsafe-eval' only in dev, // matching Next's own documented CSP guidance — React uses eval there for @@ -48,8 +47,8 @@ const frontendCSP = [ ].join("; "); const nextConfig: NextConfig = { - // NOT "standalone": this project uses a custom server (server.ts) for the - // WebRTC signaling WebSocket, and standalone output can't be combined with + // NOT "standalone": this project uses a custom server (server.ts), and + // standalone output can't be combined with // a custom server. The Dockerfile runs the full app via `npm start` instead. images: { localPatterns: [ diff --git a/package-lock.json b/package-lock.json index 6355c06..4e42f1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,22 +8,23 @@ "name": "anouma", "version": "0.1.0", "dependencies": { - "@payloadcms/db-postgres": "^3.88.0", - "@payloadcms/next": "^3.88.0", - "@payloadcms/richtext-lexical": "^3.88.0", - "@payloadcms/ui": "^3.88.0", + "@payloadcms/db-postgres": "^3.89.0", + "@payloadcms/next": "^3.89.0", + "@payloadcms/richtext-lexical": "^3.89.0", + "@payloadcms/ui": "^3.89.0", "drizzle-orm": "^0.45.2", "framer-motion": "^13.1.1", "graphql": "^16.14.2", - "next": "16.3.2", + "livekit-client": "^2.22.3", + "livekit-server-sdk": "^2.19.0", + "next": "^16.3.5", "nodemailer": "^9.0.5", - "payload": "^3.88.0", + "payload": "^3.89.0", "react": "19.2.8", "react-dom": "19.2.8", "recharts": "^3.10.1", "sharp": "^0.35.3", - "tsx": "^4.23.12", - "ws": "^8.21.3" + "tsx": "^4.23.12" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -31,9 +32,8 @@ "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", - "@types/ws": "^8.18.1", "eslint": "^9", - "eslint-config-next": "16.3.2", + "eslint-config-next": "^16.3.5", "tailwindcss": "^4", "typescript": "^5" } @@ -317,6 +317,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@date-fns/tz": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", @@ -1702,9 +1708,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -1720,13 +1726,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -1742,20 +1748,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1765,9 +1771,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -1781,9 +1787,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -1797,9 +1803,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -1813,9 +1819,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -1829,9 +1835,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -1845,9 +1851,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -1861,9 +1867,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -1877,9 +1883,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -1893,9 +1899,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -1909,9 +1915,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -1925,9 +1931,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -1943,13 +1949,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -1965,13 +1971,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -1987,13 +1993,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -2009,13 +2015,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -2031,13 +2037,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -2053,13 +2059,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -2075,13 +2081,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -2097,17 +2103,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -2117,16 +2123,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -2136,9 +2142,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -2155,9 +2161,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -2174,9 +2180,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -2542,6 +2548,21 @@ "yjs": ">=13.5.22" } }, + "node_modules/@livekit/mutex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", + "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "license": "Apache-2.0" + }, + "node_modules/@livekit/protocol": { + "version": "1.50.4", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz", + "integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, "node_modules/@monaco-editor/loader": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", @@ -2588,15 +2609,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.2.tgz", - "integrity": "sha512-8k4YoG8cM7LWlkfzGNYCRBbFNlernLiMw4s0btVl+CmmWqn3VpYypA72/5Feb1UWdxe6tHqr5KHP4p4Y4m9luA==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", + "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.2.tgz", - "integrity": "sha512-z+HW1cZgt8QhByw8p2EbxF94AImgsKIYUbtSkA7Zld2T9yrKAlys4jNOcAOCtv6csX2CoA/5qCVyesL5pHmJ0A==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.5.tgz", + "integrity": "sha512-PGfSeItHJ12DH8t+6sEbuMe59NE5rAhCfgk06QKTH2ne9VUL1JlaXdYXY3B8RaiF2SO50rDYvd39TulP6A6xZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2637,9 +2658,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.2.tgz", - "integrity": "sha512-ib5Llm93YCKoKWDh6ZaHq6QWTuOZ2bRkSnUwMmX8dsRIOkBNL1vVlSiUKSfixPL9SSh9pvukzqajk/klkn5vqg==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", + "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", "cpu": [ "arm64" ], @@ -2653,9 +2674,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.2.tgz", - "integrity": "sha512-qd98fX2+I5nYJDioW2o7nSjoxM5KvWdeDefM80igia4+C/qSIEhH4MhTE+hO/7qKM7W37/Mq+dOWp8UePSyLHw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", + "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", "cpu": [ "x64" ], @@ -2669,9 +2690,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.2.tgz", - "integrity": "sha512-vqsgb6FAOzcrCccsLXiKtAy5t8EzO+uOazuFaSkQxeY0tNONG3vpHYy8pyBafcI5SNFPTeyard6yTr6SzNGo2A==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", + "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", "cpu": [ "arm64" ], @@ -2685,9 +2706,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.2.tgz", - "integrity": "sha512-xIe1eujfHUB2XcxHGddxJyu6TJRPjC5NpIkQYB/32ESkt5VkQyIAjmLRS38c+s6QY+qjtY/4KarVDzXRuD7lZQ==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", + "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", "cpu": [ "arm64" ], @@ -2701,9 +2722,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.2.tgz", - "integrity": "sha512-Fe0SA2j8X0kmc3aveuHD7UktO3AE2+mH3LguP60vGbz7u0z+MrDXbeb5iZFYAwR7EzzzXJ2Yk966w9mGTFMqfA==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", + "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", "cpu": [ "x64" ], @@ -2717,9 +2738,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.2.tgz", - "integrity": "sha512-TFBipb+gyesI/2Ve4zVu7kGltBWN/R466G5/1gtt2lECfc22G1pjkTxu68Q9aFcOaXiRGTQfvDbQQFe7mYgxiQ==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", + "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", "cpu": [ "x64" ], @@ -2733,9 +2754,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.2.tgz", - "integrity": "sha512-rVtmnNpBYIosDnKD/96dKxFsJnwnn1WRGG/HioSe8XCm2ksSHNrd2R6+hSjvTBxeMNhJ9pYeu/90cWB1nQLuNA==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", + "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", "cpu": [ "arm64" ], @@ -2749,9 +2770,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.2.tgz", - "integrity": "sha512-H4Y2o2/JcHu8LtwzD5CXfHhwxwz8gfsx2HXDEw46Mtev5xHnEmB7HNtZtmriw5ReUOjRtcDqo7XSbU01FT9NlA==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", + "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", "cpu": [ "x64" ], @@ -2813,12 +2834,12 @@ } }, "node_modules/@payloadcms/db-postgres": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/@payloadcms/db-postgres/-/db-postgres-3.88.0.tgz", - "integrity": "sha512-uP2b3yH8MulguWSH7j8vJaFX3ols4fVKPtsvZ49XQvDsafBAAUZjc1aCldo1ozoVfhC46tx4pv0ovtHQc8zdcA==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/@payloadcms/db-postgres/-/db-postgres-3.89.0.tgz", + "integrity": "sha512-Z6sa/8ahAYqW7ttcAL4Y1+50w5SaEN0hJdNVJCzgBFB/1nnrcmFaKO5m++aeeTsAdffSRXV9J8G5n8fBChMr5w==", "license": "MIT", "dependencies": { - "@payloadcms/drizzle": "3.88.0", + "@payloadcms/drizzle": "3.89.0", "@types/pg": "8.20.0", "console-table-printer": "2.12.1", "drizzle-kit": "0.31.7", @@ -2829,13 +2850,13 @@ "uuid": "13.0.2" }, "peerDependencies": { - "payload": "3.88.0" + "payload": "3.89.0" } }, "node_modules/@payloadcms/drizzle": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/@payloadcms/drizzle/-/drizzle-3.88.0.tgz", - "integrity": "sha512-qtPVSLjAFnQl1GwaQPhmnYTwCCWgTnRm4ps5yJxmN3RPdLF6S77gvqZMnC4HTeH7YADQJW3nFgQCVYExxWE6hA==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/@payloadcms/drizzle/-/drizzle-3.89.0.tgz", + "integrity": "sha512-/Ijf87WfIaIGDrlAQBOXcNxRZy+IDfpZQvpmJpA4BwO7PEVMqU6nHSUYVgbFHy6+4XYHvsTp31/4xo06yQwiOw==", "license": "MIT", "dependencies": { "console-table-printer": "2.12.1", @@ -2846,13 +2867,13 @@ "uuid": "13.0.2" }, "peerDependencies": { - "payload": "3.88.0" + "payload": "3.89.0" } }, "node_modules/@payloadcms/graphql": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/@payloadcms/graphql/-/graphql-3.88.0.tgz", - "integrity": "sha512-ync+bPFl0UacwBs1wVQkPlaSWWQJc4LjGG9OuRHEW7wYW3aeVQ+H2zfdFL6/cVJGP3zOq+XIlvYYw8G86Ki+Vw==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/@payloadcms/graphql/-/graphql-3.89.0.tgz", + "integrity": "sha512-oa77vdW2yKwla8hXKDQOYvCLZNn6rmR8Th2QgGfq3gAvlR5hEnZ7KH9VK23AfkimXRuHKnI0tH8K+OaBOKdStA==", "license": "MIT", "dependencies": { "graphql-scalars": "1.22.2", @@ -2865,7 +2886,7 @@ }, "peerDependencies": { "graphql": "^16.8.1", - "payload": "3.88.0" + "payload": "3.89.0" } }, "node_modules/@payloadcms/graphql/node_modules/@esbuild/aix-ppc64": { @@ -3344,17 +3365,17 @@ } }, "node_modules/@payloadcms/next": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/@payloadcms/next/-/next-3.88.0.tgz", - "integrity": "sha512-3f2YOGSmtTp85YnlJm9FusRgshKJbiLI8uXs8iQNMwU7RZJnLCURG8iv/6sjKHO0owIfw3vIL5qgxdtIBuQwpA==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/@payloadcms/next/-/next-3.89.0.tgz", + "integrity": "sha512-1PEzFEQIRhrOQh1cKegmNcaOQxWXA5wflxMkgqZqKZN62i64/gMB4MWspyfBEHjiAT0NPFDZyK7e522qEOj4vg==", "license": "MIT", "dependencies": { "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", "@dnd-kit/sortable": "10.0.0", - "@payloadcms/graphql": "3.88.0", - "@payloadcms/translations": "3.88.0", - "@payloadcms/ui": "3.88.0", + "@payloadcms/graphql": "3.89.0", + "@payloadcms/translations": "3.89.0", + "@payloadcms/ui": "3.89.0", "busboy": "^1.6.0", "dequal": "2.0.3", "file-type": "21.3.4", @@ -3372,13 +3393,13 @@ "peerDependencies": { "graphql": "^16.8.1", "next": ">=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.6 <17.0.0", - "payload": "3.88.0" + "payload": "3.89.0" } }, "node_modules/@payloadcms/richtext-lexical": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/@payloadcms/richtext-lexical/-/richtext-lexical-3.88.0.tgz", - "integrity": "sha512-lY8oIUiggykWKB3ljMfSidxBtA8ahMq0Qqp28OuvaCNIDp0HTMIPxcHvFOQZngMqtQHhpautD6vcQR0Q9CEHHQ==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/@payloadcms/richtext-lexical/-/richtext-lexical-3.89.0.tgz", + "integrity": "sha512-IV9sKT/fYptRJXk1XfIKW6V2sBWma4hofBMPI3b1Ab0ClKlyudwrtRtqv9cZzxwoNMYhtC8h+3SQMOq10eI2bQ==", "license": "MIT", "dependencies": { "@lexical/clipboard": "0.41.0", @@ -3392,8 +3413,8 @@ "@lexical/selection": "0.41.0", "@lexical/table": "0.41.0", "@lexical/utils": "0.41.0", - "@payloadcms/translations": "3.88.0", - "@payloadcms/ui": "3.88.0", + "@payloadcms/translations": "3.89.0", + "@payloadcms/ui": "3.89.0", "acorn": "8.16.0", "bson-objectid": "2.0.4", "csstype": "3.1.3", @@ -3415,8 +3436,8 @@ "peerDependencies": { "@faceless-ui/modal": "3.0.0", "@faceless-ui/scroll-info": "2.0.0", - "@payloadcms/next": "3.88.0", - "payload": "3.88.0", + "@payloadcms/next": "3.89.0", + "payload": "3.89.0", "react": "^19.0.1 || ^19.1.2 || ^19.2.1", "react-dom": "^19.0.1 || ^19.1.2 || ^19.2.1" } @@ -3440,18 +3461,18 @@ "license": "MIT" }, "node_modules/@payloadcms/translations": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/@payloadcms/translations/-/translations-3.88.0.tgz", - "integrity": "sha512-AZVQjjW5pKxO+K9ODeh6yw6/EfXK1hxGPw55KzzDC5srO3hjNskgyWtkauKvbz+S0+xFspANbRc4OD/Rg/74WA==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/@payloadcms/translations/-/translations-3.89.0.tgz", + "integrity": "sha512-XLPOdv9NoPcqr21zBTu0uyJk0qGRIF7JVl2H2UTecpweQSMlQCackhUKWz1LKBO7QcKgkNSVHFnMNcizRDcPDg==", "license": "MIT", "dependencies": { "date-fns": "4.1.0" } }, "node_modules/@payloadcms/ui": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/@payloadcms/ui/-/ui-3.88.0.tgz", - "integrity": "sha512-iwQhHPDn4Oj2bPg1UV467BoxfUNo1rUmD8jMZaw2Vtt/nmxAPl3W3CWN/CsOX/yITuPuGHJxznOg4PZNMF3gEg==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/@payloadcms/ui/-/ui-3.89.0.tgz", + "integrity": "sha512-jKc8kgSjaICZ39sNC4dr4MLkAj2lR9ikzSwcHzPGOsJlleOVPJq5jM9bmAnqEOhtbpPNl0CGlpo6RIeECnBmwg==", "license": "MIT", "dependencies": { "@date-fns/tz": "1.2.0", @@ -3462,7 +3483,8 @@ "@faceless-ui/scroll-info": "2.0.0", "@faceless-ui/window-info": "3.0.1", "@monaco-editor/react": "4.7.0", - "@payloadcms/translations": "3.88.0", + "@payloadcms/translations": "3.89.0", + "@swc/helpers": ">=0.5.17", "bson-objectid": "2.0.4", "date-fns": "4.1.0", "dequal": "2.0.3", @@ -3483,7 +3505,7 @@ }, "peerDependencies": { "next": ">=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.6 <17.0.0", - "payload": "3.88.0", + "payload": "3.89.0", "react": "^19.0.1 || ^19.1.2 || ^19.2.1", "react-dom": "^19.0.1 || ^19.1.2 || ^19.2.1" } @@ -4057,6 +4079,7 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4081,14 +4104,6 @@ "@types/react": "*" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5903,16 +5918,6 @@ "csstype": "^3.0.2" } }, - "node_modules/dompurify": { - "version": "3.4.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", - "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/drizzle-kit": { "version": "0.31.7", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.7.tgz", @@ -6479,13 +6484,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.2.tgz", - "integrity": "sha512-gTABOJmyc6pEgSX1Z1VOjBxkSmo5Hkdj+ePclDf8HLGTsnVWzgtDdrOeQrAnUkf0JNJJhwPuXrsIwmFZRKJLoQ==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.5.tgz", + "integrity": "sha512-wPjq9MLQuWykHs8tsm2gH6OJThk6N27L8Te2JatlaGZ7jT1gtgGmJKukygL28xOlWsg5J1a1bGy/PvLyQC8OYA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.3.2", + "@next/eslint-plugin-next": "16.3.5", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -6854,6 +6859,15 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-copy": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", @@ -6933,9 +6947,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, "license": "ISC", "dependencies": { @@ -8115,17 +8129,6 @@ "dev": true, "license": "ISC" }, - "node_modules/isomorphic.js": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", - "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", - "license": "MIT", - "peer": true, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -8179,9 +8182,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -8359,28 +8362,6 @@ "integrity": "sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==", "license": "MIT" }, - "node_modules/lib0": { - "version": "0.2.117", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", - "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", - "license": "MIT", - "peer": true, - "dependencies": { - "isomorphic.js": "^0.2.4" - }, - "bin": { - "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", - "0gentesthtml": "bin/gentesthtml.js", - "0serve": "bin/0serve.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -8648,6 +8629,59 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/livekit-client": { + "version": "2.22.3", + "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.22.3.tgz", + "integrity": "sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/mutex": "1.1.1", + "@livekit/protocol": "1.50.4", + "events": "^3.3.0", + "jose": "^6.1.0", + "loglevel": "^1.9.2", + "machina": "^7.0.0", + "sdp-transform": "^2.15.0", + "tslib": "2.8.1", + "typed-emitter": "^2.1.0", + "webrtc-adapter": "9.0.6" + }, + "peerDependencies": { + "@types/dom-mediacapture-record": "^1" + } + }, + "node_modules/livekit-client/node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/livekit-server-sdk": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.19.0.tgz", + "integrity": "sha512-9q+MuVS2qky5+Xaf7/XalMu90EcGX2wkKBy80ANlJH8WCulQpfNMU8ILIMq913X59kH6itNyxP4fr16616KG3A==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.1", + "@livekit/protocol": "1.51.0", + "jose": "^5.1.2" + }, + "engines": { + "node": ">=19" + } + }, + "node_modules/livekit-server-sdk/node_modules/@livekit/protocol": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.51.0.tgz", + "integrity": "sha512-kEDEDaFvIcp5VlYBromdSKMJMEJKfPvXPfEaJPdEz1Qgo84gZAoLF2g58CcMlVYGsjrwE5XYgQxRS5B7KuEj3w==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -8677,6 +8711,19 @@ "dev": true, "license": "MIT" }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -8709,6 +8756,15 @@ "yallist": "^3.0.2" } }, + "node_modules/machina": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/machina/-/machina-7.0.1.tgz", + "integrity": "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==", + "license": "MIT", + "engines": { + "node": ">=22.22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -8719,19 +8775,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", - "license": "MIT", - "peer": true, - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -9418,17 +9461,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/monaco-editor": { - "version": "0.56.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", - "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", - "license": "MIT", - "peer": true, - "dependencies": { - "dompurify": "3.4.8", - "marked": "14.0.0" - } - }, "node_modules/motion-dom": { "version": "13.1.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.1.1.tgz", @@ -9492,12 +9524,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.2.tgz", - "integrity": "sha512-/ZCaubUy17Lld1SiPWxuPbCk2ihqAxF2QNQaPZeEaEb7t1I58qhsJN187D7AfpapHAqUPXH0f/thtdW9dWgWFg==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", + "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", "license": "MIT", "dependencies": { - "@next/env": "16.3.2", + "@next/env": "16.3.5", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -9511,15 +9543,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.2", - "@next/swc-darwin-x64": "16.3.2", - "@next/swc-linux-arm64-gnu": "16.3.2", - "@next/swc-linux-arm64-musl": "16.3.2", - "@next/swc-linux-x64-gnu": "16.3.2", - "@next/swc-linux-x64-musl": "16.3.2", - "@next/swc-win32-arm64-msvc": "16.3.2", - "@next/swc-win32-x64-msvc": "16.3.2", - "sharp": "^0.35.3" + "@next/swc-darwin-arm64": "16.3.5", + "@next/swc-darwin-x64": "16.3.5", + "@next/swc-linux-arm64-gnu": "16.3.5", + "@next/swc-linux-arm64-musl": "16.3.5", + "@next/swc-linux-x64-gnu": "16.3.5", + "@next/swc-linux-x64-musl": "16.3.5", + "@next/swc-win32-arm64-msvc": "16.3.5", + "@next/swc-win32-x64-msvc": "16.3.5", + "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -9602,9 +9634,9 @@ } }, "node_modules/nodemailer": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", - "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz", + "integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -9931,13 +9963,13 @@ } }, "node_modules/payload": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/payload/-/payload-3.88.0.tgz", - "integrity": "sha512-O7zuS80bvEGLte+7xZjwN05+ox5BCsGcQT2M6+CTote07JQOOvHJoiuoyQFw6cUElcFTWGMC5dy03w7J7sTYGg==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/payload/-/payload-3.89.0.tgz", + "integrity": "sha512-n0rDsMDTlKkNDqNllSLow1xEn2XaktD2oyi6Gl7i/LAD7xRBaS+tj27ca9EeNM5QmoGSd734UCHYj5Vr4JQYNA==", "license": "MIT", "dependencies": { "@next/env": "^15.1.5", - "@payloadcms/translations": "3.88.0", + "@payloadcms/translations": "3.89.0", "@types/busboy": "1.5.4", "ajv": "8.18.0", "bson-objectid": "2.0.4", @@ -11237,6 +11269,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -11333,6 +11375,21 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -11409,9 +11466,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", @@ -11425,31 +11482,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" }, "peerDependenciesMeta": { "@types/node": { @@ -12676,11 +12733,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12995,6 +13061,19 @@ "d3-timer": "^3.0.1" } }, + "node_modules/webrtc-adapter": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", + "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -13187,24 +13266,6 @@ "node": ">= 6" } }, - "node_modules/yjs": { - "version": "13.6.32", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.32.tgz", - "integrity": "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "lib0": "^0.2.99" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index cb6078c..6aa2e3c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "next build", "start": "NODE_ENV=production tsx server.ts", "lint": "eslint", - "test": "echo \"No automated test suite configured yet — add one (e.g. vitest) and update this script.\"", + "test": "node --import tsx --test tests/**/*.test.ts", "payload": "payload", "generate:types": "payload generate:types", "generate:importmap": "payload generate:importmap", @@ -18,22 +18,23 @@ "seo:check": "payload run scripts/seo-check.ts" }, "dependencies": { - "@payloadcms/db-postgres": "^3.88.0", - "@payloadcms/next": "^3.88.0", - "@payloadcms/richtext-lexical": "^3.88.0", - "@payloadcms/ui": "^3.88.0", + "@payloadcms/db-postgres": "^3.89.0", + "@payloadcms/next": "^3.89.0", + "@payloadcms/richtext-lexical": "^3.89.0", + "@payloadcms/ui": "^3.89.0", "drizzle-orm": "^0.45.2", "framer-motion": "^13.1.1", "graphql": "^16.14.2", - "next": "16.3.2", + "livekit-client": "^2.22.3", + "livekit-server-sdk": "^2.19.0", + "next": "^16.3.5", "nodemailer": "^9.0.5", - "payload": "^3.88.0", + "payload": "^3.89.0", "react": "19.2.8", "react-dom": "19.2.8", "recharts": "^3.10.1", "sharp": "^0.35.3", - "tsx": "^4.23.12", - "ws": "^8.21.3" + "tsx": "^4.23.12" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -41,9 +42,8 @@ "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", - "@types/ws": "^8.18.1", "eslint": "^9", - "eslint-config-next": "16.3.2", + "eslint-config-next": "^16.3.5", "tailwindcss": "^4", "typescript": "^5" } diff --git a/payload.config.ts b/payload.config.ts index c938b7e..aea81b8 100644 --- a/payload.config.ts +++ b/payload.config.ts @@ -94,6 +94,10 @@ export default buildConfig({ AnalyticsSettings, ], editor: lexicalEditor(), + graphQL: { + disablePlaygroundInProduction: true, + disableIntrospectionInProduction: true, + }, secret: process.env.PAYLOAD_SECRET || "", typescript: { outputFile: path.resolve(dirname, "payload-types.ts"), diff --git a/server.ts b/server.ts index d52ef9c..b6a1597 100644 --- a/server.ts +++ b/server.ts @@ -1,25 +1,17 @@ /** - * Custom Next.js server so we can attach a plain WebSocket server for WebRTC - * signaling (offer/answer/ICE relay) alongside the normal Next.js request - * handler. Standalone output mode can't be combined with a custom server - * (see next.config.ts), so this project deploys by running this file - * directly (`npm run start` / `npm run dev`) instead of `next start`. - * - * The WS server never touches audio/video/screen-share media — it only - * relays small JSON signaling messages between participants of the same - * meeting room (see lib/meeting/signalingServer.ts). + * Custom Next.js server. Standalone output mode can't be combined with a + * custom server (see next.config.ts), so production runs this file directly. + * LiveKit handles signaling and media separately; this process only serves + * the website and its API. */ import { createServer } from "node:http"; import next from "next"; -import { WebSocketServer } from "ws"; -import { attachSignalingServer } from "./lib/meeting/signalingServer"; const port = parseInt(process.env.PORT || "3000", 10); const hostname = process.env.HOSTNAME || "0.0.0.0"; const dev = process.env.NODE_ENV !== "production"; const app = next({ dev, hostname, port }); -const SIGNALING_PATH = "/ws/signaling"; app.prepare().then(() => { // Must be requested after prepare() resolves. @@ -30,24 +22,14 @@ app.prepare().then(() => { handle(req, res); }); - const wss = new WebSocketServer({ noServer: true }); - attachSignalingServer(wss); - server.on("upgrade", (req, socket, head) => { - const url = req.url ?? ""; - if (url.startsWith(SIGNALING_PATH)) { - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit("connection", ws, req); - }); - } else { - // Let Next.js handle its own upgrade requests (e.g. dev-mode HMR). - upgradeHandle(req, socket, head); - } + // Let Next.js handle its own upgrade requests (e.g. dev-mode HMR). + upgradeHandle(req, socket, head); }); server.listen(port, () => { console.log( - `> ANOUMA server listening at http://${hostname}:${port} (${dev ? "development" : "production"}), signaling on ${SIGNALING_PATH}`, + `> ANOUMA server listening at http://${hostname}:${port} (${dev ? "development" : "production"})`, ); }); }); diff --git a/setup.sh b/setup.sh index 24e867a..4833941 100755 --- a/setup.sh +++ b/setup.sh @@ -310,10 +310,9 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= SMTP_FROM= -STUN_SERVER=stun:stun.l.google.com:19302 -TURN_SERVER= -TURN_USERNAME= -TURN_PASSWORD= +LIVEKIT_URL= +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= GOOGLE_SITE_VERIFICATION= DOCKER_NETWORK=anouma-network NPM_NETWORK= @@ -353,6 +352,14 @@ else log_info "NEXT_PUBLIC_SERVER_URL bereits vorhanden — unverändert." fi +if [ -z "$(env_get LIVEKIT_URL)" ]; then + BASE_DOMAIN="${DOMAIN:-$(env_get NEXT_PUBLIC_SERVER_URL | sed -E 's#^https?://([^/]+).*#\1#')}" + LIVEKIT_DOMAIN="$(ask "LiveKit-Domain (DNS + TLS-Reverse-Proxy nötig)" "livekit.${BASE_DOMAIN}" "LIVEKIT_DOMAIN")" + env_set LIVEKIT_URL "https://${LIVEKIT_DOMAIN}" +fi +env_ensure LIVEKIT_API_KEY "LK$(openssl rand -hex 12)" && log_info "LIVEKIT_API_KEY generiert." || log_info "LIVEKIT_API_KEY bereits vorhanden — unverändert." +env_ensure LIVEKIT_API_SECRET "$(gen_secret)" && log_info "LIVEKIT_API_SECRET generiert." || log_info "LIVEKIT_API_SECRET bereits vorhanden — unverändert." + if [ -z "$(env_get SMTP_HOST)" ]; then SETUP_SMTP="$(ask_yn "SMTP-Zugangsdaten jetzt eintragen? (sonst später manuell in .env)" "N")" if [ "$SETUP_SMTP" = true ]; then @@ -437,6 +444,9 @@ echo "ANALYTICS_SALT_SECRET: $(mask "$(env_get ANALYTICS_SALT_SECRET)")" echo "CRON_SECRET: $(mask "$(env_get CRON_SECRET)")" echo "SMTP_HOST: $(env_get SMTP_HOST)" echo "SMTP_PASSWORD: $(mask "$(env_get SMTP_PASSWORD)")" +echo "LIVEKIT_URL: $(env_get LIVEKIT_URL)" +echo "LIVEKIT_API_KEY: $(mask "$(env_get LIVEKIT_API_KEY)")" +echo "LIVEKIT_API_SECRET: $(mask "$(env_get LIVEKIT_API_SECRET)")" echo "NPM_NETWORK: $(env_get NPM_NETWORK)" echo "REGISTRY: $(env_get REGISTRY)/$(env_get REGISTRY_REPO)" echo "REGISTRY_USERNAME: $(env_get REGISTRY_USERNAME)" diff --git a/tests/meeting.test.ts b/tests/meeting.test.ts new file mode 100644 index 0000000..5ecf07a --- /dev/null +++ b/tests/meeting.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +process.env.MEETING_SESSION_SECRET = "test-only-meeting-secret"; + +import { berlinParts } from "../lib/calendar/timezone"; +import { combineDateAndTime, getMeetingStatus } from "../lib/meeting/status"; +import { createMeetingToken, verifyMeetingToken } from "../lib/meeting/token"; + +test("meeting tokens round-trip and preserve their role", () => { + const issued = createMeetingToken({ eventSlug: "beratung", name: "Anna", role: "host", ttlMs: 60_000 }); + const verified = verifyMeetingToken(issued.token); + + assert.ok(verified); + assert.equal(verified.eventSlug, "beratung"); + assert.equal(verified.participantId, issued.participantId); + assert.equal(verified.role, "host"); +}); + +test("tampered and malformed meeting tokens are rejected", () => { + const issued = createMeetingToken({ eventSlug: "beratung", name: "Guest", role: "participant" }); + + assert.equal(verifyMeetingToken(`${issued.token}x`), null); + assert.equal(verifyMeetingToken("not-a-token"), null); + assert.equal(verifyMeetingToken({ token: issued.token }), null); +}); + +test("date-only and time-only Payload fields combine in Berlin time", () => { + // 08:00 in Berlin on a July date is 06:00 UTC (CEST). + const result = combineDateAndTime("2026-07-01T00:00:00.000Z", "2026-01-01T07:00:00.000Z"); + assert.equal(result.toISOString(), "2026-07-01T06:00:00.000Z"); +}); + +test("Berlin DST offsets are applied at both sides of the summer-time boundary", () => { + assert.equal(berlinParts(new Date("2026-01-15T12:00:00.000Z")).hour, 13); + assert.equal(berlinParts(new Date("2026-07-15T12:00:00.000Z")).hour, 14); +}); + +test("meeting status follows the join and close windows", () => { + const start = new Date("2026-07-01T10:00:00.000Z"); + const end = new Date("2026-07-01T11:00:00.000Z"); + const window = { start, end, joinWindowMinutes: 15, closeAfterMinutes: 10 }; + + assert.equal(getMeetingStatus({ ...window, now: new Date("2026-07-01T09:44:59.000Z") }), "scheduled"); + assert.equal(getMeetingStatus({ ...window, now: new Date("2026-07-01T09:45:00.000Z") }), "open"); + assert.equal(getMeetingStatus({ ...window, now: start }), "live"); + assert.equal(getMeetingStatus({ ...window, now: new Date("2026-07-01T11:10:00.000Z") }), "ended"); +});