2026-10-23 21:33:14 +03:00
|
|
|
/**
|
|
|
|
|
* Sia-backed storage for scan-clean uploads.
|
|
|
|
|
*
|
2026-10-24 01:08:24 +03:00
|
|
|
* 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/<sha256>` — is handed back to
|
|
|
|
|
* the submit UI. The publisher no longer has to self-host.
|
2026-10-23 21:33:14 +03:00
|
|
|
*
|
|
|
|
|
* Design notes
|
|
|
|
|
* ------------
|
|
|
|
|
* - Writes go to `s3://${bucket}/${prefix}<sha256>.bin` by content hash.
|
2026-10-24 01:08:24 +03:00
|
|
|
* 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/<sha256>`),
|
|
|
|
|
* 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.
|
2026-10-23 21:33:14 +03:00
|
|
|
* - 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
|
|
|
|
|
* ---
|
2026-10-24 01:08:24 +03:00
|
|
|
* PROMETHEUS_S3_ENDPOINT write-side endpoint, e.g. http://s3d:8000
|
|
|
|
|
* PROMETHEUS_S3_ACCESS_KEY access key minted by `s3d keys create <user>`
|
2026-10-23 21:33:14 +03:00
|
|
|
* PROMETHEUS_S3_SECRET matching secret
|
2026-10-24 01:08:24 +03:00
|
|
|
* 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
|
2026-10-23 21:33:14 +03:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { createReadStream } from 'node:fs';
|
|
|
|
|
import { stat } from 'node:fs/promises';
|
2026-10-24 01:08:24 +03:00
|
|
|
import { S3Client, S3Error } from './s3.js';
|
2026-10-23 21:33:14 +03:00
|
|
|
|
|
|
|
|
const CFG = {
|
|
|
|
|
endpoint: process.env.PROMETHEUS_S3_ENDPOINT || '',
|
|
|
|
|
accessKey: process.env.PROMETHEUS_S3_ACCESS_KEY || '',
|
|
|
|
|
secret: process.env.PROMETHEUS_S3_SECRET || '',
|
2026-10-24 01:08:24 +03:00
|
|
|
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(/\/+$/, ''),
|
2026-10-23 21:33:14 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let client = null;
|
2026-10-24 01:08:24 +03:00
|
|
|
let bucketReady = false;
|
2026-10-23 21:33:14 +03:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 01:08:24 +03:00
|
|
|
/** 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-23 21:33:14 +03:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-10-24 01:08:24 +03:00
|
|
|
* same. The returned URL is stable across reruns.
|
2026-10-23 21:33:14 +03:00
|
|
|
*
|
|
|
|
|
* @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
|
2026-10-24 01:08:24 +03:00
|
|
|
* @param {string} args.name — original filename (for display suffix, unused server-side)
|
2026-10-23 21:33:14 +03:00
|
|
|
* @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_*');
|
|
|
|
|
|
2026-10-24 01:08:24 +03:00
|
|
|
await ensureBucket(c);
|
|
|
|
|
|
2026-10-23 21:33:14 +03:00
|
|
|
const { size } = await stat(localPath);
|
|
|
|
|
const key = `${CFG.prefix}${sha256}.bin`;
|
|
|
|
|
const body = createReadStream(localPath);
|
|
|
|
|
const contentType = mime || 'application/octet-stream';
|
|
|
|
|
|
2026-10-24 01:08:24 +03:00
|
|
|
// s3d wants a Content-Length on streaming PUTs; chunked encoding does not
|
|
|
|
|
// round-trip cleanly for large bodies in its current gateway.
|
2026-10-23 21:33:14 +03:00
|
|
|
await c.putObject(CFG.bucket, key, body, {
|
|
|
|
|
contentType,
|
|
|
|
|
contentLength: size,
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-24 01:08:24 +03:00
|
|
|
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 } : {});
|
2026-10-23 21:33:14 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 01:08:24 +03:00
|
|
|
export function publicUrlFor(sha256) {
|
|
|
|
|
return `${CFG.publicBase}/dl/${sha256}`;
|
2026-10-23 21:33:14 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizePrefix(p) {
|
|
|
|
|
const t = String(p || '').replace(/^\/+/, '');
|
|
|
|
|
return t.endsWith('/') || t === '' ? t : t + '/';
|
|
|
|
|
}
|