pingql/apps/status/src/index.ts

289 lines
13 KiB
TypeScript

import { Elysia } from "elysia";
import { Eta } from "eta";
import { resolve } from "path";
import { createHash } from "crypto";
import sql from "./db";
import { loadStatusPage, loadPagePayload, loadMonitorDetail, type Window } from "./data";
import { renderRss } from "./render/rss";
import { renderBadge, badgeFromState } from "./render/badge";
import { cached } from "./cache";
import { allow } from "./rate-limit";
import { checkPassword, makeAuthCookie, verifyAuthCookie } from "./auth";
// Crash isolation: log loudly, never exit. Status pages going down silently is
// worse than weird logs.
process.on("unhandledRejection", (reason) => console.error("[unhandledRejection]", reason));
process.on("uncaughtException", (err) => console.error("[uncaughtException]", err));
const eta = new Eta({ views: resolve(import.meta.dir, "./views"), cache: true, defaultExtension: ".ejs" });
const PUBLIC_BASE = process.env.STATUS_BASE_URL ?? "https://status.pingql.com";
// Hash the static assets so their URLs change whenever their bytes change.
// Used as cache-busting query strings on the <link>/<script> tags so visitors
// always pull the freshest CSS/JS even with the long-lived `immutable`
// Cache-Control we set on the asset routes below.
const expandJsPath = resolve(import.meta.dir, "./static/expand.js");
const expandJsBytes = await Bun.file(expandJsPath).bytes();
const expandJsHash = createHash("md5").update(expandJsBytes).digest("hex").slice(0, 8);
const appCssPath = resolve(import.meta.dir, "./static/app.css");
const appCssBytes = await Bun.file(appCssPath).bytes();
const appCssHash = createHash("md5").update(appCssBytes).digest("hex").slice(0, 8);
function clientIp(req: Request): string {
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| req.headers.get("cf-connecting-ip")
|| "unknown";
}
// 404s must NOT be cached by browsers or Cloudflare. The same URL frequently
// flips between "200 with data" and "404 not-found" — for example when an
// operator adds a password to a previously-public page, or when a slug
// changes, or when a monitor is removed. If Cloudflare cached a 404 (default
// behaviour for unspecified Cache-Control on 404 responses) the operator
// would see stale 404s for the entire edge TTL. Likewise, an old 200 cached
// at the edge could keep serving image/svg+xml headers over a now-changed
// body, which is exactly the symptom that prompted this fix.
//
// no-store is the bluntest form: never store, always revalidate. Pair with
// 404 status so any layer in front knows not to hold on to it.
const NO_STORE_HEADERS = {
"cache-control": "no-store, must-revalidate",
"pragma": "no-cache",
} as const;
function notFound(): Response {
return new Response(eta.render("not-found", {}), {
status: 404,
headers: {
"content-type": "text/html; charset=utf-8",
...NO_STORE_HEADERS,
},
});
}
function jsonNotFound(): Response {
return new Response(JSON.stringify({ error: "not found" }), {
status: 404,
headers: {
"content-type": "application/json",
...NO_STORE_HEADERS,
},
});
}
function rateLimited(): Response {
return new Response("Too many requests", { status: 429 });
}
function isAuthorised(page: { id: string; password_hash: string | null }, req: Request): boolean {
if (!page.password_hash) return true;
return verifyAuthCookie(req.headers.get("cookie"), page.id);
}
// Memoirist (Elysia's router) treats `:slug.json` as a single parameter named
// "slug.json" and refuses to coexist with `:slug`. Instead, use one `:slug`
// route and dispatch on the suffix in the handler.
function splitSlugAndFormat(raw: string): { slug: string; format: "html" | "json" | "rss" } {
if (raw.endsWith(".json")) return { slug: raw.slice(0, -5), format: "json" };
if (raw.endsWith(".rss")) return { slug: raw.slice(0, -4), format: "rss" };
return { slug: raw, format: "html" };
}
async function renderHtml(slug: string, request: Request): Promise<Response> {
const page = await cached(`page:${slug}`, 15, () => loadStatusPage(slug));
if (!page) return notFound();
if (!isAuthorised(page, request)) {
return new Response(eta.render("password", { title: page.title, slug: page.slug, error: null }), {
status: 401,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug));
if (!payload) return notFound();
const html = eta.render("page", { ...payload, expandJsHash, appCssHash });
const headers: Record<string, string> = {
"content-type": "text/html; charset=utf-8",
"cache-control": "public, max-age=15, s-maxage=15",
"x-frame-options":"SAMEORIGIN",
"x-content-type-options": "nosniff",
"referrer-policy":"strict-origin-when-cross-origin",
};
if (!page.index_search) headers["x-robots-tag"] = "noindex, nofollow";
return new Response(html, { headers });
}
async function renderJson(slug: string, request: Request, win?: Window): Promise<Response> {
const page = await cached(`page:${slug}`, 15, () => loadStatusPage(slug));
// Both nonexistent and gated-without-cookie collapse to the same 404 so a
// scraper iterating slugs against /<slug>.json can't use the response code
// as an oracle for which slugs are private. Forces any OSINT enumeration to
// fall back to HTML scraping (which gets the password form, also a 401-ish
// signal but more expensive to parse and less stable).
if (!page || !isAuthorised(page, request)) return jsonNotFound();
const cacheKey = `payload:${slug}:${win ?? page.default_window}`;
const payload = await cached(cacheKey, 15, () => loadPagePayload(slug, win));
if (!payload) return jsonNotFound();
return new Response(JSON.stringify(payload), {
headers: {
"content-type": "application/json",
"cache-control": "public, max-age=15, s-maxage=15",
...(page.index_search ? {} : { "x-robots-tag": "noindex, nofollow" }),
},
});
}
async function renderRssResp(slug: string, request: Request): Promise<Response> {
const page = await loadStatusPage(slug);
if (!page) return notFound();
// Password-protected pages must NOT leak incident text via RSS. Without
// this check anyone with the slug could pull every incident update on a
// private page just by hitting /<slug>.rss. Match the page-existence 404
// exactly so an unauthenticated scraper can't even tell whether a slug
// is gated vs. nonexistent.
if (!isAuthorised(page, request)) return notFound();
const xml = await cached(`rss:${slug}`, 300, () => renderRss(page, PUBLIC_BASE));
return new Response(xml, {
headers: {
"content-type": "application/rss+xml; charset=utf-8",
"cache-control": "public, max-age=300, s-maxage=300",
},
});
}
const app = new Elysia()
// No status page lives at the root — show the same 404 visitors get for any
// unknown slug, so a stray hit on the apex doesn't leak service identity.
.get("/", () => notFound())
// Static expand.js — cached aggressively, hash-busted via query string.
.get("/_static/expand.js", () => new Response(Bun.file(expandJsPath), {
headers: {
"content-type": "application/javascript; charset=utf-8",
"cache-control": "public, max-age=31536000, immutable",
},
}))
// Static app.css — same caching contract as expand.js. The query string in
// the <link> tag is the file's MD5 hash, so deploys propagate immediately
// even though the asset itself is marked immutable for a year.
.get("/_static/app.css", () => new Response(Bun.file(appCssPath), {
headers: {
"content-type": "text/css; charset=utf-8",
"cache-control": "public, max-age=31536000, immutable",
},
}))
// Single public route — dispatches HTML / JSON / RSS by extension on the slug.
.get("/:slug", async ({ params, request, query }) => {
const { slug, format } = splitSlugAndFormat(params.slug);
if (!allow(slug, clientIp(request))) return rateLimited();
if (format === "json") return renderJson(slug, request, (query as any)?.window as Window | undefined);
if (format === "rss") return renderRssResp(slug, request);
return renderHtml(slug, request);
})
// Public SVG badge. Password-protected pages 404 here so an unauthenticated
// shields-style embed can't reveal a private page's current state — and
// crucially can't even confirm whether a private slug exists, since the
// 404 is identical to a totally bogus slug.
.get("/:slug/badge.svg", async ({ params, request }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited();
const page = await loadStatusPage(params.slug);
if (!page) return notFound();
if (!isAuthorised(page, request)) return notFound();
const payload = await cached(`payload:${params.slug}`, 15, () => loadPagePayload(params.slug));
if (!payload) return notFound();
const { message, color } = badgeFromState(payload.monitors);
const svg = renderBadge("status", message, color);
return new Response(svg, {
headers: {
"content-type": "image/svg+xml",
"cache-control": "public, max-age=15, s-maxage=15",
},
});
})
// Per-monitor detail JSON for the click-to-expand UI in compact mode.
// Path is /:slug/monitor/:idWithExt where idWithExt is e.g. "abc123.json".
// We strip the .json suffix in the handler — same trick as the slug route to
// dodge memoirist's "two params at the same position" rule.
.get("/:slug/monitor/:idWithExt", async ({ params, request, query }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited();
// Same password gate as the page-level JSON. Without this, the click-to-
// expand endpoint would happily serve uptime / region state / incidents
// for monitors on a password-protected page to anyone who guesses the
// monitor id (8 hex bytes). Match the page existence 404 so unauthenticated
// requests can't even tell whether a slug is gated.
const page = await loadStatusPage(params.slug);
if (!page || !isAuthorised(page, request)) return jsonNotFound();
const idWithExt = params.idWithExt;
const monitorId = idWithExt.endsWith(".json") ? idWithExt.slice(0, -5) : idWithExt;
const win = (query as any)?.window as Window | undefined;
const cacheKey = `monitor:${params.slug}:${monitorId}:${win ?? ''}`;
const payload = await cached(cacheKey, 15, () => loadMonitorDetail(params.slug, monitorId, win));
if (!payload) return jsonNotFound();
return new Response(JSON.stringify(payload), {
headers: {
"content-type": "application/json",
"cache-control": "public, max-age=15, s-maxage=15",
},
});
})
// PWA manifest. Rate-limited like every other public endpoint, and
// password-gated 404s match the page-existence 404 so a scraper can't use
// this route as an oracle for which slugs exist behind a password gate.
.get("/:slug/manifest.json", async ({ params, request }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited();
const page = await loadStatusPage(params.slug);
if (!page) return notFound();
if (!isAuthorised(page, request)) return notFound();
return new Response(JSON.stringify({
name: page.title,
short_name: page.title.slice(0, 12),
description: page.description ?? "",
start_url: `/${page.slug}`,
display: "standalone",
background_color: page.theme === "light" ? "#ffffff" : "#0a0a0a",
theme_color: page.theme === "light" ? "#0ea5e9" : "#0a0a0a",
}), {
headers: {
"content-type": "application/manifest+json",
"cache-control": "public, max-age=86400, s-maxage=86400",
},
});
})
// Password gate POST
.post("/:slug/auth", async ({ params, request }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited();
const page = await loadStatusPage(params.slug);
if (!page) return notFound();
if (!page.password_hash) {
return Response.redirect(`/${page.slug}`, 303);
}
const form = await request.formData();
const password = String(form.get("password") ?? "");
const ok = await checkPassword(password, page.password_hash);
if (!ok) {
return new Response(eta.render("password", { title: page.title, slug: page.slug, error: "Wrong password" }), {
status: 401,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
return new Response(null, {
status: 303,
headers: { "location": `/${page.slug}`, "set-cookie": makeAuthCookie(page.id) },
});
});
const port = Number(process.env.STATUS_PORT ?? 3003);
const server = Bun.serve({
port,
fetch(req) { return app.handle(req); },
});
console.log(`PingQL status service running at http://localhost:${server.port}`);