352 lines
16 KiB
TypeScript
352 lines
16 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;
|
|
// Pass the current password_hash so verifyAuthCookie can derive the
|
|
// expected pwTag and reject any cookie that was issued under a previous
|
|
// password - i.e. rotating the password evicts every existing session.
|
|
return verifyAuthCookie(req.headers.get("cookie"), page.id, page.password_hash);
|
|
}
|
|
|
|
// 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> {
|
|
// Page row is fetched fresh on every request - never cached. The page row
|
|
// carries the live password_hash + index_search + display config; an
|
|
// operator changing any of those in the dashboard must take effect
|
|
// immediately, not after a TTL window. The single PK lookup is sub-ms,
|
|
// and the heavy work (rollup payload below) is still cached for 15s.
|
|
const page = await loadStatusPage(slug);
|
|
if (!page) return notFound();
|
|
if (!isAuthorised(page, request)) {
|
|
// Do NOT pass page.title to the password template - that would let any
|
|
// OSINT scraper iterating slugs harvest the human-readable name of every
|
|
// private page without ever authenticating. Slug is fine: it's already
|
|
// in the URL the visitor typed.
|
|
return new Response(eta.render("password", { slug: page.slug, error: null }), {
|
|
status: 401,
|
|
headers: { "content-type": "text/html; charset=utf-8", ...NO_STORE_HEADERS },
|
|
});
|
|
}
|
|
const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug));
|
|
if (!payload) return notFound();
|
|
const html = eta.render("page", { ...payload, expandJsHash, appCssHash });
|
|
// Password-protected pages MUST be private - never let an edge cache or
|
|
// shared proxy hold a copy that some other visitor could pull. Public
|
|
// pages keep the 15s shared cache for performance under viral hits.
|
|
const cacheControl = page.password_hash
|
|
? "private, no-store, must-revalidate"
|
|
: "public, max-age=15, s-maxage=15";
|
|
const headers: Record<string, string> = {
|
|
"content-type": "text/html; charset=utf-8",
|
|
"cache-control": cacheControl,
|
|
"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> {
|
|
// Same as renderHtml: never cache the page row. The auth check has to see
|
|
// the live password_hash, otherwise rotating a password leaves a 15s
|
|
// window where old cookies still validate against the stale row.
|
|
const page = await 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 ?? '24h'}`;
|
|
const payload = await cached(cacheKey, 15, () => loadPagePayload(slug, win));
|
|
if (!payload) return jsonNotFound();
|
|
// Password-protected JSON must be private - same reasoning as renderHtml.
|
|
const cacheControl = page.password_hash
|
|
? "private, no-store, must-revalidate"
|
|
: "public, max-age=15, s-maxage=15";
|
|
return new Response(JSON.stringify(payload), {
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"cache-control": cacheControl,
|
|
...(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));
|
|
// Authenticated visitor on a private page → never edge-cache the response.
|
|
const cacheControl = page.password_hash
|
|
? "private, no-store, must-revalidate"
|
|
: "public, max-age=300, s-maxage=300";
|
|
return new Response(xml, {
|
|
headers: {
|
|
"content-type": "application/rss+xml; charset=utf-8",
|
|
"cache-control": cacheControl,
|
|
},
|
|
});
|
|
}
|
|
|
|
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);
|
|
// Same private/no-store rule on authenticated badge fetches for private pages.
|
|
const cacheControl = page.password_hash
|
|
? "private, no-store, must-revalidate"
|
|
: "public, max-age=15, s-maxage=15";
|
|
return new Response(svg, {
|
|
headers: {
|
|
"content-type": "image/svg+xml",
|
|
"cache-control": cacheControl,
|
|
},
|
|
});
|
|
})
|
|
|
|
// 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();
|
|
const cacheControl = page.password_hash
|
|
? "private, no-store, must-revalidate"
|
|
: "public, max-age=15, s-maxage=15";
|
|
return new Response(JSON.stringify(payload), {
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"cache-control": cacheControl,
|
|
},
|
|
});
|
|
})
|
|
|
|
// 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": page.password_hash
|
|
? "private, no-store, must-revalidate"
|
|
: "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", { slug: page.slug, error: "Wrong password" }), {
|
|
status: 401,
|
|
headers: { "content-type": "text/html; charset=utf-8", ...NO_STORE_HEADERS },
|
|
});
|
|
}
|
|
return new Response(null, {
|
|
status: 303,
|
|
headers: { "location": `/${page.slug}`, "set-cookie": makeAuthCookie(page.id, page.password_hash) },
|
|
});
|
|
});
|
|
|
|
const port = Number(process.env.STATUS_PORT ?? 3003);
|
|
const server = Bun.serve({
|
|
port,
|
|
// Wrap app.handle in a try/catch so any unexpected throw - Postgres
|
|
// connection blip, template render error, missing env var, etc. - turns
|
|
// into a generic 500 with an opaque body. Without this wrapper Bun's
|
|
// default error path may include framework details, file paths, or stack
|
|
// traces in the response, which would leak internal layout to anyone who
|
|
// could trigger an exception. We log the real reason server-side.
|
|
async fetch(req) {
|
|
try {
|
|
return await app.handle(req);
|
|
} catch (err) {
|
|
console.error("[fetch] unhandled error:", err);
|
|
return new Response("Internal server error", {
|
|
status: 500,
|
|
headers: { "content-type": "text/plain; charset=utf-8", ...NO_STORE_HEADERS },
|
|
});
|
|
}
|
|
},
|
|
error(err) {
|
|
// Belt-and-suspenders: even if Bun catches an error before our fetch
|
|
// wrapper sees it, return a generic body rather than the default page.
|
|
console.error("[server] error:", err);
|
|
return new Response("Internal server error", {
|
|
status: 500,
|
|
headers: { "content-type": "text/plain; charset=utf-8", ...NO_STORE_HEADERS },
|
|
});
|
|
},
|
|
});
|
|
|
|
console.log(`PingQL status service running at http://localhost:${server.port}`);
|