fix: harden booking and meeting flows
This commit is contained in:
@@ -13,16 +13,17 @@ const MAX_TRACKED_KEYS = 5000;
|
||||
|
||||
function sweepExpired(now: number) {
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.resetAt < now) buckets.delete(key);
|
||||
if (bucket.resetAt <= now) buckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function checkRateLimit(key: string, opts: { max: number; windowMs: number }): boolean {
|
||||
const now = Date.now();
|
||||
if (buckets.size > MAX_TRACKED_KEYS) sweepExpired(now);
|
||||
if (buckets.size >= MAX_TRACKED_KEYS) sweepExpired(now);
|
||||
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket || bucket.resetAt < now) {
|
||||
if (!bucket || bucket.resetAt <= now) {
|
||||
if (!bucket && buckets.size >= MAX_TRACKED_KEYS) return false;
|
||||
buckets.set(key, { count: 1, resetAt: now + opts.windowMs });
|
||||
return true;
|
||||
}
|
||||
@@ -31,7 +32,7 @@ export function checkRateLimit(key: string, opts: { max: number; windowMs: numbe
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getClientIp(request: Request): string {
|
||||
export function getClientIp(request: { headers: Headers }): string {
|
||||
// Deployment is always behind exactly one trusted reverse proxy (see
|
||||
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
|
||||
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Only origin-relative application paths may be used after login. */
|
||||
export function safeLoginRedirect(value: string | null): string {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u0020]/.test(value)) return "/konto";
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { checkRateLimit, getClientIp } from "./rateLimit";
|
||||
|
||||
/** Shared budgets for registration, email changes and resend (REST and GraphQL). */
|
||||
export function allowVerificationEmail(request: { headers: Headers }, email: string, customerId?: string | number): boolean {
|
||||
const windowMs = 60 * 60 * 1000;
|
||||
return checkRateLimit(`verification:ip:${getClientIp(request)}`, { max: 10, windowMs }) &&
|
||||
(customerId === undefined || checkRateLimit(`verification:customer:${customerId}`, { max: 3, windowMs })) &&
|
||||
checkRateLimit(`verification:email:${email.trim().toLowerCase()}`, { max: 3, windowMs });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BookingRequest } from "@/payload-types";
|
||||
|
||||
/** Explicit allow-list at the server/client boundary, including relationships. */
|
||||
export function customerBookingView(booking: BookingRequest) {
|
||||
const offer = typeof booking.offer === "object" ? booking.offer : null;
|
||||
const event = typeof booking.linkedEvent === "object" ? booking.linkedEvent : null;
|
||||
const alternative = booking.proposedAlternative;
|
||||
return {
|
||||
id: booking.id,
|
||||
date: booking.date,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
status: booking.status,
|
||||
appointmentType: booking.appointmentType,
|
||||
offer: offer ? { title: offer.title } : null,
|
||||
linkedEvent: event ? { slug: event.slug, isOnline: event.isOnline } : null,
|
||||
proposedAlternative: alternative ? {
|
||||
date: alternative.date,
|
||||
startTime: alternative.startTime,
|
||||
endTime: alternative.endTime,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
export type CustomerBooking = ReturnType<typeof customerBookingView>;
|
||||
+3
-2
@@ -13,13 +13,14 @@ import type { PayloadRequest } from "payload";
|
||||
*/
|
||||
export async function acquireBookingDayLock(req: PayloadRequest, dateKey: string): Promise<void> {
|
||||
const txID = req.transactionID ? String(await req.transactionID) : undefined;
|
||||
if (!txID) return; // No active transaction — nothing to lock against.
|
||||
if (!txID) throw new Error("A booking lock requires an active transaction");
|
||||
|
||||
const adapter = req.payload.db as unknown as {
|
||||
sessions?: Record<string, { db: { execute: (query: unknown) => Promise<unknown> } }>;
|
||||
drizzle: { execute: (query: unknown) => Promise<unknown> };
|
||||
};
|
||||
const tx = adapter.sessions?.[txID]?.db ?? adapter.drizzle;
|
||||
const tx = adapter.sessions?.[txID]?.db;
|
||||
if (!tx) throw new Error("Booking transaction not found");
|
||||
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${dateKey}))`);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { BookingRequest, Event, MeetingSetting } from "@/payload-types";
|
||||
import type { MeetingSetting } from "@/payload-types";
|
||||
import type { CustomerBooking } from "./customerView";
|
||||
import { canJoinMeeting, combineDateAndTime, getMeetingStatus, type MeetingStatus } from "@/lib/meeting/status";
|
||||
|
||||
export function getBookingMeetingStatus(booking: BookingRequest, settings: MeetingSetting): MeetingStatus | null {
|
||||
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
|
||||
export function getBookingMeetingStatus(booking: CustomerBooking, settings: MeetingSetting): MeetingStatus | null {
|
||||
const event = booking.linkedEvent;
|
||||
if (booking.status !== "confirmed" || booking.appointmentType !== "online" || !event?.isOnline) return null;
|
||||
|
||||
const start = combineDateAndTime(booking.date, booking.startTime);
|
||||
@@ -17,7 +18,7 @@ export function getBookingMeetingStatus(booking: BookingRequest, settings: Meeti
|
||||
});
|
||||
}
|
||||
|
||||
export function canJoinBookingMeeting(booking: BookingRequest, settings: MeetingSetting): boolean {
|
||||
export function canJoinBookingMeeting(booking: CustomerBooking, settings: MeetingSetting): boolean {
|
||||
const status = getBookingMeetingStatus(booking, settings);
|
||||
return status !== null && canJoinMeeting(status);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { cache } from "react";
|
||||
import { getCMS } from "@/lib/payload/getPayload";
|
||||
import type { BookingRequest } from "@/payload-types";
|
||||
import { customerBookingView, type CustomerBooking } from "./customerView";
|
||||
|
||||
/**
|
||||
* These are called only after the caller has already verified who the
|
||||
* current customer is (see lib/auth/customer.ts) — the `where: { user }`
|
||||
* filter below is what actually scopes results to that one customer.
|
||||
*/
|
||||
export const getCustomerBookings = cache(async (customerId: number): Promise<BookingRequest[]> => {
|
||||
export const getCustomerBookings = cache(async (customerId: number): Promise<CustomerBooking[]> => {
|
||||
const payload = await getCMS();
|
||||
const result = await payload.find({
|
||||
collection: "booking-requests",
|
||||
@@ -16,10 +16,10 @@ export const getCustomerBookings = cache(async (customerId: number): Promise<Boo
|
||||
depth: 2,
|
||||
limit: 200,
|
||||
});
|
||||
return result.docs;
|
||||
return result.docs.map(customerBookingView);
|
||||
});
|
||||
|
||||
export async function getCustomerBookingById(customerId: number, id: number): Promise<BookingRequest | null> {
|
||||
export async function getCustomerBookingById(customerId: number, id: number): Promise<CustomerBooking | null> {
|
||||
const payload = await getCMS();
|
||||
const result = await payload.find({
|
||||
collection: "booking-requests",
|
||||
@@ -27,5 +27,5 @@ export async function getCustomerBookingById(customerId: number, id: number): Pr
|
||||
depth: 2,
|
||||
limit: 1,
|
||||
});
|
||||
return result.docs[0] ?? null;
|
||||
return result.docs[0] ? customerBookingView(result.docs[0]) : null;
|
||||
}
|
||||
|
||||
+11
-8
@@ -1,4 +1,4 @@
|
||||
import type { Payload } from "payload";
|
||||
import type { Payload, PayloadRequest } from "payload";
|
||||
import { WEEKDAYS } from "@/collections/Availability";
|
||||
import { berlinParts, berlinWallTimeToUTC, type CalendarDate } from "@/lib/calendar/timezone";
|
||||
|
||||
@@ -63,17 +63,20 @@ function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
|
||||
*/
|
||||
export async function getAvailableSlots(
|
||||
payload: Payload,
|
||||
args: { durationMinutes: number; from: Date; to: Date },
|
||||
args: { durationMinutes: number; from: Date; to: Date; req?: PayloadRequest },
|
||||
): Promise<Map<string, TimeSlot[]>> {
|
||||
const { durationMinutes, from, to } = args;
|
||||
const { durationMinutes, from, to, req } = args;
|
||||
const result = new Map<string, TimeSlot[]>();
|
||||
if (!Number.isInteger(durationMinutes) || durationMinutes <= 0 || durationMinutes > 1440 ||
|
||||
!Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || to < from ||
|
||||
to.getTime() - from.getTime() > 63 * 86400000) return result;
|
||||
|
||||
const [{ docs: weekly }, { docs: overrides }, { docs: busyBookings }] = await Promise.all([
|
||||
payload.find({ collection: "availability", where: { active: { equals: true } }, limit: 100 }),
|
||||
payload.find({ collection: "availability", where: { active: { equals: true } }, pagination: false, req }),
|
||||
payload.find({
|
||||
collection: "availability-overrides",
|
||||
where: { and: [{ date: { greater_than_equal: from.toISOString() } }, { date: { less_than_equal: to.toISOString() } }] },
|
||||
limit: 200,
|
||||
pagination: false, req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "booking-requests",
|
||||
@@ -84,7 +87,7 @@ export async function getAvailableSlots(
|
||||
{ date: { less_than_equal: to.toISOString() } },
|
||||
],
|
||||
},
|
||||
limit: 500,
|
||||
pagination: false, req,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -131,11 +134,11 @@ export async function getAvailableSlots(
|
||||
const slotEnd = atMinutesOfDay(day, start + durationMinutes);
|
||||
if (slotStart < now) continue;
|
||||
if (dayBusy.some((b) => overlaps(slotStart, slotEnd, b.start, b.end))) continue;
|
||||
slots.push({ start: slotStart, end: slotEnd });
|
||||
if (!slots.some((slot) => slot.start.getTime() === slotStart.getTime())) slots.push({ start: slotStart, end: slotEnd });
|
||||
}
|
||||
}
|
||||
|
||||
if (slots.length > 0) result.set(key, slots);
|
||||
if (slots.length > 0) result.set(key, slots.sort((a, b) => a.start.getTime() - b.start.getTime()));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { commitTransaction, createLocalReq, initTransaction, killTransaction, type Payload, type PayloadRequest } from "payload";
|
||||
import { acquireBookingDayLock } from "./lock";
|
||||
|
||||
export async function withBookingTransaction<T>(payload: Payload, key: string, action: (req: PayloadRequest) => Promise<T>): Promise<T> {
|
||||
const req = await createLocalReq({}, payload);
|
||||
try {
|
||||
if (!await initTransaction(req)) throw new Error("Booking transaction unavailable");
|
||||
await acquireBookingDayLock(req, "booking-calendar");
|
||||
await acquireBookingDayLock(req, key);
|
||||
const result = await action(req);
|
||||
await commitTransaction(req);
|
||||
return result;
|
||||
} catch (error) {
|
||||
await killTransaction(req);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,15 @@ const BERLIN_TZ = "Europe/Berlin";
|
||||
|
||||
export type CalendarDate = { year: number; month: number; day: number };
|
||||
|
||||
/** Strict calendar input, interpreted in Berlin regardless of server TZ. */
|
||||
export function parseBerlinDate(value: string): Date | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
if (year < 1000 || month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||||
const date = berlinWallTimeToUTC({ year, month, day }, 0, 0);
|
||||
return berlinDateKey(date) === value ? date : null;
|
||||
}
|
||||
|
||||
export function berlinDateKey(date: Date): string {
|
||||
const { year, month, day } = berlinParts(date);
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
|
||||
@@ -15,6 +15,9 @@ export function getMailTransport(): nodemailer.Transporter {
|
||||
host: SMTP_HOST,
|
||||
port: Number(SMTP_PORT),
|
||||
secure: Number(SMTP_PORT) === 465,
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 20000,
|
||||
auth: { user: SMTP_USER, pass: SMTP_PASSWORD },
|
||||
});
|
||||
return cached;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import type { Payload } from "payload";
|
||||
import type { Event, MeetingSetting } from "@/payload-types";
|
||||
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "./status";
|
||||
import type { MeetingRole } from "./token";
|
||||
|
||||
export function meetingAccessVersion(event: Event): string {
|
||||
const secret = process.env.MEETING_SESSION_SECRET;
|
||||
if (!secret) throw new Error("MEETING_SESSION_SECRET is not set");
|
||||
return createHmac("sha256", secret).update(JSON.stringify([
|
||||
event.id, event.slug, event.meetingPassword, event.date, event.startTime,
|
||||
event.endTime, event.isOnline, event._status, event.isPrivateBooking,
|
||||
])).digest("hex");
|
||||
}
|
||||
|
||||
export function meetingWindow(event: Event, settings: MeetingSetting, role: MeetingRole, now = new Date()) {
|
||||
const start = combineDateAndTime(event.date, event.startTime);
|
||||
const end = event.endTime ? combineDateAndTime(event.date, event.endTime) : new Date(start.getTime() + 3600000);
|
||||
const closeAt = end.getTime() + settings.meetingCloseAfterMinutes * 60000;
|
||||
const status = getMeetingStatus({ start, end,
|
||||
joinWindowMinutes: role === "host" ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
|
||||
closeAfterMinutes: settings.meetingCloseAfterMinutes, now });
|
||||
return { allowed: Number.isFinite(closeAt) && end > start && canJoinMeeting(status), closeAt, status };
|
||||
}
|
||||
|
||||
/** Shared by every issuer: existing credentials are not a substitute for current access. */
|
||||
export async function authorizeMeeting(payload: Payload, event: Event, role: MeetingRole) {
|
||||
if (!event.isOnline || (!event.isPrivateBooking && event._status !== "published" && role !== "host")) return null;
|
||||
if (event.isPrivateBooking) {
|
||||
const { docs } = await payload.find({ collection: "booking-requests", depth: 0, limit: 1,
|
||||
where: { and: [
|
||||
{ linkedEvent: { equals: event.id } }, { status: { equals: "confirmed" } },
|
||||
{ appointmentType: { equals: "online" } },
|
||||
] },
|
||||
});
|
||||
if (!docs[0]) return null;
|
||||
}
|
||||
const settings = await payload.findGlobal({ slug: "meeting-settings" });
|
||||
const window = meetingWindow(event, settings, role);
|
||||
if (!window.allowed) return null;
|
||||
return { accessVersion: meetingAccessVersion(event), expiresAt: window.closeAt };
|
||||
}
|
||||
+10
-4
@@ -17,9 +17,9 @@ function getLiveKitApiUrl(): string {
|
||||
}
|
||||
|
||||
/** Stable opaque room name; the public event slug is never sent to LiveKit logs. */
|
||||
export function getLiveKitRoomName(eventSlug: string): string {
|
||||
export function getLiveKitRoomName(eventSlug: string, accessVersion?: string): string {
|
||||
const secret = requiredEnv("LIVEKIT_API_SECRET");
|
||||
return `anouma-${createHmac("sha256", secret).update(eventSlug).digest("hex").slice(0, 32)}`;
|
||||
return `anouma-${createHmac("sha256", secret).update(JSON.stringify([eventSlug, accessVersion])).digest("hex").slice(0, 32)}`;
|
||||
}
|
||||
|
||||
export async function createLiveKitToken(args: {
|
||||
@@ -28,12 +28,18 @@ export async function createLiveKitToken(args: {
|
||||
name: string;
|
||||
role: MeetingRole;
|
||||
clientId?: string;
|
||||
accessVersion?: string;
|
||||
expiresAt: number;
|
||||
}): Promise<string> {
|
||||
const room = getLiveKitRoomName(args.eventSlug);
|
||||
const room = getLiveKitRoomName(args.eventSlug, args.accessVersion);
|
||||
const remainingSeconds = Math.floor((args.expiresAt - Date.now()) / 1000);
|
||||
if (remainingSeconds <= 0) throw new Error("Meeting access has expired");
|
||||
const token = new AccessToken(requiredEnv("LIVEKIT_API_KEY"), requiredEnv("LIVEKIT_API_SECRET"), {
|
||||
identity: args.participantId,
|
||||
name: args.name,
|
||||
ttl: "2h",
|
||||
// Self-hosted LiveKit cannot revoke already issued JWTs. Keep the
|
||||
// connection credential short-lived; the app re-authorizes reconnects.
|
||||
ttl: Math.min(60, remainingSeconds),
|
||||
metadata: JSON.stringify({ role: args.role, clientId: args.clientId }),
|
||||
});
|
||||
token.addGrant({
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { CollectionAfterChangeHook } from "payload";
|
||||
import type { Event } from "@/payload-types";
|
||||
import { meetingAccessVersion } from "./access";
|
||||
import { getLiveKitRoomName, getLiveKitRoomService } from "./livekit";
|
||||
|
||||
/** Disconnect the previous room when its password, schedule or access changes. */
|
||||
export const revokeChangedMeeting: CollectionAfterChangeHook<Event> = async ({ doc, previousDoc, req }) => {
|
||||
if (!previousDoc?.isOnline || !previousDoc.slug || !process.env.LIVEKIT_URL || !process.env.MEETING_SESSION_SECRET) return doc;
|
||||
const previousVersion = meetingAccessVersion(previousDoc);
|
||||
if (previousVersion === meetingAccessVersion(doc)) return doc;
|
||||
try {
|
||||
const service = getLiveKitRoomService();
|
||||
const name = getLiveKitRoomName(previousDoc.slug, previousVersion);
|
||||
if ((await service.listRooms([name])).length) await service.deleteRoom(name);
|
||||
} catch (err) {
|
||||
req.payload.logger.error({ err, msg: "Could not disconnect revoked meeting room" });
|
||||
}
|
||||
return doc;
|
||||
};
|
||||
@@ -36,6 +36,8 @@ export function getMeetingStatus({
|
||||
const closeAt = end.getTime() + closeAfterMinutes * 60_000;
|
||||
const t = now.getTime();
|
||||
|
||||
if (![openAt, closeAt, t].every(Number.isFinite) || end <= start) return "ended";
|
||||
|
||||
if (t >= closeAt) return "ended";
|
||||
if (t >= start.getTime()) return "live";
|
||||
if (t >= openAt) return "open";
|
||||
|
||||
@@ -17,6 +17,8 @@ export type MeetingTokenPayload = {
|
||||
clientId?: string;
|
||||
name: string;
|
||||
role: MeetingRole;
|
||||
accessVersion?: string;
|
||||
testRoom?: boolean;
|
||||
exp: number; // unix ms
|
||||
};
|
||||
|
||||
@@ -41,6 +43,8 @@ export function createMeetingToken(args: {
|
||||
role: MeetingRole;
|
||||
clientId?: string;
|
||||
ttlMs?: number;
|
||||
accessVersion?: string;
|
||||
testRoom?: boolean;
|
||||
}): { token: string; participantId: string } {
|
||||
const participantId = randomUUID();
|
||||
const clientId =
|
||||
@@ -53,6 +57,8 @@ export function createMeetingToken(args: {
|
||||
clientId,
|
||||
name: args.name,
|
||||
role: args.role,
|
||||
accessVersion: args.accessVersion,
|
||||
testRoom: args.testRoom,
|
||||
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
|
||||
};
|
||||
const body = base64url(JSON.stringify(payload));
|
||||
@@ -76,7 +82,9 @@ export function verifyMeetingToken(token: unknown): MeetingTokenPayload | null {
|
||||
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload;
|
||||
if (
|
||||
!Number.isFinite(payload.exp) ||
|
||||
payload.exp < Date.now() ||
|
||||
payload.exp <= Date.now() ||
|
||||
(payload.accessVersion !== undefined && !/^[0-9a-f]{64}$/.test(payload.accessVersion)) ||
|
||||
(payload.testRoom !== undefined && typeof payload.testRoom !== "boolean") ||
|
||||
typeof payload.eventSlug !== "string" ||
|
||||
payload.eventSlug.length > 200 ||
|
||||
typeof payload.participantId !== "string" ||
|
||||
|
||||
Reference in New Issue
Block a user