prometheus/service/uploads.js

281 lines
9.2 KiB
JavaScript
Raw Permalink Normal View History

prometheus: upload + ClamAV scan + signed scan reports 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.
2026-10-05 22:41:55 +02:00
/**
* POST /api/upload — accept a file, scan it, hand back a signed scan report.
*
* This endpoint is the floor of the submit path. It does not publish anything
* on chain, it does not collect payment, it does not need the publisher's
* identity. All it does is:
*
* 1. Receive the file bytes (multipart/form-data, `file` field).
* 2. Spool to a temp file while hashing to SHA-256.
* 3. Hand the file to clamd (or MOCK_SCAN) for a verdict.
* 4. Keep the file if clean, discard it otherwise.
* 5. Sign the verdict with the service key and return it.
*
* A clean upload leaves `uploads/<sha256>.bin` on disk plus a
* `uploads/<sha256>.json` sidecar with the metadata. The signed report in
* the response is the artifact the manifest-signing step will attach to the
* APS1 record later.
*
* The file-size cap is deliberately generous — apps can be big — but every
* byte over the cap is dropped and the upload rejected: letting one request
* fill the disk is worse than making a publisher try again.
*/
import { createHash } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { pipeline } from 'node:stream/promises';
import { PassThrough } from 'node:stream';
import Busboy from 'busboy';
import { scanStream } from './clamd.js';
import { signReport } from './signing.js';
import { isConfigured as storageConfigured, uploadScannedFile } from './storage.js';
prometheus: upload + ClamAV scan + signed scan reports 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.
2026-10-05 22:41:55 +02:00
const UPLOAD_DIR = process.env.PROMETHEUS_UPLOAD_DIR
|| resolve(dirname(fileURLToPath(import.meta.url)), '..', 'uploads');
prometheus: size-tiered pricing, 2 GiB cap, new hero copy, SEO LD Three threads, one commit: 1. Size-tiered listing fee instead of a flat $1 priceUsdForSize(bytes) in service/payments.js: 0 - 100 MiB → free 100.001 - 200 MiB → $1 200.001 - 300 MiB → $2 ... 1900.001 - 2000 MiB → $19 2000.001 - 2048 MiB → $20 (ceiling at 2 GiB) listingNeedsPayment(bytes) replaces paymentsRequired() — a free-tier upload always publishes immediately even when PAYMENT_REQUIRED=1; the operator switch only governs the paid tiers. createInvoice derives the fee from the listing's own size at invoice time. 2. Upload cap back to 2 GiB The 200-MiB cap from the previous commit (which fixed the Caddy-413 HTML bug) is reverted. The paywall is now the real size gate; Caddy is just the ceiling. p2pexchange Caddyfile already updated + reloaded. 3. Hero + SEO Hero leads with the plain-English product category: The decentralized app store. A home for apps, programs, dApps, and libraries — listed and settled on the Bitcoin Cash chain, hosted on the decentralized web, endorsed by signature. Anyone can publish. Nobody can gatekeep. Named for the one who gave fire to humans and did not ask the gods first. "hosted on Sia" → "hosted on the decentralized web". Sia is the mechanism, not the pitch — general readers aren't tripped on the acronym on the hero. Meta description rewritten to lead with "decentralized app store" and name the long-tail terms the audience searches for ("web3 browsers", "BCH wallets", "mobile and desktop apps"). Two JSON-LD blocks (WebSite + Organization) inlined on home via layout's headExtra channel — knowledge panel + WebSite result card. 4. Submit wizard Step 1 result shows the computed listing fee under the scan verdict, so publishers see the price BEFORE filling out the manifest. Step 4 publish branches on the server's payment_required flag. On a paid listing it POSTs /api/invoice right after the listing POST and renders a payment card with the exact sat amount, the operator receive address, a `bitcoincash:` URI the wallet opens, and the invoice status URL for polling.
2026-10-22 23:31:43 +03:00
// 2 GiB — upper ceiling. The REAL gate on size is the paywall (free below
// 100 MiB, $1/USD per 100 MiB above), not this constant. The service's cap
// and Caddy's `request_body { max_size 2GB }` on the prometheus.x vhost
// must stay in sync — if Caddy rejects first, the browser gets an HTML 413
// page that the JSON parser cannot read.
prometheus: 200 MiB upload cap + Caddy block fix for the "<" error Two bugs under one roof. The visible one: a publisher uploading anything over 10 MB on the live site got "Network error — Unexpected token '<'" in the browser console. Cause: the Caddy block I wrote for prometheus.x had `request_body { max_size 2GB }` nested inside the handle block, where it is a syntax Caddy parses but does not apply, so Caddy's own 10 MB default was still the gate. Any oversize POST got Caddy's HTML 413 page, which the browser's JSON parser choked on at the leading `<`. Fix: moved `request_body { max_size … }` to the top of the prometheus.x site block (outside `handle`), where Caddy actually honours it. The live Caddyfile on p2pexchange is already patched + reloaded; a 50 MB upload verified through the end-to-end path (Caddy → service → clamd → signed scan report). The scope one: 2 GiB was more than Prometheus should offer to publishers at launch. Mobile apps are usually ~100 MB and even most desktop installers come in well under 200 MiB. Dropping to 200 MiB: - Fits every Android APK and most desktop installers. - Shrinks the operator's Sia storage bill (fewer accidental Steam-size uploads). - Keeps the "publishers of larger binaries self-host and submit a manifest with the external URL + SHA" escape hatch — the scanner still fetches the URL and checks the hash, so a 5 GB game isn't locked out, Prometheus just doesn't eat its storage. Changes: service/uploads.js MAX_FILE_BYTES default: 2 GiB → 200 MiB service/pages.js /submit step-1 subtitle: "any size up to 2 GiB" → "up to 200 MiB" deploy/prometheus-x.conf (unused angie fallback) 2g → 200m deploy/apps-silentmode-st.conf (unused angie fallback) 2g → 200m PROMETHEUS_MAX_UPLOAD_BYTES env still overrides per-instance.
2026-10-22 23:20:19 +03:00
// Change via env rather than code so an operator can tighten per-instance.
prometheus: upload + ClamAV scan + signed scan reports 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.
2026-10-05 22:41:55 +02:00
const MAX_FILE_BYTES = Number(process.env.PROMETHEUS_MAX_UPLOAD_BYTES)
prometheus: size-tiered pricing, 2 GiB cap, new hero copy, SEO LD Three threads, one commit: 1. Size-tiered listing fee instead of a flat $1 priceUsdForSize(bytes) in service/payments.js: 0 - 100 MiB → free 100.001 - 200 MiB → $1 200.001 - 300 MiB → $2 ... 1900.001 - 2000 MiB → $19 2000.001 - 2048 MiB → $20 (ceiling at 2 GiB) listingNeedsPayment(bytes) replaces paymentsRequired() — a free-tier upload always publishes immediately even when PAYMENT_REQUIRED=1; the operator switch only governs the paid tiers. createInvoice derives the fee from the listing's own size at invoice time. 2. Upload cap back to 2 GiB The 200-MiB cap from the previous commit (which fixed the Caddy-413 HTML bug) is reverted. The paywall is now the real size gate; Caddy is just the ceiling. p2pexchange Caddyfile already updated + reloaded. 3. Hero + SEO Hero leads with the plain-English product category: The decentralized app store. A home for apps, programs, dApps, and libraries — listed and settled on the Bitcoin Cash chain, hosted on the decentralized web, endorsed by signature. Anyone can publish. Nobody can gatekeep. Named for the one who gave fire to humans and did not ask the gods first. "hosted on Sia" → "hosted on the decentralized web". Sia is the mechanism, not the pitch — general readers aren't tripped on the acronym on the hero. Meta description rewritten to lead with "decentralized app store" and name the long-tail terms the audience searches for ("web3 browsers", "BCH wallets", "mobile and desktop apps"). Two JSON-LD blocks (WebSite + Organization) inlined on home via layout's headExtra channel — knowledge panel + WebSite result card. 4. Submit wizard Step 1 result shows the computed listing fee under the scan verdict, so publishers see the price BEFORE filling out the manifest. Step 4 publish branches on the server's payment_required flag. On a paid listing it POSTs /api/invoice right after the listing POST and renders a payment card with the exact sat amount, the operator receive address, a `bitcoincash:` URI the wallet opens, and the invoice status URL for polling.
2026-10-22 23:31:43 +03:00
|| 2 * 1024 * 1024 * 1024;
prometheus: upload + ClamAV scan + signed scan reports 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.
2026-10-05 22:41:55 +02:00
/**
* Express-style handler attached to `/api/upload`.
*/
export async function handleUpload(req, res) {
if (req.method !== 'POST') {
return respond(res, 405, { ok: false, error: 'method not allowed' });
}
const contentType = String(req.headers['content-type'] || '');
if (!contentType.startsWith('multipart/form-data')) {
return respond(res, 415, {
ok: false,
error: 'content-type must be multipart/form-data with a `file` field',
});
}
await mkdir(UPLOAD_DIR, { recursive: true });
const tempId = randomUUID();
const tempPath = resolve(UPLOAD_DIR, `.tmp-${tempId}`);
let gotFile = false;
let truncated = false;
let originalName = '';
let mimeType = 'application/octet-stream';
// We let busboy drive. The one 'file' event we care about forwards straight
// to a hashing + write pipeline; any extra fields are ignored.
const busboy = Busboy({
headers: req.headers,
limits: { fileSize: MAX_FILE_BYTES, files: 1 },
});
const hasher = createHash('sha256');
let byteCount = 0;
// We'd like to pipe the busboy file into TWO sinks: the temp file and the
// hasher. PassThrough splits a stream in one line: one upstream, two
// downstreams, no buffering we have to manage ourselves.
const toHash = new PassThrough();
const toDisk = new PassThrough();
toHash.on('data', (chunk) => {
hasher.update(chunk);
byteCount += chunk.length;
});
const writeToDisk = pipeline(toDisk, createWriteStream(tempPath));
const fileDone = new Promise((resolveFile, rejectFile) => {
busboy.on('file', (fieldname, file, info) => {
gotFile = true;
originalName = sanitize(info.filename || 'upload');
mimeType = info.mimeType || mimeType;
file.on('limit', () => { truncated = true; });
file.on('data', (chunk) => {
toHash.write(chunk);
toDisk.write(chunk);
});
file.on('end', () => {
toHash.end();
toDisk.end();
});
file.on('error', rejectFile);
});
busboy.on('finish', resolveFile);
busboy.on('error', rejectFile);
});
// Pump the request through busboy. If anything throws here we clean up and
// reply with 400 — we never leave a half-written temp file behind.
try {
req.pipe(busboy);
await fileDone;
await writeToDisk;
} catch (err) {
await safeUnlink(tempPath);
return respond(res, 400, { ok: false, error: String(err?.message || err) });
}
if (!gotFile) {
await safeUnlink(tempPath);
return respond(res, 400, { ok: false, error: 'no `file` field in request' });
}
if (truncated) {
await safeUnlink(tempPath);
return respond(res, 413, {
ok: false,
error: `file exceeded the ${MAX_FILE_BYTES}-byte cap`,
});
}
const sha256 = hasher.digest('hex');
// Scan from the on-disk copy. Scanning in parallel with receive would
// save latency on huge files, but doubles the error surface — we would
// need to coordinate two independent failure modes. On-disk-then-scan is
// what every mature scanning service does; we can revisit once we have
// a reason.
const scan = await scanStream(createReadStream(tempPath), { sha256 });
prometheus: upload + ClamAV scan + signed scan reports 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.
2026-10-05 22:41:55 +02:00
const now = new Date().toISOString();
const upload = {
hash: sha256,
size: byteCount,
name: originalName,
mime: mimeType,
received_at: now,
};
let stored = false;
let finalPath = null;
let hostedUrl = null;
prometheus: upload + ClamAV scan + signed scan reports 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.
2026-10-05 22:41:55 +02:00
if (scan.verdict === 'clean') {
finalPath = resolve(UPLOAD_DIR, `${sha256}.bin`);
try {
await rename(tempPath, finalPath);
stored = true;
await writeFile(
resolve(UPLOAD_DIR, `${sha256}.json`),
JSON.stringify({ upload, scan }, null, 2),
'utf8',
);
} catch (err) {
// Rare: another concurrent upload of the same bytes won the race.
// The existing file is already correct; drop ours and move on.
await safeUnlink(tempPath);
stored = true;
finalPath = resolve(UPLOAD_DIR, `${sha256}.bin`);
}
// Push to Sia-backed storage if the service is configured for it, so
// the submit UI can skip the "where will you host this?" question. A
// failure here is non-fatal — the local scan report still works and
// the publisher can fall back to providing their own download_url.
if (storageConfigured()) {
try {
const result = await uploadScannedFile({
localPath: finalPath,
sha256,
name: originalName,
mime: mimeType,
});
hostedUrl = result.downloadUrl;
} catch (err) {
// Keep going — the local copy stays on disk and the publisher can
// paste their own URL in step 2. Logged for the operator.
console.error('[prometheus] sia upload failed:',
err?.message || String(err));
}
}
prometheus: upload + ClamAV scan + signed scan reports 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.
2026-10-05 22:41:55 +02:00
} else {
// Infected or error — the bytes do not stay on our disk.
await safeUnlink(tempPath);
}
// The signed report is what callers persist. It carries the upload metadata
// AND the scan outcome, so a downstream verifier can confirm this exact
// (hash, size, verdict) tuple came from this service and was not edited.
const reportPayload = {
v: 1,
kind: 'prometheus.scan-report',
service: 'prometheus',
issued_at: now,
upload: {
hash: upload.hash,
size: upload.size,
name: upload.name,
mime: upload.mime,
},
scan: {
verdict: scan.verdict,
engine: scan.engine,
signatures: scan.signatures,
duration_ms: scan.duration_ms,
...(scan.virustotal ? { virustotal: scan.virustotal } : {}),
prometheus: upload + ClamAV scan + signed scan reports 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.
2026-10-05 22:41:55 +02:00
},
stored,
};
const report = signReport(reportPayload);
return respond(res, scan.verdict === 'error' ? 502 : 200, {
ok: scan.verdict !== 'error',
upload,
scan: {
verdict: scan.verdict,
engine: scan.engine,
signatures: scan.signatures,
duration_ms: scan.duration_ms,
...(scan.error ? { error: scan.error } : {}),
...(scan.virustotal ? { virustotal: scan.virustotal } : {}),
prometheus: upload + ClamAV scan + signed scan reports 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.
2026-10-05 22:41:55 +02:00
},
stored,
// When Sia-backed storage is wired up and the push succeeded, this is
// the public download URL the UI pre-fills in step 2 so the publisher
// doesn't need external hosting. Null when storage is not configured
// or the push failed — publisher pastes their own URL.
hosted_url: hostedUrl,
prometheus: upload + ClamAV scan + signed scan reports 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.
2026-10-05 22:41:55 +02:00
report,
});
}
/* ----------------------- helpers ----------------------- */
function respond(res, status, body) {
const json = JSON.stringify(body);
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(json),
'cache-control': 'no-store',
});
res.end(json);
}
function sanitize(name) {
// Keep the publisher's filename for display, but strip path separators and
// control characters — nothing we write to disk is derived from this; it is
// just metadata for the UI.
return String(name)
.replace(/[\\/\x00-\x1f]/g, '_')
.slice(0, 200);
}
async function safeUnlink(path) {
try { await unlink(path); } catch { /* already gone */ }
}