Publishers no longer need to self-host. When the service is configured
with PROMETHEUS_S3_* credentials pointing at the hosted s3d
(s3.silentmode.st), every clean upload is pushed to
s3://${bucket}/${prefix}<sha256>.bin and the public URL comes back on
/api/upload as `hosted_url`. Step 2 of the submit form pre-fills the
Download URL input with that URL — publishers can overwrite if they
prefer their own hosting; by default they ship nothing.
- service/s3.js: SigV4 S3 client vendored from Pithos/core/s3.js so bug
fixes port by file-copy. Path-style, UNSIGNED-PAYLOAD streaming PUT,
Content-Length required (s3d chunked encoding does not round-trip
cleanly for large bodies).
- service/storage.js: thin wrapper exposing isConfigured() and
uploadScannedFile(). Idempotent on sha256 — same bytes → same key.
Failure is non-fatal; the publisher can still paste their own URL.
- service/uploads.js: after rename-to-final, push to Sia if configured.
Added `hosted_url` to the response (null when storage off).
- service/pages.js: step 2 pre-fills download_url when hosted_url is
present; hint text explains the override path.
- docker-compose.yml: PROMETHEUS_S3_* env plumbed through, with sane
defaults for bucket/prefix (bns + prometheus-storage/).
280 lines
9.2 KiB
JavaScript
280 lines
9.2 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';
|
|
import { isConfigured as storageConfigured, uploadScannedFile } from './storage.js';
|
|
|
|
const UPLOAD_DIR = process.env.PROMETHEUS_UPLOAD_DIR
|
|
|| resolve(dirname(fileURLToPath(import.meta.url)), '..', 'uploads');
|
|
|
|
// 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.
|
|
// Change via env rather than code so an operator can tighten per-instance.
|
|
const MAX_FILE_BYTES = Number(process.env.PROMETHEUS_MAX_UPLOAD_BYTES)
|
|
|| 2 * 1024 * 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;
|
|
let hostedUrl = 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`);
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
}
|
|
} 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,
|
|
// 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,
|
|
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 */ }
|
|
}
|