/** * Sia-backed storage for scan-clean uploads. * * When the service is configured with S3 credentials pointing at the Silent * Mode hosted s3d (s3.silentmode.st), every clean upload also goes to Sia and * the public download URL is handed back to the submit UI. The publisher no * longer has to self-host — their binary gets a stable https URL right out * of step 1. * * Design notes * ------------ * - Writes go to `s3://${bucket}/${prefix}.bin` by content hash. * Collisions on hash are a no-op: the same bytes always land at the same * key, so re-uploads do not duplicate storage. * - Downloads are served through the s3 gateway's public URL * (`${publicBase}/${bucket}/${prefix}.bin`). The bns bucket on * s3.silentmode.st is already public-read so site mirrors and Pithos * share-links work from any client — nothing extra to configure here. * - 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. * - Content-Type is passed through so the gateway serves apk/exe with the * right MIME, which matters for mobile download prompts. * * Env * --- * PROMETHEUS_S3_ENDPOINT e.g. https://s3.silentmode.st * PROMETHEUS_S3_ACCESS_KEY access key for a sia-storage user * PROMETHEUS_S3_SECRET matching secret * PROMETHEUS_S3_BUCKET default: bns * PROMETHEUS_S3_PREFIX default: prometheus-storage/ * PROMETHEUS_PUBLIC_BASE default: same as PROMETHEUS_S3_ENDPOINT */ import { createReadStream } from 'node:fs'; import { stat } from 'node:fs/promises'; import { S3Client } 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 || 'bns', prefix: normalizePrefix(process.env.PROMETHEUS_S3_PREFIX || 'prometheus-storage/'), publicBase: (process.env.PROMETHEUS_PUBLIC_BASE || process.env.PROMETHEUS_S3_ENDPOINT || '').replace(/\/+$/, ''), }; let client = null; 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; } /** * 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, so a listing that already * references it stays valid. * * @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 the display suffix) * @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_*'); const { size } = await stat(localPath); const key = `${CFG.prefix}${sha256}.bin`; const body = createReadStream(localPath); const contentType = mime || 'application/octet-stream'; // The s3d upstream wants Content-Length for streaming PUTs; without it // s3d falls back to transfer-encoding: chunked which Sia's gateway does // not pass through cleanly on large bodies. Pithos has the same gotcha. await c.putObject(CFG.bucket, key, body, { contentType, contentLength: size, }); // Content-Disposition is a client decision, not something we persist on // the object; the UI can add it to the download link if needed. We just // hand back the raw public URL — stable, cacheable, hash-addressed. const downloadUrl = publicUrlFor(key); return { downloadUrl, key, bytes: size }; } export function publicUrlFor(key) { if (!CFG.publicBase) return null; // Path-style URL: the bucket is a path segment, matching how s3d exposes // the hosted gateway. (Virtual-hosted URLs would need per-bucket DNS, // which the public gateway does not set up.) return `${CFG.publicBase}/${encodeURIComponent(CFG.bucket)}/${encodeURI(key)}`; } function normalizePrefix(p) { const t = String(p || '').replace(/^\/+/, ''); return t.endsWith('/') || t === '' ? t : t + '/'; }