pingql/apps/api/src/routes/status_pages.ts

225 lines
11 KiB
TypeScript

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<string> {
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"] } });