/** * Prometheus HTTP server. * * One `node:http` listener, a map-based router for page routes, and a tiny * static-file handler for /static/*. No framework, no templating engine — * the response-rendering path for a page is: look up the handler, call it, * wrap the result in `layout()`, flush. If any step of that stops reading * at a glance, we have gotten too clever. * * Dynamic routes land here later: /api/upload, /api/manifest, /api/publish, * /api/pay, /admin/*. For now the server is only serving pages, so the * router is only pages. */ import { createServer } from 'node:http'; import { createReadStream, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, normalize, resolve } from 'node:path'; import { layout } from './layout.js'; import * as pages from './pages.js'; import { handleUpload } from './uploads.js'; import { clamdPing } from './clamd.js'; import { publicKeyBase64, loadServiceKey } from './signing.js'; import { handleListingCreate, handleListingIndex, handleListingGet, } from './listings.js'; const PORT = Number(process.env.PORT) || 3700; const BIND = process.env.BIND || '127.0.0.1'; // Load the service key on startup so a missing-key problem shows up in the // terminal when the operator reads the launch log, not under a POST later. loadServiceKey(); const SELF = fileURLToPath(import.meta.url); const SERVICE_DIR = dirname(SELF); const PUBLIC_DIR = resolve(SERVICE_DIR, '..', 'public'); /** * The page router. * * Exact-match paths first. Patterns (category pages, listing detail) will * attach here when the browse milestone lands. */ const PAGE_ROUTES = { '/': pages.home, '/publish': pages.publish, '/browse': pages.browse, '/submit': pages.submit, '/boundaries': pages.boundaries, }; /** * Content types for static files. Deliberately short — if the server * learns to serve a type that is not here, add it here rather than * reaching for `mime-types`. */ const MIME = { '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.js': 'text/javascript; charset=utf-8', '.png': 'image/png', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', }; /* ============ server ============ */ const server = createServer(async (req, res) => { const started = Date.now(); const url = normalizeRequestUrl(req.url); try { if (url.startsWith('/static/')) { await serveStatic(url.slice('/static/'.length), res); } else if (url === '/api/upload') { await handleUpload(req, res); } else if (url === '/api/listing') { await handleListingCreate(req, res); } else if (url === '/api/listings') { handleListingIndex(req, res, req.url); } else if (url.startsWith('/api/listings/')) { handleListingGet(req, res, req.url); } else if (url === '/healthz') { await serveHealth(res); } else if (url === '/.well-known/prometheus-service-key.json') { serveServiceKey(res); } else if (PAGE_ROUTES[url]) { servePage(PAGE_ROUTES[url], res); } else { servePage(pages.notFound, res, 404, req); } } catch (err) { // The response-level error log is intentionally short — a stack trace // in the terminal is for the operator, not every reader. console.error('[prometheus] 500', req.method, url, err?.message || err); if (!res.headersSent) { res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); } res.end('internal error\n'); } res.on('close', () => { const ms = Date.now() - started; console.log(`[prometheus] ${res.statusCode} ${req.method} ${url} ${ms}ms`); }); }); server.listen(PORT, BIND, () => { console.log(`[prometheus] listening on http://${BIND}:${PORT}`); }); /* ============ helpers ============ */ /** * Trim query/hash, decode once, collapse any `..` to its parent. * Returns a leading-slash path. */ function normalizeRequestUrl(raw) { const [path] = String(raw || '/').split('?'); const decoded = decodeURIComponent(path); // normalize + leading-slash enforcement means `/foo/..//bar` → `/bar`. // On Windows, node:path/normalize uses backslashes; convert back. const normalized = normalize(decoded).replaceAll('\\', '/'); return normalized.startsWith('/') ? normalized : '/' + normalized; } /** * Render a page function to the response. */ function servePage(fn, res, status = 200, req = null) { const page = fn(req); const html = layout(page); res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60', }); res.end(html); } /** * `/healthz` — JSON probe for operators and uptime checks. Says whether * clamd (or the mock) is reachable; the HTTP status alone is enough for * an opinionated monitor. */ async function serveHealth(res) { const scanner = await clamdPing(); const ok = scanner.ok === true; const body = JSON.stringify({ ok, scanner }); res.writeHead(ok ? 200 : 503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(body), }); res.end(body); } /** * `/.well-known/prometheus-service-key.json` — the public key callers use to * verify scan reports. Lives at a well-known path so clients can cache it by * origin. */ function serveServiceKey(res) { const body = JSON.stringify({ service: 'prometheus', alg: 'ed25519', pubkey: publicKeyBase64(), issued_by: 'https://prometheus.x', }, null, 2); res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=3600', 'content-length': Buffer.byteLength(body), }); res.end(body); } /** * Serve a file from PUBLIC_DIR by relative path. The caller passes a * path with no leading slash and no `..`; this double-checks via * path.resolve against PUBLIC_DIR and refuses anything that escapes. */ async function serveStatic(relPath, res) { const full = resolve(PUBLIC_DIR, relPath); if (!full.startsWith(PUBLIC_DIR + '\\') && !full.startsWith(PUBLIC_DIR + '/')) { res.writeHead(400, { 'content-type': 'text/plain' }); res.end('bad path\n'); return; } let st; try { st = statSync(full); } catch { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('not found\n'); return; } if (!st.isFile()) { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('not found\n'); return; } const ext = '.' + relPath.split('.').pop(); const mime = MIME[ext] || 'application/octet-stream'; res.writeHead(200, { 'content-type': mime, // Static assets are versioned in the URL (`?v=1`), so a long cache is // safe and the only way to make the viewport snappy on repeat visits. 'cache-control': 'public, max-age=31536000, immutable', 'content-length': st.size, }); createReadStream(full).pipe(res); }