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