Fix security and correctness issues found in full-codebase audit
- Restrict meetingPassword field so it isn't leaked via the public REST/GraphQL API - Fix booking slot computation to use Europe/Berlin time instead of the server's UTC clock - Reset email verification when a customer changes their email address - Fix ICS calendar injection via unescaped carriage returns in event descriptions - Bind WebRTC meeting kicks to a persistent client id so they survive a rejoin - Move the meeting signaling token out of the WebSocket query string into the first message - Prevent duplicate signaling connections from leaking when the same participant reconnects - Use the rightmost X-Forwarded-For entry for rate limiting (single trusted reverse proxy) - Use constant-time comparison for the cron endpoint's bearer token - Remove SVG from allowed Media upload types (stored-XSS risk, unused anyway) - Add baseline security headers and a CSP to next.config.ts - Fix CI/CD script injection via unsanitized git ref names in release.yml
This commit is contained in:
@@ -23,6 +23,7 @@ jobs:
|
|||||||
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
|
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
|
||||||
PAYLOAD_SECRET: ci-only-not-a-real-secret
|
PAYLOAD_SECRET: ci-only-not-a-real-secret
|
||||||
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
|
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
|
||||||
|
ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret
|
||||||
CRON_SECRET: ci-only-not-a-real-secret
|
CRON_SECRET: ci-only-not-a-real-secret
|
||||||
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ jobs:
|
|||||||
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
|
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
|
||||||
PAYLOAD_SECRET: ci-only-not-a-real-secret
|
PAYLOAD_SECRET: ci-only-not-a-real-secret
|
||||||
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
|
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
|
||||||
|
ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret
|
||||||
CRON_SECRET: ci-only-not-a-real-secret
|
CRON_SECRET: ci-only-not-a-real-secret
|
||||||
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
||||||
steps:
|
steps:
|
||||||
@@ -67,27 +68,43 @@ jobs:
|
|||||||
# so step commands use the same $GITHUB_OUTPUT / $GITHUB_ENV
|
# so step commands use the same $GITHUB_OUTPUT / $GITHUB_ENV
|
||||||
# mechanism (the `gitea.*` vs `github.*` expression contexts are
|
# mechanism (the `gitea.*` vs `github.*` expression contexts are
|
||||||
# just aliases of each other for `${{ }}` templating).
|
# just aliases of each other for `${{ }}` templating).
|
||||||
|
#
|
||||||
|
# The tag name (github.ref_name / $GITHUB_REF_NAME) is
|
||||||
|
# attacker-influenced: anyone with tag-push rights controls it, and
|
||||||
|
# git allows shell metacharacters in tag names. It's read here via
|
||||||
|
# the automatic $GITHUB_REF_NAME env var rather than `${{ github.ref_name }}`
|
||||||
|
# so its value reaches bash as data (argv/envp), never spliced into
|
||||||
|
# the script text — `${{ }}` expressions are substituted into the
|
||||||
|
# YAML before bash ever sees the script, which is what makes
|
||||||
|
# interpolating attacker-controlled values that way a script-injection
|
||||||
|
# vector (arbitrary code execution in a job that holds the registry
|
||||||
|
# and production deploy credentials).
|
||||||
run: |
|
run: |
|
||||||
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
|
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
|
||||||
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT"
|
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
echo "version=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.maro.run
|
REGISTRY: git.maro.run
|
||||||
REGISTRY_REPO: maro/anouma
|
REGISTRY_REPO: maro/anouma
|
||||||
|
|
||||||
- name: Log in to registry
|
- name: Log in to registry
|
||||||
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ steps.image.outputs.registry }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
env:
|
||||||
|
REGISTRY_IMAGE: ${{ steps.image.outputs.registry }}
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_IMAGE" -u "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
run: |
|
env:
|
||||||
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
|
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
|
||||||
VERSION="${{ steps.image.outputs.version }}"
|
VERSION: ${{ steps.image.outputs.version }}
|
||||||
docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
|
run: docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
|
||||||
|
|
||||||
- name: Push image
|
- name: Push image
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
|
||||||
|
VERSION: ${{ steps.image.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
|
|
||||||
VERSION="${{ steps.image.outputs.version }}"
|
|
||||||
docker push "$IMAGE:$VERSION"
|
docker push "$IMAGE:$VERSION"
|
||||||
docker push "$IMAGE:latest"
|
docker push "$IMAGE:latest"
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ export const isAdmin: Access = ({ req: { user } }) =>
|
|||||||
export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
|
export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
|
||||||
user?.collection === "users" && user.role === "admin";
|
user?.collection === "users" && user.role === "admin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admins always see Analytics; any other "users" account additionally needs
|
||||||
|
* its own `canViewAnalytics` flag (see collections/Users.ts) — this is the
|
||||||
|
* granular permission the single "admin" role doesn't otherwise provide.
|
||||||
|
* Used both as a Payload Access/FieldAccess function and called directly
|
||||||
|
* (with a minimal `{ req: { user } }` shape) from the custom admin nav-link
|
||||||
|
* and view components, which Payload does not gate via collection access.
|
||||||
|
*/
|
||||||
|
export const canViewAnalytics: Access = ({ req: { user } }) =>
|
||||||
|
user?.collection === "users" && (user.role === "admin" || user.canViewAnalytics === true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admins can read every document (including drafts); everyone else may only
|
* Admins can read every document (including drafts); everyone else may only
|
||||||
* read documents whose Payload draft/publish status is "published".
|
* read documents whose Payload draft/publish status is "published".
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { runEventReminders } from "@/lib/meeting/reminders";
|
import { runEventReminders } from "@/lib/meeting/reminders";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const bufA = Buffer.from(a);
|
||||||
|
const bufB = Buffer.from(b);
|
||||||
|
if (bufA.length !== bufB.length) return false;
|
||||||
|
return timingSafeEqual(bufA, bufB);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intended to be called by an external cron job every few minutes, e.g.:
|
* Intended to be called by an external cron job every few minutes, e.g.:
|
||||||
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
|
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
|
||||||
@@ -14,7 +22,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authHeader = request.headers.get("authorization");
|
const authHeader = request.headers.get("authorization");
|
||||||
if (authHeader !== `Bearer ${secret}`) {
|
if (!authHeader || !safeEqual(authHeader, `Bearer ${secret}`)) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type Args = { params: Promise<{ slug: string }> };
|
|||||||
export async function POST(request: Request, { params }: Args) {
|
export async function POST(request: Request, { params }: Args) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
let body: { name?: unknown; password?: unknown };
|
let body: { name?: unknown; password?: unknown; clientId?: unknown };
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -29,6 +29,7 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
|
|
||||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||||
const password = typeof body.password === "string" ? body.password.trim() : "";
|
const password = typeof body.password === "string" ? body.password.trim() : "";
|
||||||
|
const clientId = typeof body.clientId === "string" ? body.clientId.trim() : undefined;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 });
|
return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -85,6 +86,10 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
eventSlug: slug,
|
eventSlug: slug,
|
||||||
name,
|
name,
|
||||||
role: isHost ? "host" : "participant",
|
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).
|
||||||
|
clientId: isHost ? undefined : clientId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
+29
-14
@@ -1,24 +1,40 @@
|
|||||||
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
||||||
import { isAdminFieldLevel } from "@/access";
|
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||||
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
|
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
|
||||||
import { verificationEmail } from "@/lib/email/authTemplates";
|
import { verificationEmail } from "@/lib/email/authTemplates";
|
||||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||||
|
|
||||||
const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => {
|
// Routes through the shared isAdmin check (role === "admin", not just
|
||||||
const user = req.user;
|
// collection === "users") so this stays correct if a non-admin role is ever
|
||||||
|
// added to Users — see the comment on isAdmin in access/index.ts.
|
||||||
|
const isSelfOrAdmin: typeof isAdmin = (args) => {
|
||||||
|
const user = args.req.user;
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if (user.collection === "users") return true;
|
if (user.collection === "users") return isAdmin(args);
|
||||||
if (user.collection === "customers") return { id: { equals: user.id } };
|
if (user.collection === "customers") return { id: { equals: user.id } };
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Runs on every new customer account: issues a random, single-use,
|
// Runs on every new customer account, and again whenever an existing
|
||||||
// time-limited email-verification token. Only its hash is ever persisted
|
// customer changes their email address: issues a random, single-use,
|
||||||
// (see the emailVerificationTokenHash field below) — the plaintext exists
|
// time-limited email-verification token and resets emailVerified to false.
|
||||||
// only in req.context for the afterChange hook below to email out, and is
|
// Only the token's hash is ever persisted (see emailVerificationTokenHash
|
||||||
// never written to the database or logged.
|
// below) — the plaintext exists only in req.context for the afterChange
|
||||||
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, req }) => {
|
// hook below to email out, and is never written to the database or logged.
|
||||||
if (operation !== "create") return data;
|
//
|
||||||
|
// The email-change case matters because `update` access on this collection
|
||||||
|
// is isSelfOrAdmin and the auto-added `email` field carries no field-level
|
||||||
|
// access of its own — without this, a customer could verify a throwaway
|
||||||
|
// address once and then repoint `email` at an address they don't own while
|
||||||
|
// keeping emailVerified: true (which gates booking-confirmation emails).
|
||||||
|
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc, operation, req }) => {
|
||||||
|
const emailChanged =
|
||||||
|
operation === "update" &&
|
||||||
|
typeof data.email === "string" &&
|
||||||
|
typeof originalDoc?.email === "string" &&
|
||||||
|
data.email !== originalDoc.email;
|
||||||
|
if (operation !== "create" && !emailChanged) return data;
|
||||||
|
|
||||||
const token = generateVerificationToken();
|
const token = generateVerificationToken();
|
||||||
data.emailVerified = false;
|
data.emailVerified = false;
|
||||||
data.emailVerificationTokenHash = hashVerificationToken(token);
|
data.emailVerificationTokenHash = hashVerificationToken(token);
|
||||||
@@ -27,8 +43,7 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, r
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, operation, req }) => {
|
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => {
|
||||||
if (operation !== "create") return doc;
|
|
||||||
const token = req.context.pendingVerificationToken;
|
const token = req.context.pendingVerificationToken;
|
||||||
if (typeof token !== "string") return doc;
|
if (typeof token !== "string") return doc;
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +84,7 @@ export const Customers: CollectionConfig = {
|
|||||||
create: () => true,
|
create: () => true,
|
||||||
read: isSelfOrAdmin,
|
read: isSelfOrAdmin,
|
||||||
update: isSelfOrAdmin,
|
update: isSelfOrAdmin,
|
||||||
delete: ({ req }) => req.user?.collection === "users",
|
delete: isAdmin,
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeChange: [issueVerificationToken],
|
beforeChange: [issueVerificationToken],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
||||||
import { isAdmin, publishedOrAdmin } from "@/access";
|
import { isAdmin, isAdminFieldLevel, publishedOrAdmin } from "@/access";
|
||||||
import { slugField } from "@/fields/slug";
|
import { slugField } from "@/fields/slug";
|
||||||
import { seoFields } from "@/fields/seo";
|
import { seoFields } from "@/fields/seo";
|
||||||
import { generateMeetingPassword } from "@/lib/meeting/password";
|
import { generateMeetingPassword } from "@/lib/meeting/password";
|
||||||
@@ -162,6 +162,13 @@ export const Events: CollectionConfig = {
|
|||||||
name: "meetingPassword",
|
name: "meetingPassword",
|
||||||
type: "text",
|
type: "text",
|
||||||
label: "Meeting-Passwort",
|
label: "Meeting-Passwort",
|
||||||
|
// Events are publicly readable (publishedOrAdmin) via the REST/
|
||||||
|
// GraphQL API, so without this the plaintext password would be
|
||||||
|
// returned to any visitor fetching the event — defeating the
|
||||||
|
// password check in app/(payload)/api/meetings/[slug]/join. Only
|
||||||
|
// exposed to admins there; the join route reads it via the Local
|
||||||
|
// API, which bypasses field access by default.
|
||||||
|
access: { read: isAdminFieldLevel },
|
||||||
admin: {
|
admin: {
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
condition: (data) => Boolean(data?.isOnline),
|
condition: (data) => Boolean(data?.isOnline),
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export const Media: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
upload: {
|
upload: {
|
||||||
staticDir: path.resolve(dirname, "..", "media"),
|
staticDir: path.resolve(dirname, "..", "media"),
|
||||||
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/svg+xml"],
|
// SVG deliberately excluded: it can carry inline <script>/event-handler
|
||||||
|
// payloads that execute if the raw file is ever opened directly (not via
|
||||||
|
// <img>) — not currently needed by any part of the site.
|
||||||
|
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
|
||||||
imageSizes: [
|
imageSizes: [
|
||||||
{ name: "thumbnail", width: 400, position: "centre" },
|
{ name: "thumbnail", width: 400, position: "centre" },
|
||||||
{ name: "card", width: 800, position: "centre" },
|
{ name: "card", width: 800, position: "centre" },
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import type { CollectionConfig } from "payload";
|
|||||||
import { isAdmin } from "@/access";
|
import { isAdmin } from "@/access";
|
||||||
|
|
||||||
function normalizePath(value: string): string {
|
function normalizePath(value: string): string {
|
||||||
const trimmed = value.trim();
|
// Lowercased to match the slugs it points at/from (fields/slug.ts always
|
||||||
|
// lowercases via slugify) — without this, "/Angebote/Doula" and
|
||||||
|
// "/angebote/doula" count as different values to the `unique` constraint
|
||||||
|
// on fromPath, letting near-duplicate redirects coexist.
|
||||||
|
const trimmed = value.trim().toLowerCase();
|
||||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||||
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export function CallRoom({ slug }: { slug: string }) {
|
|||||||
const localStreamRef = useRef<MediaStream | null>(null);
|
const localStreamRef = useRef<MediaStream | null>(null);
|
||||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||||
const sessionRef = useRef<StoredMeetingSession | null>(null);
|
const sessionRef = useRef<StoredMeetingSession | null>(null);
|
||||||
|
const remotesRef = useRef<Map<string, RemoteEntry>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
remotesRef.current = remotes;
|
||||||
|
}, [remotes]);
|
||||||
|
|
||||||
const send = useCallback((message: ClientToServerMessage) => {
|
const send = useCallback((message: ClientToServerMessage) => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
@@ -149,9 +154,15 @@ export function CallRoom({ slug }: { slug: string }) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling?token=${encodeURIComponent(parsed.token)}`);
|
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling`);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
// Token travels as the first message, not a `?token=` query param, so
|
||||||
|
// it doesn't end up persisted in reverse-proxy/access logs.
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ type: "auth", token: parsed.token }));
|
||||||
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const message: ServerToClientMessage = JSON.parse(event.data);
|
const message: ServerToClientMessage = JSON.parse(event.data);
|
||||||
handleServerMessage(message);
|
handleServerMessage(message);
|
||||||
@@ -207,11 +218,18 @@ export function CallRoom({ slug }: { slug: string }) {
|
|||||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "auth-error": {
|
||||||
|
setPhase("error");
|
||||||
|
setErrorMessage("Die Meeting-Sitzung ist abgelaufen oder ungültig.");
|
||||||
|
cleanup();
|
||||||
|
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function remotesLookupName(participantId: string): string {
|
function remotesLookupName(participantId: string): string {
|
||||||
return remotes.get(participantId)?.name ?? "Teilnehmer:in";
|
return remotesRef.current.get(participantId)?.name ?? "Teilnehmer:in";
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -2,11 +2,29 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { TextField, FormError } from "@/components/FormField";
|
||||||
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";
|
|
||||||
|
|
||||||
export const MEETING_SESSION_KEY = "anouma-meeting-session";
|
export const MEETING_SESSION_KEY = "anouma-meeting-session";
|
||||||
|
const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
|
||||||
|
|
||||||
|
// A stable per-browser identity so a host's "kick" survives the removed
|
||||||
|
// participant simply reconnecting (which always gets a fresh participantId
|
||||||
|
// — see lib/meeting/token.ts). Deliberately in localStorage, not
|
||||||
|
// sessionStorage: it must outlive the "kicked" screen clearing the session
|
||||||
|
// and the "Erneut beitreten" link going back through this form again.
|
||||||
|
function getOrCreateClientId(): string {
|
||||||
|
try {
|
||||||
|
const existing = localStorage.getItem(MEETING_CLIENT_ID_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = crypto.randomUUID();
|
||||||
|
localStorage.setItem(MEETING_CLIENT_ID_KEY, created);
|
||||||
|
return created;
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable (private browsing, etc.) — fall back to a
|
||||||
|
// one-off id; kicks just won't stick across reconnects in that case.
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type StoredMeetingSession = {
|
export type StoredMeetingSession = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -33,7 +51,7 @@ export function JoinForm({ slug }: { slug: string }) {
|
|||||||
const res = await fetch(`/api/meetings/${slug}/join`, {
|
const res = await fetch(`/api/meetings/${slug}/join`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, password }),
|
body: JSON.stringify({ name, password, clientId: getOrCreateClientId() }),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -62,41 +80,27 @@ export function JoinForm({ slug }: { slug: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<TextField
|
||||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
label="Dein Name"
|
||||||
Dein Name
|
id="name"
|
||||||
</label>
|
type="text"
|
||||||
<input
|
required
|
||||||
id="name"
|
value={name}
|
||||||
type="text"
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
placeholder="Name eingeben"
|
||||||
value={name}
|
autoComplete="name"
|
||||||
onChange={(e) => setName(e.target.value)}
|
/>
|
||||||
placeholder="Name eingeben"
|
<TextField
|
||||||
className={fieldClass}
|
label="Meeting-Passwort"
|
||||||
autoComplete="name"
|
id="password"
|
||||||
/>
|
type="text"
|
||||||
</div>
|
required
|
||||||
<div>
|
value={password}
|
||||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
Meeting-Passwort
|
placeholder="Passwort eingeben"
|
||||||
</label>
|
autoComplete="off"
|
||||||
<input
|
/>
|
||||||
id="password"
|
{error && <FormError>{error}</FormError>}
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="Passwort eingeben"
|
|
||||||
className={fieldClass}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
+13
-1
@@ -32,7 +32,19 @@ export function checkRateLimit(key: string, opts: { max: number; windowMs: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getClientIp(request: Request): string {
|
export function getClientIp(request: Request): string {
|
||||||
|
// Deployment is always behind exactly one trusted reverse proxy (see
|
||||||
|
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
|
||||||
|
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
|
||||||
|
// *appends* the real client IP to any X-Forwarded-For it already
|
||||||
|
// received rather than replacing it, so a directly-connecting client
|
||||||
|
// could prepend arbitrary spoofed entries of their own. Only the
|
||||||
|
// right-most (last) entry — the one the trusted proxy itself appended —
|
||||||
|
// is trustworthy; taking the first entry would let a client bypass this
|
||||||
|
// rate limit entirely by sending a fake X-Forwarded-For header.
|
||||||
const forwarded = request.headers.get("x-forwarded-for");
|
const forwarded = request.headers.get("x-forwarded-for");
|
||||||
if (forwarded) return forwarded.split(",")[0]!.trim();
|
if (forwarded) {
|
||||||
|
const parts = forwarded.split(",");
|
||||||
|
return parts[parts.length - 1]!.trim();
|
||||||
|
}
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-9
@@ -3,6 +3,8 @@ import { WEEKDAYS } from "@/collections/Availability";
|
|||||||
|
|
||||||
export type TimeSlot = { start: Date; end: Date };
|
export type TimeSlot = { start: Date; end: Date };
|
||||||
|
|
||||||
|
const BERLIN_TZ = "Europe/Berlin";
|
||||||
|
|
||||||
const WEEKDAY_BY_INDEX = [
|
const WEEKDAY_BY_INDEX = [
|
||||||
"sunday",
|
"sunday",
|
||||||
"monday",
|
"monday",
|
||||||
@@ -13,18 +15,73 @@ const WEEKDAY_BY_INDEX = [
|
|||||||
"saturday",
|
"saturday",
|
||||||
] as const;
|
] 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 {
|
function dateKey(date: Date): string {
|
||||||
return date.toISOString().slice(0, 10);
|
const { year, month, day } = berlinParts(date);
|
||||||
|
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarDate(date: Date): CalendarDate {
|
||||||
|
const { year, month, day } = berlinParts(date);
|
||||||
|
return { year, month, day };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCalendarDays({ year, month, day }: CalendarDate, delta: number): CalendarDate {
|
||||||
|
// Pure calendar-rollover arithmetic (month/year boundaries) — no timezone
|
||||||
|
// conversion happens here, so using the UTC constructor is safe and simple.
|
||||||
|
const utc = new Date(Date.UTC(year, month - 1, day + delta));
|
||||||
|
return { year: utc.getUTCFullYear(), month: utc.getUTCMonth() + 1, day: utc.getUTCDate() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayOf({ year, month, day }: CalendarDate): (typeof WEEKDAY_BY_INDEX)[number] {
|
||||||
|
return WEEKDAY_BY_INDEX[new Date(Date.UTC(year, month - 1, day)).getUTCDay()];
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeOfDayMinutes(date: Date): number {
|
function timeOfDayMinutes(date: Date): number {
|
||||||
return date.getHours() * 60 + date.getMinutes();
|
const { hour, minute } = berlinParts(date);
|
||||||
|
return hour * 60 + minute;
|
||||||
}
|
}
|
||||||
|
|
||||||
function atMinutesOfDay(day: Date, minutes: number): Date {
|
function atMinutesOfDay(day: CalendarDate, minutes: number): Date {
|
||||||
const d = new Date(day);
|
const rounded = Math.round(minutes);
|
||||||
d.setHours(0, Math.round(minutes), 0, 0);
|
return berlinWallTimeToUTC(day, Math.floor(rounded / 60), rounded % 60);
|
||||||
return d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
|
function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
|
||||||
@@ -67,8 +124,12 @@ export async function getAvailableSlots(
|
|||||||
const overridesByDate = new Map(overrides.map((o) => [dateKey(new Date(o.date)), o]));
|
const overridesByDate = new Map(overrides.map((o) => [dateKey(new Date(o.date)), o]));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
for (let day = new Date(from); day <= to; day = new Date(day.getTime() + 24 * 60 * 60 * 1000)) {
|
for (
|
||||||
const key = dateKey(day);
|
let day = calendarDate(from);
|
||||||
|
atMinutesOfDay(day, 0) <= to;
|
||||||
|
day = addCalendarDays(day, 1)
|
||||||
|
) {
|
||||||
|
const key = `${day.year}-${String(day.month).padStart(2, "0")}-${String(day.day).padStart(2, "0")}`;
|
||||||
const override = overridesByDate.get(key);
|
const override = overridesByDate.get(key);
|
||||||
|
|
||||||
let windows: { startMinutes: number; endMinutes: number }[] = [];
|
let windows: { startMinutes: number; endMinutes: number }[] = [];
|
||||||
@@ -83,7 +144,7 @@ export async function getAvailableSlots(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
const weekdayName = WEEKDAY_BY_INDEX[day.getDay()];
|
const weekdayName = weekdayOf(day);
|
||||||
windows = weekly
|
windows = weekly
|
||||||
.filter((w) => w.weekday === weekdayName)
|
.filter((w) => w.weekday === weekdayName)
|
||||||
.map((w) => ({
|
.map((w) => ({
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ function escapeICSText(value: string): string {
|
|||||||
.replace(/\\/g, "\\\\")
|
.replace(/\\/g, "\\\\")
|
||||||
.replace(/;/g, "\\;")
|
.replace(/;/g, "\\;")
|
||||||
.replace(/,/g, "\\,")
|
.replace(/,/g, "\\,")
|
||||||
|
// \r must be stripped (not just \n escaped): many ICS parsers split
|
||||||
|
// lines on a bare CR too, so leaving it unescaped lets untrusted text
|
||||||
|
// (e.g. a customer's booking message ending up in DESCRIPTION) break
|
||||||
|
// out of the property value and inject extra iCalendar lines/properties.
|
||||||
|
.replace(/\r\n?/g, "\\n")
|
||||||
.replace(/\n/g, "\\n");
|
.replace(/\n/g, "\\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type ParticipantSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ClientToServerMessage =
|
export type ClientToServerMessage =
|
||||||
|
| { type: "auth"; token: string }
|
||||||
| { type: "offer"; to: string; payload: RTCSessionDescriptionInit }
|
| { type: "offer"; to: string; payload: RTCSessionDescriptionInit }
|
||||||
| { type: "answer"; to: string; payload: RTCSessionDescriptionInit }
|
| { type: "answer"; to: string; payload: RTCSessionDescriptionInit }
|
||||||
| { type: "ice-candidate"; to: string; payload: RTCIceCandidateInit }
|
| { type: "ice-candidate"; to: string; payload: RTCIceCandidateInit }
|
||||||
@@ -19,4 +20,5 @@ export type ServerToClientMessage =
|
|||||||
| { type: "offer"; from: string; payload: RTCSessionDescriptionInit }
|
| { type: "offer"; from: string; payload: RTCSessionDescriptionInit }
|
||||||
| { type: "answer"; from: string; payload: RTCSessionDescriptionInit }
|
| { type: "answer"; from: string; payload: RTCSessionDescriptionInit }
|
||||||
| { type: "ice-candidate"; from: string; payload: RTCIceCandidateInit }
|
| { type: "ice-candidate"; from: string; payload: RTCIceCandidateInit }
|
||||||
| { type: "kicked" };
|
| { type: "kicked" }
|
||||||
|
| { type: "auth-error"; reason: string };
|
||||||
|
|||||||
+19
-3
@@ -4,6 +4,7 @@ import type { MeetingRole } from "./token";
|
|||||||
export type Participant = {
|
export type Participant = {
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
participantId: string;
|
participantId: string;
|
||||||
|
clientId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: MeetingRole;
|
role: MeetingRole;
|
||||||
};
|
};
|
||||||
@@ -11,6 +12,11 @@ export type Participant = {
|
|||||||
type Room = {
|
type Room = {
|
||||||
participants: Map<string, Participant>;
|
participants: Map<string, Participant>;
|
||||||
kickedParticipantIds: Set<string>;
|
kickedParticipantIds: Set<string>;
|
||||||
|
// 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<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// In-memory only — this server process is the single source of truth for
|
// In-memory only — this server process is the single source of truth for
|
||||||
@@ -22,19 +28,27 @@ const rooms = new Map<string, Room>();
|
|||||||
function getOrCreateRoom(eventSlug: string): Room {
|
function getOrCreateRoom(eventSlug: string): Room {
|
||||||
let room = rooms.get(eventSlug);
|
let room = rooms.get(eventSlug);
|
||||||
if (!room) {
|
if (!room) {
|
||||||
room = { participants: new Map(), kickedParticipantIds: new Set() };
|
room = { participants: new Map(), kickedParticipantIds: new Set(), kickedClientIds: new Set() };
|
||||||
rooms.set(eventSlug, room);
|
rooms.set(eventSlug, room);
|
||||||
}
|
}
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isKicked(eventSlug: string, participantId: string): boolean {
|
export function isKicked(eventSlug: string, participantId: string, clientId?: string): boolean {
|
||||||
return rooms.get(eventSlug)?.kickedParticipantIds.has(participantId) ?? false;
|
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[] {
|
export function joinRoom(eventSlug: string, participant: Participant): Participant[] {
|
||||||
const room = getOrCreateRoom(eventSlug);
|
const room = getOrCreateRoom(eventSlug);
|
||||||
const existing = [...room.participants.values()];
|
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);
|
room.participants.set(participant.participantId, participant);
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
@@ -50,6 +64,8 @@ export function kickFromRoom(eventSlug: string, participantId: string) {
|
|||||||
const room = rooms.get(eventSlug);
|
const room = rooms.get(eventSlug);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
room.kickedParticipantIds.add(participantId);
|
room.kickedParticipantIds.add(participantId);
|
||||||
|
const target = room.participants.get(participantId);
|
||||||
|
if (target?.clientId) room.kickedClientIds.add(target.clientId);
|
||||||
room.participants.delete(participantId);
|
room.participants.delete(participantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
|
||||||
import type { WebSocket, WebSocketServer } from "ws";
|
import type { WebSocket, WebSocketServer } from "ws";
|
||||||
import { verifyMeetingToken } from "./token";
|
import { verifyMeetingToken } from "./token";
|
||||||
import {
|
import {
|
||||||
@@ -12,46 +11,61 @@ import {
|
|||||||
} from "./rooms";
|
} from "./rooms";
|
||||||
import type { ClientToServerMessage, ParticipantSummary } from "./protocol";
|
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 {
|
function participantSummary(p: Participant): ParticipantSummary {
|
||||||
return { participantId: p.participantId, name: p.name, role: p.role };
|
return { participantId: p.participantId, name: p.name, role: p.role };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachSignalingServer(wss: WebSocketServer) {
|
export function attachSignalingServer(wss: WebSocketServer) {
|
||||||
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
wss.on("connection", (ws: WebSocket) => {
|
||||||
const url = new URL(req.url ?? "", "http://internal");
|
let self: Participant | null = null;
|
||||||
const token = url.searchParams.get("token") ?? "";
|
let eventSlug: string | null = null;
|
||||||
const payload = verifyMeetingToken(token);
|
|
||||||
|
|
||||||
if (!payload) {
|
const authTimeout = setTimeout(() => {
|
||||||
ws.close(4000, "invalid-token");
|
if (!self) ws.close(4000, "auth-timeout");
|
||||||
return;
|
}, 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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (isKicked(payload.eventSlug, payload.participantId)) {
|
|
||||||
ws.close(4001, "removed-by-host");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const self: Participant = {
|
|
||||||
ws,
|
|
||||||
participantId: payload.participantId,
|
|
||||||
name: payload.name,
|
|
||||||
role: payload.role,
|
|
||||||
};
|
|
||||||
|
|
||||||
const existing = joinRoom(payload.eventSlug, self);
|
|
||||||
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "welcome",
|
|
||||||
participantId: self.participantId,
|
|
||||||
role: self.role,
|
|
||||||
participants: existing.map(participantSummary),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
broadcast(payload.eventSlug, { type: "peer-joined", ...participantSummary(self) }, {
|
|
||||||
exclude: self.participantId,
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("message", (raw) => {
|
ws.on("message", (raw) => {
|
||||||
let message: ClientToServerMessage;
|
let message: ClientToServerMessage;
|
||||||
@@ -61,8 +75,17 @@ export function attachSignalingServer(wss: WebSocketServer) {
|
|||||||
return;
|
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") {
|
if (message.type === "offer" || message.type === "answer" || message.type === "ice-candidate") {
|
||||||
const target = getParticipant(payload.eventSlug, message.to);
|
const target = getParticipant(eventSlug, message.to);
|
||||||
if (target && target.ws.readyState === target.ws.OPEN) {
|
if (target && target.ws.readyState === target.ws.OPEN) {
|
||||||
target.ws.send(
|
target.ws.send(
|
||||||
JSON.stringify({ type: message.type, from: self.participantId, payload: message.payload }),
|
JSON.stringify({ type: message.type, from: self.participantId, payload: message.payload }),
|
||||||
@@ -73,18 +96,20 @@ export function attachSignalingServer(wss: WebSocketServer) {
|
|||||||
|
|
||||||
if (message.type === "kick") {
|
if (message.type === "kick") {
|
||||||
if (self.role !== "host") return; // server-side authority, not client-claimed
|
if (self.role !== "host") return; // server-side authority, not client-claimed
|
||||||
const target = getParticipant(payload.eventSlug, message.targetParticipantId);
|
const target = getParticipant(eventSlug, message.targetParticipantId);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
kickFromRoom(payload.eventSlug, target.participantId);
|
kickFromRoom(eventSlug, target.participantId);
|
||||||
target.ws.send(JSON.stringify({ type: "kicked" }));
|
target.ws.send(JSON.stringify({ type: "kicked" }));
|
||||||
target.ws.close(4001, "removed-by-host");
|
target.ws.close(4001, "removed-by-host");
|
||||||
broadcast(payload.eventSlug, { type: "peer-left", participantId: target.participantId });
|
broadcast(eventSlug, { type: "peer-left", participantId: target.participantId });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
leaveRoom(payload.eventSlug, self.participantId);
|
clearTimeout(authTimeout);
|
||||||
broadcast(payload.eventSlug, { type: "peer-left", participantId: self.participantId });
|
if (!self || !eventSlug) return;
|
||||||
|
leaveRoom(eventSlug, self.participantId);
|
||||||
|
broadcast(eventSlug, { type: "peer-left", participantId: self.participantId });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ export type MeetingRole = "host" | "participant";
|
|||||||
export type MeetingTokenPayload = {
|
export type MeetingTokenPayload = {
|
||||||
eventSlug: string;
|
eventSlug: string;
|
||||||
participantId: string;
|
participantId: string;
|
||||||
|
// Self-declared, persisted client-side (localStorage, not sessionStorage)
|
||||||
|
// identity for participants — participantId is fresh per /join call, so
|
||||||
|
// without this a kick could always be undone by simply rejoining. Only
|
||||||
|
// used to make a kick stick across a rejoin (see rooms.ts kickedClientIds);
|
||||||
|
// never used to key live room membership, so a client choosing an
|
||||||
|
// arbitrary/colliding value can only affect their own future kick status,
|
||||||
|
// never another participant's connection. Still bypassable by clearing
|
||||||
|
// localStorage or joining from a different browser — there's no real
|
||||||
|
// identity to anchor to for an unauthenticated participant.
|
||||||
|
clientId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: MeetingRole;
|
role: MeetingRole;
|
||||||
exp: number; // unix ms
|
exp: number; // unix ms
|
||||||
@@ -29,12 +39,18 @@ export function createMeetingToken(args: {
|
|||||||
eventSlug: string;
|
eventSlug: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: MeetingRole;
|
role: MeetingRole;
|
||||||
|
clientId?: string;
|
||||||
ttlMs?: number;
|
ttlMs?: number;
|
||||||
}): { token: string; participantId: string } {
|
}): { token: string; participantId: string } {
|
||||||
const participantId = randomUUID();
|
const participantId = randomUUID();
|
||||||
|
const clientId =
|
||||||
|
typeof args.clientId === "string" && args.clientId.length > 0 && args.clientId.length <= 100
|
||||||
|
? args.clientId
|
||||||
|
: undefined;
|
||||||
const payload: MeetingTokenPayload = {
|
const payload: MeetingTokenPayload = {
|
||||||
eventSlug: args.eventSlug,
|
eventSlug: args.eventSlug,
|
||||||
participantId,
|
participantId,
|
||||||
|
clientId,
|
||||||
name: args.name,
|
name: args.name,
|
||||||
role: args.role,
|
role: args.role,
|
||||||
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
|
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
|
||||||
|
|||||||
@@ -6,6 +6,47 @@ import { fileURLToPath } from "url";
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const dirname = path.dirname(__filename);
|
const dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Applied to every route, including /admin and /api — none of these
|
||||||
|
// constrain what the page can load/render, so they can't break the Payload
|
||||||
|
// admin bundle. Camera/microphone/display-capture are allowed for the
|
||||||
|
// site's own origin because the WebRTC call pages (app/(call)/**) need them.
|
||||||
|
const baseSecurityHeaders = [
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{ key: "Permissions-Policy", value: "camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=()" },
|
||||||
|
// Browsers only ever honor this over an actual HTTPS connection, so it's
|
||||||
|
// harmless to send in local http:// dev too.
|
||||||
|
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// '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
|
||||||
|
// server-error-stack reconstruction; neither React nor Next use it in a
|
||||||
|
// production build.
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
const frontendCSP = [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self'${isDev ? " 'unsafe-eval'" : ""}`,
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self' https: wss: stun: turn: turns:",
|
||||||
|
"media-src 'self' blob:",
|
||||||
|
"frame-ancestors 'self'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"upgrade-insecure-requests",
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// NOT "standalone": this project uses a custom server (server.ts) for the
|
// NOT "standalone": this project uses a custom server (server.ts) for the
|
||||||
// WebRTC signaling WebSocket, and standalone output can't be combined with
|
// WebRTC signaling WebSocket, and standalone output can't be combined with
|
||||||
@@ -25,6 +66,12 @@ const nextConfig: NextConfig = {
|
|||||||
turbopack: {
|
turbopack: {
|
||||||
root: path.resolve(dirname),
|
root: path.resolve(dirname),
|
||||||
},
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{ source: "/:path*", headers: baseSecurityHeaders },
|
||||||
|
{ source: "/((?!admin|api).*)", headers: [{ key: "Content-Security-Policy", value: frontendCSP }] },
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withPayload(nextConfig, { devBundleServerPackages: false });
|
export default withPayload(nextConfig, { devBundleServerPackages: false });
|
||||||
|
|||||||
Reference in New Issue
Block a user