128 lines
4.9 KiB
JavaScript
128 lines
4.9 KiB
JavaScript
|
|
/**
|
||
|
|
* 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}<sha256>.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}<sha256>.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 + '/';
|
||
|
|
}
|