351 lines
10 KiB
TypeScript

import { Hono } from "jsr:@hono/hono@4";
import { getAuthenticatedUser, getCookieDomain } from "../../core/session.ts";
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import { encodeHex } from "jsr:@std/encoding@1/hex";
import { getClientIp, rateLimitWrapper } from "../../core/middleware.ts";
import { valkey } from "../../core/valkey.ts";
import { auditWrapper } from "../../core/audit.ts";
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
import * as Queries from "./queries.ts";
import {
EventJoinPageFragment,
EventSplashPageFragment,
} from "./fragments.tsx";
import { eventsActionsRoutes } from "./events_actions_routes.ts";
import { eventStreamRoutes } from "./stream_routes.ts";
export const eventsRoutes = new Hono();
// Mount Actions and Stream sub-routers
eventsRoutes.route("/", eventsActionsRoutes);
eventsRoutes.route("/", eventStreamRoutes);
// ---------------------------------------------------------
// Join Event via Code or PIN
// ---------------------------------------------------------
eventsRoutes.post("/api/join", async (c) => {
let code = "";
const contentType = c.req.header("content-type") || "";
const accept = c.req.header("accept") || "";
const wantsJson = accept.includes("application/json") &&
!accept.includes("text/html");
if (contentType.includes("application/x-www-form-urlencoded")) {
const fd = await c.req.formData();
code = (fd.get("code") as string) || "";
} else {
const body = await c.req.json().catch(() => ({}));
code = body.code || "";
}
if (!code || typeof code !== "string") {
if (wantsJson) {
return c.json({ error: "Event code or PIN is required" }, 400);
}
return c.html(
renderErrorToastFragment("Event code or PIN is required"),
400,
);
}
const clientIp = getClientIp(c);
const rateLimitKey = `ratelimit:join:fail:${clientIp}`;
if (await rateLimitWrapper.isRateLimited(rateLimitKey, 5, 60000)) {
if (wantsJson) {
return c.json({ error: "Too Many Requests" }, 429);
}
return c.html(renderErrorToastFragment("Too Many Requests"), 429);
}
try {
const event = await Queries.getEventBySlugOrPin(code);
if (!event) {
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
if (wantsJson) {
return c.json(
{ error: "Invalid event code or workshop capacity reached" },
404,
);
}
return c.html(
renderErrorToastFragment(
"Invalid event code or workshop capacity reached",
),
404,
);
}
// NAT-Safe Idempotent Re-entry: check if device already holds active session for this event
const existingUser = await getAuthenticatedUser(c);
if (existingUser && existingUser.username?.startsWith(`guest_`)) {
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
await valkey.expire(existingUser.sessionId, ttl);
deleteCookie(c, "session_id", { path: "/" });
const rpID = Deno.env.get("RP_ID");
const cookieDomain = getCookieDomain(rpID);
setCookie(c, "session_id", existingUser.sessionId, {
domain: cookieDomain,
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: ttl,
});
let appDomain = "";
if (event.app_id) {
const app = await Queries.getAppById(String(event.app_id));
if (app) {
appDomain = app.domain || "";
}
}
const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard";
if (wantsJson) {
return c.json({
success: true,
sessionId: existingUser.sessionId,
username: existingUser.username,
redirectUrl,
reused: true,
});
}
c.header("HX-Redirect", redirectUrl);
return c.html("");
}
const updatedEvent = await Queries.incrementEventSeats(String(event.id));
if (!updatedEvent) {
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
if (wantsJson) {
return c.json({ error: "Workshop capacity reached" }, 404);
}
return c.html(renderErrorToastFragment("Workshop capacity reached"), 404);
}
const guestUuid = crypto.randomUUID();
const eventShortId = String(updatedEvent.id).split("-")[0];
const username = `guest_${eventShortId}_${updatedEvent.seats_claimed}`;
await Queries.createGuestUser(
guestUuid,
username,
updatedEvent.name + " Attendee",
String(updatedEvent.id),
);
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
const label = `${updatedEvent.name} Seat #${updatedEvent.seats_claimed}`;
const ttl = (Number(updatedEvent.lifespan_hours) || 3) * 3600;
let customScopes = ["guest", "trial"];
let appDomain = "";
if (updatedEvent.app_id) {
const app = await Queries.getAppById(String(updatedEvent.app_id));
if (app) {
customScopes = [`app:${app.name}`, updatedEvent.role || "viewer"];
appDomain = app.domain || "";
}
}
const expiresAt = new Date(Date.now() + ttl * 1000);
await Queries.createEventSession(
sessionId,
guestUuid,
label,
customScopes,
expiresAt,
);
await valkey.setex(
sessionId,
ttl,
JSON.stringify({
uuid: guestUuid,
username,
account_status: "guest",
customScopes,
}),
);
auditWrapper.auditLog(guestUuid, "event_seat_claimed", updatedEvent.id, {
slug: updatedEvent.slug,
name: updatedEvent.name,
seatNumber: updatedEvent.seats_claimed,
method: "web",
}, getClientIp(c));
deleteCookie(c, "session_id", { path: "/" });
const rpID = Deno.env.get("RP_ID");
const cookieDomain = getCookieDomain(rpID);
setCookie(c, "session_id", sessionId, {
domain: cookieDomain,
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: ttl,
});
const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard";
if (wantsJson) {
return c.json({
success: true,
sessionId,
username,
redirectUrl,
});
}
c.header("HX-Redirect", redirectUrl);
return c.html("");
} catch (e: any) {
console.error("[Events] Failed to join event:", e);
if (wantsJson) {
return c.json({ error: "Failed to join event" }, 500);
}
return c.html(renderErrorToastFragment("Failed to join event"), 500);
}
});
// ---------------------------------------------------------
// CLI 1-Liner / Environment Ingress (/join/:slug)
// ---------------------------------------------------------
eventsRoutes.get("/join/:slug", async (c) => {
const slug = c.req.param("slug") || "";
const format = c.req.query("format") || "html";
try {
const event = await Queries.getEventBySlugOrPin(slug);
if (!event) {
if (format === "env" || format === "json") {
return c.text("Invalid slug or workshop capacity reached", 404);
}
return c.redirect("/join?error=not_found", 302);
}
const updatedEvent = await Queries.incrementEventSeats(String(event.id));
if (!updatedEvent) {
if (format === "env" || format === "json") {
return c.text("Workshop capacity reached", 404);
}
return c.redirect("/join?error=capacity_reached", 302);
}
const guestUuid = crypto.randomUUID();
const eventShortId = String(updatedEvent.id).split("-")[0];
const username = `guest_${eventShortId}_${updatedEvent.seats_claimed}`;
await Queries.createGuestUser(
guestUuid,
username,
updatedEvent.name + " Attendee",
String(updatedEvent.id),
);
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
const label = `${updatedEvent.name} Seat #${updatedEvent.seats_claimed}`;
const ttl = (Number(updatedEvent.lifespan_hours) || 3) * 3600;
let customScopes = ["guest", "trial"];
if (updatedEvent.app_id) {
const app = await Queries.getAppById(String(updatedEvent.app_id));
if (app) {
customScopes = [`app:${app.name}`, updatedEvent.role || "viewer"];
}
}
const expiresAt = new Date(Date.now() + ttl * 1000);
await Queries.createEventSession(
sessionId,
guestUuid,
label,
customScopes,
expiresAt,
);
await valkey.setex(
sessionId,
ttl,
JSON.stringify({
uuid: guestUuid,
username,
account_status: "guest",
customScopes,
}),
);
auditWrapper.auditLog(guestUuid, "event_seat_claimed", updatedEvent.id, {
slug: updatedEvent.slug,
name: updatedEvent.name,
seatNumber: updatedEvent.seats_claimed,
method: "cli",
}, getClientIp(c));
if (format === "env") {
return c.text(
`export AUTH_YES_TOKEN="${sessionId}"\nexport AUTH_YES_USER="${username}"\n`,
);
} else if (format === "json") {
return c.json({
success: true,
token: sessionId,
username,
expiresAt: expiresAt.toISOString(),
});
}
deleteCookie(c, "session_id", { path: "/" });
const rpID = Deno.env.get("RP_ID");
setCookie(c, "session_id", sessionId, {
domain: getCookieDomain(rpID),
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: ttl,
});
return c.redirect("/dashboard", 302);
} catch (e: any) {
console.error("[Events] Failed to execute CLI join:", e);
return c.text("Internal Server Error", 500);
}
});
// ---------------------------------------------------------
// Vanity Direct Workshop Entrance (/e/:slug)
// ---------------------------------------------------------
eventsRoutes.get("/e/:slug", async (c) => {
const slug = c.req.param("slug");
try {
const event = await Queries.getEventBySlug(slug);
if (!event) {
return c.redirect("/join?error=event_not_found", 302);
}
return c.html(<EventSplashPageFragment event={event} />);
} catch (_e) {
return c.redirect("/join?error=db_error", 302);
}
});
// ---------------------------------------------------------
// UI Join Page
// ---------------------------------------------------------
eventsRoutes.get("/join", (c) => {
return c.html(<EventJoinPageFragment />);
});