/** * GET/HEAD /dl/ — stream a stored upload back to the browser. * * Does not re-scan on the way out; the file lands here only after passing * through the clamd + VirusTotal gate at upload time. A publisher's * manifest signature covers the sha256, so the hash in the URL is also the * integrity check — a reader whose client verifies the hash sees any * substitution immediately. * * Range requests are forwarded verbatim to s3d so Theseus / Chrome / the * Android download manager can resume on a flaky connection. The handler * never touches the bytes — it is a thin proxy that forwards status, * content-type, content-length, content-range, accept-ranges and etag. * * The sha256 is the only path segment we accept; anything else 404s. We * never let the URL carry the original filename because the uploader * chose it and a browser-saved name should be stable across republishes * of the same bytes. */ import { getObjectStream, isConfigured } from './storage.js'; const SHA256_RE = /^[0-9a-f]{64}$/; export async function handleDownload(req, res, rest) { if (!isConfigured()) { return respond(res, 503, 'storage not configured'); } if (req.method !== 'GET' && req.method !== 'HEAD') { return respond(res, 405, 'method not allowed'); } // Strip anything after the hash (e.g., `/dl//MyApp.exe` for a // nicer saved filename) — we tolerate the suffix but don't use it. const hash = String(rest || '').split('/')[0].split('?')[0].toLowerCase(); if (!SHA256_RE.test(hash)) return respond(res, 404, 'not found'); const range = req.headers['range']; let upstream; try { upstream = await getObjectStream(hash, range ? { range } : {}); } catch (err) { return respond(res, 502, 'upstream unreachable: ' + String(err?.message || err)); } if (upstream.statusCode === 404) { // Drain so we don't leak the socket, then 404. upstream.resume(); return respond(res, 404, 'not found'); } if (upstream.statusCode >= 400) { upstream.resume(); return respond(res, 502, `upstream HTTP ${upstream.statusCode}`); } // Forward the storage headers the browser cares about. Deliberately // omit anything s3d-specific (x-amz-*, server) to keep the response // looking like a Prometheus one. const headers = {}; const forward = ['content-type', 'content-length', 'content-range', 'accept-ranges', 'etag', 'last-modified']; for (const h of forward) { if (upstream.headers[h]) headers[h] = upstream.headers[h]; } // Objects are content-addressed by sha256 — safe to cache forever. headers['cache-control'] = 'public, max-age=31536000, immutable'; // Minimal defaults when upstream didn't say. if (!headers['content-type']) headers['content-type'] = 'application/octet-stream'; res.writeHead(upstream.statusCode, headers); if (req.method === 'HEAD') { upstream.resume(); return res.end(); } upstream.pipe(res); // If the client disconnects, don't let the upstream read linger. req.on('close', () => upstream.destroy?.()); } function respond(res, status, text) { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'content-length': Buffer.byteLength(text), 'cache-control': 'no-store', }); res.end(text); }