POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
207 lines
6.5 KiB
JavaScript
207 lines
6.5 KiB
JavaScript
/**
|
|
* 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';
|
|
|
|
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 === '/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);
|
|
}
|