351 lines
10 KiB
TypeScript
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 />);
|
|
});
|