import { Elysia, t } from "elysia"; import { requireAuth } from "./auth"; import sql from "../db"; const Theme = t.Union([t.Literal("auto"), t.Literal("light"), t.Literal("dark")]); const Window = t.Union([t.Literal("24h"), t.Literal("7d"), t.Literal("30d"), t.Literal("90d")]); const DisplayMode = t.Union([t.Literal("compact"), t.Literal("expanded")]); const BarFrequency = t.Union([t.Literal("hourly"), t.Literal("daily")]); const StatusPageBody = t.Object({ slug: t.String({ minLength: 1, maxLength: 80, pattern: "^[a-z0-9][a-z0-9-]*$", description: "URL slug, lowercase + hyphens" }), title: t.String({ minLength: 1, maxLength: 200 }), description: t.Optional(t.Nullable(t.String({ maxLength: 2000 }))), theme: t.Optional(Theme), password: t.Optional(t.Nullable(t.String({ description: "Plain text. Will be hashed at write time. Pass null to clear." }))), index_search: t.Optional(t.Boolean()), show_powered_by: t.Optional(t.Boolean()), show_response_time: t.Optional(t.Boolean()), show_cert_expiry: t.Optional(t.Boolean()), default_window: t.Optional(Window), display_mode: t.Optional(DisplayMode), bar_frequency: t.Optional(BarFrequency), bar_count: t.Optional(t.Number({ minimum: 1, maximum: 180 })), custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))), footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))), og_image_url: t.Optional(t.Nullable(t.String({ maxLength: 2048 }))), analytics_html: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))), auto_refresh_s: t.Optional(t.Number({ minimum: 10, maximum: 3600 })), groups: t.Optional(t.Array(t.Object({ name: t.String({ minLength: 1, maxLength: 200 }), position: t.Optional(t.Number()), }))), monitors: t.Optional(t.Array(t.Object({ monitor_id: t.String(), group_index: t.Optional(t.Nullable(t.Number())), display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))), display_mode: t.Optional(t.Nullable(DisplayMode)), position: t.Optional(t.Number()), }))), }); // Strip @import and expression() from custom CSS — basic sanity, not a full // parser. The CSS still runs in the visitor's browser; this just blocks the // most common smuggling vectors. function sanitizeCss(css: string | null | undefined): string | null { if (!css) return null; return css .replace(/@import[^;]*;?/gi, "") .replace(/expression\s*\(/gi, ""); } async function hashPassword(plain: string): Promise { return await Bun.password.hash(plain, { algorithm: "bcrypt", cost: 10 }); } async function replaceGroupsAndMonitors( pageId: string, accountId: string, groups: { name: string; position?: number }[] | undefined, monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; display_mode?: "compact" | "expanded" | null; position?: number }[] | undefined, ) { if (groups !== undefined) { await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`; } const groupIds: string[] = []; if (groups && groups.length > 0) { for (let i = 0; i < groups.length; i++) { const g = groups[i]!; const [row] = await sql<{ id: string }[]>` INSERT INTO status_page_groups (status_page_id, name, position) VALUES (${pageId}, ${g.name}, ${g.position ?? i}) RETURNING id `; groupIds.push(row!.id); } } if (monitorsList !== undefined) { await sql`DELETE FROM status_page_monitors WHERE status_page_id = ${pageId}`; } if (monitorsList && monitorsList.length > 0) { // Validate that the monitors all belong to this account. const monitorIds = monitorsList.map((m) => m.monitor_id); const owned = await sql<{ id: string }[]>` SELECT id FROM monitors WHERE account_id = ${accountId} AND id = ANY(${sql.array(monitorIds)}::text[]) `; const ownedSet = new Set(owned.map((o) => o.id)); const rows: any[] = []; for (let i = 0; i < monitorsList.length; i++) { const m = monitorsList[i]!; if (!ownedSet.has(m.monitor_id)) continue; const groupId = m.group_index != null && groupIds[m.group_index] ? groupIds[m.group_index] : null; rows.push({ status_page_id: pageId, monitor_id: m.monitor_id, group_id: groupId, display_name: m.display_name ?? null, display_mode: m.display_mode ?? null, position: m.position ?? i, }); } if (rows.length > 0) { await sql` INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "display_mode", "position")} `; } } } export const statusPages = new Elysia({ prefix: "/status-pages" }) .use(requireAuth) .get("/", async ({ accountId }) => { return sql` SELECT id, slug, title, description, theme, default_window, created_at, updated_at FROM status_pages WHERE account_id = ${accountId} ORDER BY created_at DESC `; }, { detail: { summary: "List status pages", tags: ["status-pages"] } }) .post("/", async ({ accountId, body, set }) => { const password_hash = body.password ? await hashPassword(body.password) : null; const css = sanitizeCss(body.custom_css); let row; try { [row] = await sql` INSERT INTO status_pages ( account_id, slug, title, description, theme, password_hash, index_search, show_powered_by, show_response_time, show_cert_expiry, default_window, display_mode, bar_frequency, bar_count, custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s ) VALUES ( ${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null}, ${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? true}, ${body.show_powered_by ?? true}, ${body.show_response_time ?? true}, ${body.show_cert_expiry ?? false}, ${body.default_window ?? '24h'}, ${body.display_mode ?? 'expanded'}, ${body.bar_frequency ?? 'daily'}, ${body.bar_count ?? 90}, ${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null}, ${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60} ) RETURNING * `; } catch (e: any) { if (e?.code === "23505") { set.status = 409; return { error: "Slug already in use" }; } throw e; } await replaceGroupsAndMonitors(row.id, accountId, body.groups, body.monitors); return row; }, { body: StatusPageBody, detail: { summary: "Create status page", tags: ["status-pages"] } }) .get("/:id", async ({ accountId, params, set }) => { const [page] = await sql` SELECT * FROM status_pages WHERE id = ${params.id} AND account_id = ${accountId} `; if (!page) { set.status = 404; return { error: "Not found" }; } const groups = await sql` SELECT id, name, position FROM status_page_groups WHERE status_page_id = ${page.id} ORDER BY position ASC `; const monitors = await sql` SELECT spm.monitor_id, spm.group_id, spm.display_name, spm.position, m.name, m.url FROM status_page_monitors spm JOIN monitors m ON m.id = spm.monitor_id WHERE spm.status_page_id = ${page.id} ORDER BY spm.position ASC `; delete (page as any).password_hash; return { ...page, has_password: !!(page as any).password_hash || false, groups, monitors }; }, { detail: { summary: "Get status page", tags: ["status-pages"] } }) .patch("/:id", async ({ accountId, params, body, set }) => { const password_hash = body.password === undefined ? null : body.password === null ? null : await hashPassword(body.password); const css = body.custom_css === undefined ? null : sanitizeCss(body.custom_css); let row; try { [row] = await sql` UPDATE status_pages SET slug = COALESCE(${body.slug ?? null}, slug), title = COALESCE(${body.title ?? null}, title), description = COALESCE(${body.description ?? null}, description), theme = COALESCE(${body.theme ?? null}, theme), password_hash = CASE WHEN ${body.password === null} THEN NULL WHEN ${body.password !== undefined} THEN ${password_hash} ELSE password_hash END, index_search = COALESCE(${body.index_search ?? null}, index_search), show_powered_by = COALESCE(${body.show_powered_by ?? null}, show_powered_by), show_response_time = COALESCE(${body.show_response_time ?? null}, show_response_time), show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry), default_window = COALESCE(${body.default_window ?? null}, default_window), display_mode = COALESCE(${body.display_mode ?? null}, display_mode), bar_frequency = COALESCE(${body.bar_frequency ?? null}, bar_frequency), bar_count = COALESCE(${body.bar_count ?? null}, bar_count), custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END, footer_text = COALESCE(${body.footer_text ?? null}, footer_text), og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url), analytics_html = COALESCE(${body.analytics_html ?? null}, analytics_html), auto_refresh_s = COALESCE(${body.auto_refresh_s ?? null}, auto_refresh_s), updated_at = now() WHERE id = ${params.id} AND account_id = ${accountId} RETURNING * `; } catch (e: any) { if (e?.code === "23505") { set.status = 409; return { error: "Slug already in use" }; } throw e; } if (!row) { set.status = 404; return { error: "Not found" }; } await replaceGroupsAndMonitors(row.id, accountId, body.groups, body.monitors); return row; }, { body: t.Partial(StatusPageBody), detail: { summary: "Update status page", tags: ["status-pages"] } }) .delete("/:id", async ({ accountId, params, set }) => { const [row] = await sql` DELETE FROM status_pages WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id `; if (!row) { set.status = 404; return { error: "Not found" }; } return { deleted: true }; }, { detail: { summary: "Delete status page", tags: ["status-pages"] } });