prometheus/service/downloads.js
Local Dev d25ed36e14 prometheus: s3d sidecar + /dl/<sha256> for Sia-backed hosting
Publishers no longer need external hosting at all. The service runs its
own s3d (SiaFoundation gateway) as a Docker sidecar on the internal
compose network; scan-clean uploads land there via PUT; readers fetch
through /dl/<sha256> on prometheus.x which proxies back out from s3d.

- deploy/docker-compose.yml: s3d service added. Image is
  ghcr.io/siafoundation/s3d:latest, data folder bind-mounted at
  ./s3d-data/ so the operator can seed it from a Pithos connection
  export. Only prometheus-internal network — never public.
- deploy/setup-s3d.sh: one-time import of the Pithos connection
  tarball, writes a fresh s3d.yml with a random adminPassword, mints
  an S3 access key for the Prometheus service, appends
  PROMETHEUS_S3_* lines to .env. Idempotent on re-run.
- service/storage.js: endpoint now defaults to the internal s3d
  (http://s3d:8000); publicBase defaults to https://prometheus.x so
  download URLs flow through the service's own /dl handler. Bucket is
  auto-created on first use so a fresh data folder bootstraps.
  Added getObjectStream() for the read side.
- service/downloads.js: GET/HEAD /dl/<sha256> — forwards Range,
  content-type and content-length from s3d, drops s3d-specific
  headers (x-amz-*), sets immutable long-cache. Hash is validated
  against ^[0-9a-f]{64}$ before touching upstream.
- service/server.js: route table wires /dl/* to the handler above.
2026-10-24 01:08:24 +03:00

90 lines
3.2 KiB
JavaScript

/**
* GET/HEAD /dl/<sha256> — 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/<sha>/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);
}