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.
252 lines
8 KiB
JavaScript
252 lines
8 KiB
JavaScript
/**
|
|
* 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';
|
|
|
|
const UPLOAD_DIR = process.env.PROMETHEUS_UPLOAD_DIR
|
|
|| resolve(dirname(fileURLToPath(import.meta.url)), '..', 'uploads');
|
|
|
|
// 200 MiB — fits every Android APK and most desktop installers; publishers
|
|
// of larger binaries self-host and submit a manifest with the external URL +
|
|
// SHA. The service's cap and Caddy's `request_body { max_size 200MB }` 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.
|
|
// Change via env rather than code so an operator can tighten per-instance.
|
|
const MAX_FILE_BYTES = Number(process.env.PROMETHEUS_MAX_UPLOAD_BYTES)
|
|
|| 200 * 1024 * 1024;
|
|
|
|
/**
|
|
* 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 });
|
|
|
|
const now = new Date().toISOString();
|
|
const upload = {
|
|
hash: sha256,
|
|
size: byteCount,
|
|
name: originalName,
|
|
mime: mimeType,
|
|
received_at: now,
|
|
};
|
|
|
|
let stored = false;
|
|
let finalPath = null;
|
|
|
|
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`);
|
|
}
|
|
} 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 } : {}),
|
|
},
|
|
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 } : {}),
|
|
},
|
|
stored,
|
|
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 */ }
|
|
}
|