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.
90 lines
3.2 KiB
JavaScript
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);
|
|
}
|