/** * Sia-backed storage for scan-clean uploads. * * When the service is configured with S3 credentials pointing at the * sidecar s3d, every clean upload is pushed to Sia and the public download * URL — hosted by Prometheus itself on `/dl/` — is handed back to * the submit UI. The publisher no longer has to self-host. * * Design notes * ------------ * - Writes go to `s3://${bucket}/${prefix}.bin` by content hash. * The bucket is created on first use if missing, so a fresh s3d data * folder bootstraps itself. * - The returned URL is a Prometheus-hosted URL (`${publicBase}/dl/`), * NOT the raw s3d endpoint. The service's `/dl` handler streams from s3d * on the back end; the s3d itself stays off the public network. * - If storage is not configured, isConfigured() returns false and the * service falls back to its old behavior (publisher provides their own * download_url). That keeps dev loops working without creds. * * Env * --- * PROMETHEUS_S3_ENDPOINT write-side endpoint, e.g. http://s3d:8000 * PROMETHEUS_S3_ACCESS_KEY access key minted by `s3d keys create ` * PROMETHEUS_S3_SECRET matching secret * PROMETHEUS_S3_BUCKET default: prometheus * PROMETHEUS_S3_PREFIX default: apps/ * PROMETHEUS_PUBLIC_BASE default: https://prometheus.x — origin for * the public /dl URL readers follow */ import { createReadStream } from 'node:fs'; import { stat } from 'node:fs/promises'; import { S3Client, S3Error } from './s3.js'; const CFG = { endpoint: process.env.PROMETHEUS_S3_ENDPOINT || '', accessKey: process.env.PROMETHEUS_S3_ACCESS_KEY || '', secret: process.env.PROMETHEUS_S3_SECRET || '', bucket: process.env.PROMETHEUS_S3_BUCKET || 'prometheus', prefix: normalizePrefix(process.env.PROMETHEUS_S3_PREFIX || 'apps/'), publicBase: (process.env.PROMETHEUS_PUBLIC_BASE || 'https://prometheus.x').replace(/\/+$/, ''), }; let client = null; let bucketReady = false; export function isConfigured() { return Boolean(CFG.endpoint && CFG.accessKey && CFG.secret); } export function describe() { if (!isConfigured()) return { configured: false }; return { configured: true, endpoint: CFG.endpoint, bucket: CFG.bucket, prefix: CFG.prefix, publicBase: CFG.publicBase, }; } function getClient() { if (!isConfigured()) return null; if (!client) { client = new S3Client({ endpoint: CFG.endpoint, accessKeyId: CFG.accessKey, secretKey: CFG.secret, }); } return client; } /** Make sure the bucket exists; called once lazily, idempotent on error. */ async function ensureBucket(c) { if (bucketReady) return; try { await c.createBucket(CFG.bucket); } catch (err) { // BucketAlreadyOwnedByYou / BucketAlreadyExists → done. Anything else // is a real failure the caller should see. if (err instanceof S3Error && /already/i.test(err.code || '')) { // noop } else if (err?.status === 409) { // noop } else { throw err; } } bucketReady = true; } /** * Upload a scan-clean file to Sia and return the public URL readers can fetch. * * Idempotent: if an object with the same key already exists (same sha256 → * same bytes), the PUT overwrites with identical content and the URL is the * same. The returned URL is stable across reruns. * * @param {object} args * @param {string} args.localPath — absolute path to the file on disk * @param {string} args.sha256 — content hash, used as the object key * @param {string} args.name — original filename (for display suffix, unused server-side) * @param {string} args.mime — content-type for the stored object * @returns {Promise<{ downloadUrl: string, key: string, bytes: number }>} */ export async function uploadScannedFile({ localPath, sha256, name, mime }) { const c = getClient(); if (!c) throw new Error('storage not configured — set PROMETHEUS_S3_*'); await ensureBucket(c); const { size } = await stat(localPath); const key = `${CFG.prefix}${sha256}.bin`; const body = createReadStream(localPath); const contentType = mime || 'application/octet-stream'; // s3d wants a Content-Length on streaming PUTs; chunked encoding does not // round-trip cleanly for large bodies in its current gateway. await c.putObject(CFG.bucket, key, body, { contentType, contentLength: size, }); return { downloadUrl: publicUrlFor(sha256), key, bytes: size }; } /** * Open a read stream from s3d for a given sha256. Returns the raw HTTP * response so callers can stream it straight back to the browser and * forward content-type / content-length / etag / accept-ranges. Supports * Range requests via the optional `range` header. */ export async function getObjectStream(sha256, { range } = {}) { const c = getClient(); if (!c) throw new Error('storage not configured'); const key = `${CFG.prefix}${sha256}.bin`; return c.getObject(CFG.bucket, key, range ? { range } : {}); } export function publicUrlFor(sha256) { return `${CFG.publicBase}/dl/${sha256}`; } function normalizePrefix(p) { const t = String(p || '').replace(/^\/+/, ''); return t.endsWith('/') || t === '' ? t : t + '/'; }