prometheus: push scan-clean uploads to Sia, auto-fill the download URL
Publishers no longer need to self-host. When the service is configured
with PROMETHEUS_S3_* credentials pointing at the hosted s3d
(s3.silentmode.st), every clean upload is pushed to
s3://${bucket}/${prefix}<sha256>.bin and the public URL comes back on
/api/upload as `hosted_url`. Step 2 of the submit form pre-fills the
Download URL input with that URL — publishers can overwrite if they
prefer their own hosting; by default they ship nothing.
- service/s3.js: SigV4 S3 client vendored from Pithos/core/s3.js so bug
fixes port by file-copy. Path-style, UNSIGNED-PAYLOAD streaming PUT,
Content-Length required (s3d chunked encoding does not round-trip
cleanly for large bodies).
- service/storage.js: thin wrapper exposing isConfigured() and
uploadScannedFile(). Idempotent on sha256 — same bytes → same key.
Failure is non-fatal; the publisher can still paste their own URL.
- service/uploads.js: after rename-to-final, push to Sia if configured.
Added `hosted_url` to the response (null when storage off).
- service/pages.js: step 2 pre-fills download_url when hosted_url is
present; hint text explains the override path.
- docker-compose.yml: PROMETHEUS_S3_* env plumbed through, with sane
defaults for bucket/prefix (bns + prometheus-storage/).
This commit is contained in:
parent
4cde8728fa
commit
09bb42f7a6
5 changed files with 437 additions and 1 deletions
|
|
@ -32,6 +32,16 @@ services:
|
|||
PROMETHEUS_PAYMENT_ADDRESS: ${PROMETHEUS_PAYMENT_ADDRESS:-}
|
||||
PROMETHEUS_LISTING_FEE_USD: ${PROMETHEUS_LISTING_FEE_USD:-1}
|
||||
BNS_GATEWAY: ${BNS_GATEWAY:-}
|
||||
# Sia-backed storage: when set, scan-clean uploads are pushed to the
|
||||
# hosted s3d and the submit UI pre-fills the download URL. All four
|
||||
# must be set for storage to turn on; otherwise publishers host
|
||||
# their own binaries. See service/storage.js for defaults.
|
||||
PROMETHEUS_S3_ENDPOINT: ${PROMETHEUS_S3_ENDPOINT:-}
|
||||
PROMETHEUS_S3_ACCESS_KEY: ${PROMETHEUS_S3_ACCESS_KEY:-}
|
||||
PROMETHEUS_S3_SECRET: ${PROMETHEUS_S3_SECRET:-}
|
||||
PROMETHEUS_S3_BUCKET: ${PROMETHEUS_S3_BUCKET:-bns}
|
||||
PROMETHEUS_S3_PREFIX: ${PROMETHEUS_S3_PREFIX:-prometheus-storage/}
|
||||
PROMETHEUS_PUBLIC_BASE: ${PROMETHEUS_PUBLIC_BASE:-}
|
||||
volumes:
|
||||
- data:/data
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -506,7 +506,7 @@ export function submit() {
|
|||
<label>Download URL
|
||||
<input name="download_url" required type="url" placeholder="https://example.com/myapp-1.0.0.exe"
|
||||
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-family:ui-monospace,monospace;margin-top:.3rem">
|
||||
<small style="color:var(--dim)">Where readers' clients fetch the binary. The SHA-256 (from step 1) must match what the URL serves.</small>
|
||||
<small style="color:var(--dim)" id="download-url-hint">Where readers' clients fetch the binary. The SHA-256 (from step 1) must match what the URL serves.</small>
|
||||
</label>
|
||||
|
||||
<label>Homepage (optional)
|
||||
|
|
@ -730,6 +730,18 @@ export function submit() {
|
|||
return;
|
||||
}
|
||||
W.upload = json;
|
||||
// If the service hosted the file for us (Sia-backed storage is wired
|
||||
// up), pre-fill the download_url input in step 2 so the publisher
|
||||
// doesn't need external hosting. They can still override by typing
|
||||
// their own URL — pre-fill is a convenience, not a lock.
|
||||
if (json.hosted_url) {
|
||||
const dlInput = document.querySelector('input[name="download_url"]');
|
||||
if (dlInput && !dlInput.value) dlInput.value = json.hosted_url;
|
||||
const hint = $('download-url-hint');
|
||||
if (hint) hint.innerHTML = 'Pre-filled with the URL we hosted this file at. ' +
|
||||
'Overwrite if you prefer to host it elsewhere — the SHA-256 (from step 1) ' +
|
||||
'must match whatever the URL serves.';
|
||||
}
|
||||
$('u-name').textContent = json.upload.name;
|
||||
$('u-size').textContent = fmtBytes(json.upload.size);
|
||||
$('u-hash').textContent = json.upload.hash.slice(0,8) + '…' + json.upload.hash.slice(-6);
|
||||
|
|
|
|||
259
service/s3.js
Normal file
259
service/s3.js
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
// A small S3 client for s3d: AWS SigV4 (path-style, us-east-1, UNSIGNED-PAYLOAD
|
||||
// for bodies so uploads stream straight through) plus the handful of bucket
|
||||
// and object calls the console needs.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
export const REGION = 'us-east-1';
|
||||
export const SERVICE = 's3';
|
||||
|
||||
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
||||
const hmac = (k, s) => crypto.createHmac('sha256', k).update(s).digest();
|
||||
|
||||
// RFC 3986 encoding as SigV4 wants it; '/' kept for object keys in paths.
|
||||
export function uriEncode(str, keepSlash) {
|
||||
const out = encodeURIComponent(str)
|
||||
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
||||
// (A "never matches" regex like /$^/ is not one: it matches the empty
|
||||
// string, which turned every empty query value into "/".)
|
||||
return keepSlash ? out.replace(/%2F/g, '/') : out;
|
||||
}
|
||||
|
||||
export function canonicalQuery(query) {
|
||||
return Object.keys(query).sort()
|
||||
.map((k) => `${uriEncode(k)}=${uriEncode(String(query[k] ?? ''))}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
export function amzDate(d = new Date()) {
|
||||
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
}
|
||||
|
||||
export function signingKey(secret, date) {
|
||||
return hmac(hmac(hmac(hmac('AWS4' + secret, date), REGION), SERVICE), 'aws4_request');
|
||||
}
|
||||
|
||||
export function objectPath(bucket, key) {
|
||||
return '/' + uriEncode(bucket) + (key != null ? '/' + uriEncode(key, true) : '');
|
||||
}
|
||||
|
||||
export class S3Client {
|
||||
// endpoint: 'http://127.0.0.1:8000'
|
||||
constructor({ endpoint, accessKeyId, secretKey }) {
|
||||
this.endpoint = new URL(endpoint);
|
||||
this.accessKeyId = accessKeyId;
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
sign(method, path, query, headers, { now = amzDate(), payloadHash = 'UNSIGNED-PAYLOAD' } = {}) {
|
||||
const date = now.slice(0, 8);
|
||||
const h = { ...headers, host: this.endpoint.host, 'x-amz-date': now, 'x-amz-content-sha256': payloadHash };
|
||||
const names = Object.keys(h).map((k) => k.toLowerCase()).sort();
|
||||
const lower = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), String(v).trim()]));
|
||||
const signed = names.join(';');
|
||||
const canonical = [method, path, canonicalQuery(query), names.map((n) => `${n}:${lower[n]}\n`).join(''), signed, payloadHash].join('\n');
|
||||
const scope = `${date}/${REGION}/${SERVICE}/aws4_request`;
|
||||
const toSign = ['AWS4-HMAC-SHA256', now, scope, sha256(canonical)].join('\n');
|
||||
const sig = crypto.createHmac('sha256', signingKey(this.secretKey, date)).update(toSign).digest('hex');
|
||||
lower.authorization = `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${sig}`;
|
||||
delete lower.host;
|
||||
return lower;
|
||||
}
|
||||
|
||||
// Presigned GET for share links. publicEndpoint lets the link name the
|
||||
// address readers will use, which may differ from the loopback the GUI uses.
|
||||
presign(bucket, key, expiresSec = 3600, publicEndpoint, now = amzDate()) {
|
||||
const base = publicEndpoint ? new URL(publicEndpoint) : this.endpoint;
|
||||
const date = now.slice(0, 8);
|
||||
const scope = `${date}/${REGION}/${SERVICE}/aws4_request`;
|
||||
const path = objectPath(bucket, key);
|
||||
const query = {
|
||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||
'X-Amz-Credential': `${this.accessKeyId}/${scope}`,
|
||||
'X-Amz-Date': now,
|
||||
'X-Amz-Expires': String(Math.min(Math.max(1, expiresSec | 0), 604800)),
|
||||
'X-Amz-SignedHeaders': 'host',
|
||||
};
|
||||
const canonical = ['GET', path, canonicalQuery(query), `host:${base.host}\n`, 'host', 'UNSIGNED-PAYLOAD'].join('\n');
|
||||
const toSign = ['AWS4-HMAC-SHA256', now, scope, sha256(canonical)].join('\n');
|
||||
query['X-Amz-Signature'] = crypto.createHmac('sha256', signingKey(this.secretKey, date)).update(toSign).digest('hex');
|
||||
return `${base.origin}${path}?${canonicalQuery(query)}`;
|
||||
}
|
||||
|
||||
// Low-level request. body: Buffer | string | Readable | undefined.
|
||||
// Resolves the raw http.IncomingMessage so callers can stream it.
|
||||
request(method, path, { query = {}, headers = {}, body, payloadHash } = {}) {
|
||||
const signedHeaders = this.sign(method, path, query, headers, payloadHash ? { payloadHash } : undefined);
|
||||
const qs = canonicalQuery(query);
|
||||
// The hosted gateway is https; a local s3d is plain http on loopback.
|
||||
const transport = this.endpoint.protocol === 'https:' ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = transport.request({
|
||||
protocol: this.endpoint.protocol,
|
||||
hostname: this.endpoint.hostname,
|
||||
port: this.endpoint.port,
|
||||
method,
|
||||
path: path + (qs ? '?' + qs : ''),
|
||||
headers: signedHeaders,
|
||||
}, resolve);
|
||||
req.on('error', reject);
|
||||
if (body instanceof Readable) body.pipe(req);
|
||||
else req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
async call(method, path, opts) {
|
||||
const res = await this.request(method, path, opts);
|
||||
const text = await readBody(res);
|
||||
if (res.statusCode >= 300) throw s3Error(res.statusCode, text);
|
||||
return { status: res.statusCode, headers: res.headers, text };
|
||||
}
|
||||
|
||||
async listBuckets() {
|
||||
const { text } = await this.call('GET', '/');
|
||||
return blocks(text, 'Bucket').map((b) => ({ name: tag(b, 'Name'), created: tag(b, 'CreationDate') }));
|
||||
}
|
||||
|
||||
createBucket(bucket) { return this.call('PUT', objectPath(bucket)); }
|
||||
deleteBucket(bucket) { return this.call('DELETE', objectPath(bucket)); }
|
||||
|
||||
async listObjects(bucket, { prefix = '', delimiter = '/', token, max = 1000 } = {}) {
|
||||
const query = { 'list-type': '2', prefix, delimiter, 'max-keys': String(max) };
|
||||
if (token) query['continuation-token'] = token;
|
||||
const { text } = await this.call('GET', objectPath(bucket), { query });
|
||||
return {
|
||||
folders: blocks(text, 'CommonPrefixes').map((b) => tag(b, 'Prefix')),
|
||||
objects: blocks(text, 'Contents').map((b) => ({
|
||||
key: tag(b, 'Key'), size: Number(tag(b, 'Size')), modified: tag(b, 'LastModified'), etag: tag(b, 'ETag'),
|
||||
})),
|
||||
truncated: tag(text, 'IsTruncated') === 'true',
|
||||
nextToken: tag(text, 'NextContinuationToken'),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteObject(bucket, key) { return this.call('DELETE', objectPath(bucket, key)); }
|
||||
|
||||
// Server-side copy. s3d copies the entry in its own database: the copy
|
||||
// points at the same data on Sia, so nothing is uploaded again.
|
||||
copyObject(srcBucket, srcKey, dstBucket, dstKey) {
|
||||
return this.call('PUT', objectPath(dstBucket, dstKey), { headers: { 'x-amz-copy-source': objectPath(srcBucket, srcKey) } });
|
||||
}
|
||||
|
||||
// Every key under prefix (no folder grouping), all pages.
|
||||
async allKeys(bucket, prefix = '') {
|
||||
const keys = [];
|
||||
let token;
|
||||
do {
|
||||
const page = await this.listObjects(bucket, { prefix, delimiter: '', token });
|
||||
keys.push(...page.objects.map((o) => o.key));
|
||||
token = page.truncated ? page.nextToken : null;
|
||||
} while (token);
|
||||
return keys;
|
||||
}
|
||||
|
||||
// S3 has no rename: copy to the new name, then delete the old one.
|
||||
async renameObject(bucket, from, to, dstBucket = bucket) {
|
||||
await this.copyObject(bucket, from, dstBucket, to);
|
||||
await this.deleteObject(bucket, from);
|
||||
}
|
||||
|
||||
// Rename a folder (or move a whole drive's contents): every key under
|
||||
// `from` moves under `to`. Keys are listed first, so the loop never
|
||||
// walks into the copies it makes. Returns the number moved.
|
||||
async renamePrefix(bucket, from, to, dstBucket = bucket) {
|
||||
const keys = await this.allKeys(bucket, from);
|
||||
for (const k of keys) await this.renameObject(bucket, k, to + k.slice(from.length), dstBucket);
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
// Deletes every key under prefix. Returns the number deleted.
|
||||
async deletePrefix(bucket, prefix) {
|
||||
let n = 0;
|
||||
let token;
|
||||
do {
|
||||
const page = await this.listObjects(bucket, { prefix, delimiter: '', token });
|
||||
for (const o of page.objects) { await this.deleteObject(bucket, o.key); n++; }
|
||||
token = page.truncated ? page.nextToken : null;
|
||||
} while (token);
|
||||
return n;
|
||||
}
|
||||
|
||||
putObject(bucket, key, body, { contentType, contentLength } = {}) {
|
||||
const headers = {};
|
||||
if (contentType) headers['content-type'] = contentType;
|
||||
if (contentLength != null) headers['content-length'] = String(contentLength);
|
||||
return this.call('PUT', objectPath(bucket, key), { headers, body });
|
||||
}
|
||||
|
||||
getObject(bucket, key, { range } = {}) {
|
||||
return this.request('GET', objectPath(bucket, key), { headers: range ? { range } : {} });
|
||||
}
|
||||
|
||||
async getPolicy(bucket) {
|
||||
const res = await this.request('GET', objectPath(bucket), { query: { policy: '' } });
|
||||
const text = await readBody(res);
|
||||
if (res.statusCode === 404) return null;
|
||||
if (res.statusCode >= 300) throw s3Error(res.statusCode, text);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
putPolicy(bucket, policy) {
|
||||
const body = JSON.stringify(policy);
|
||||
return this.call('PUT', objectPath(bucket), { query: { policy: '' }, body, headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } });
|
||||
}
|
||||
|
||||
deletePolicy(bucket) { return this.call('DELETE', objectPath(bucket), { query: { policy: '' } }); }
|
||||
}
|
||||
|
||||
// The only policy shape s3d accepts: anonymous read of objects and listing.
|
||||
export function publicReadPolicy(bucket, { list = false } = {}) {
|
||||
const statements = [{
|
||||
Effect: 'Allow', Principal: '*', Action: ['s3:GetObject'], Resource: [`arn:aws:s3:::${bucket}/*`],
|
||||
}];
|
||||
if (list) statements.push({ Effect: 'Allow', Principal: '*', Action: ['s3:ListBucket'], Resource: [`arn:aws:s3:::${bucket}`] });
|
||||
return { Version: '2012-10-17', Statement: statements };
|
||||
}
|
||||
|
||||
export function readBody(res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
res.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export class S3Error extends Error {
|
||||
constructor(status, code, message) { super(message || code); this.status = status; this.code = code; }
|
||||
}
|
||||
|
||||
function s3Error(status, text) {
|
||||
return new S3Error(status, tag(text, 'Code') || `HTTP ${status}`, tag(text, 'Message') || text.slice(0, 200) || `HTTP ${status}`);
|
||||
}
|
||||
|
||||
// S3 responses are flat enough that tag extraction is all we need.
|
||||
function blocks(xml, name) {
|
||||
const re = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'g');
|
||||
const out = [];
|
||||
let m;
|
||||
while ((m = re.exec(xml))) out.push(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function tag(xml, name) {
|
||||
const m = xml.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
|
||||
return m ? unescapeXml(m[1]) : null;
|
||||
}
|
||||
|
||||
function unescapeXml(s) {
|
||||
return s.replace(/&(lt|gt|amp|quot|apos|#\d+|#x[0-9a-f]+);/gi, (_, e) => {
|
||||
switch (e.toLowerCase()) {
|
||||
case 'lt': return '<'; case 'gt': return '>'; case 'amp': return '&';
|
||||
case 'quot': return '"'; case 'apos': return "'";
|
||||
default: return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));
|
||||
}
|
||||
});
|
||||
}
|
||||
127
service/storage.js
Normal file
127
service/storage.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* 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 + '/';
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import { PassThrough } from 'node:stream';
|
|||
import Busboy from 'busboy';
|
||||
import { scanStream } from './clamd.js';
|
||||
import { signReport } from './signing.js';
|
||||
import { isConfigured as storageConfigured, uploadScannedFile } from './storage.js';
|
||||
|
||||
const UPLOAD_DIR = process.env.PROMETHEUS_UPLOAD_DIR
|
||||
|| resolve(dirname(fileURLToPath(import.meta.url)), '..', 'uploads');
|
||||
|
|
@ -162,6 +163,7 @@ export async function handleUpload(req, res) {
|
|||
|
||||
let stored = false;
|
||||
let finalPath = null;
|
||||
let hostedUrl = null;
|
||||
|
||||
if (scan.verdict === 'clean') {
|
||||
finalPath = resolve(UPLOAD_DIR, `${sha256}.bin`);
|
||||
|
|
@ -180,6 +182,27 @@ export async function handleUpload(req, res) {
|
|||
stored = true;
|
||||
finalPath = resolve(UPLOAD_DIR, `${sha256}.bin`);
|
||||
}
|
||||
|
||||
// Push to Sia-backed storage if the service is configured for it, so
|
||||
// the submit UI can skip the "where will you host this?" question. A
|
||||
// failure here is non-fatal — the local scan report still works and
|
||||
// the publisher can fall back to providing their own download_url.
|
||||
if (storageConfigured()) {
|
||||
try {
|
||||
const result = await uploadScannedFile({
|
||||
localPath: finalPath,
|
||||
sha256,
|
||||
name: originalName,
|
||||
mime: mimeType,
|
||||
});
|
||||
hostedUrl = result.downloadUrl;
|
||||
} catch (err) {
|
||||
// Keep going — the local copy stays on disk and the publisher can
|
||||
// paste their own URL in step 2. Logged for the operator.
|
||||
console.error('[prometheus] sia upload failed:',
|
||||
err?.message || String(err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Infected or error — the bytes do not stay on our disk.
|
||||
await safeUnlink(tempPath);
|
||||
|
|
@ -222,6 +245,11 @@ export async function handleUpload(req, res) {
|
|||
...(scan.virustotal ? { virustotal: scan.virustotal } : {}),
|
||||
},
|
||||
stored,
|
||||
// When Sia-backed storage is wired up and the push succeeded, this is
|
||||
// the public download URL the UI pre-fills in step 2 so the publisher
|
||||
// doesn't need external hosting. Null when storage is not configured
|
||||
// or the push failed — publisher pastes their own URL.
|
||||
hosted_url: hostedUrl,
|
||||
report,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue