// DigitalOcean Spaces (S3-compatible) uploader — hand-rolled AWS SigV4 PUT so // large media (video) lives in object storage instead of the Coolify volume. // Zero-dependency (crypto only). FEATURE-FLAGGED: inert unless all of // DO_SPACES_KEY / DO_SPACES_SECRET / DO_SPACES_BUCKET / DO_SPACES_REGION are set. const crypto = require('crypto'); const https = require('https'); function enabled() { return !!(process.env.DO_SPACES_KEY && process.env.DO_SPACES_SECRET && process.env.DO_SPACES_BUCKET && process.env.DO_SPACES_REGION); } const sha256hex = b => crypto.createHash('sha256').update(b).digest('hex'); const hmac = (key, s) => crypto.createHmac('sha256', key).update(s).digest(); // PUT one object, public-read. Returns the public URL. Rejects on non-2xx. function put(key, body, contentType) { return new Promise((resolve, reject) => { if (!enabled()) return reject(new Error('spaces-disabled')); const region = process.env.DO_SPACES_REGION; const bucket = process.env.DO_SPACES_BUCKET; const host = bucket + '.' + region + '.digitaloceanspaces.com'; const path = '/' + key.replace(/^\/+/, ''); const now = new Date(); const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''); // YYYYMMDDTHHMMSSZ const dateStamp = amzDate.slice(0, 8); const payloadHash = sha256hex(body); const signed = 'content-type;host;x-amz-acl;x-amz-content-sha256;x-amz-date'; const canonicalHeaders = 'content-type:' + contentType + '\n' + 'host:' + host + '\n' + 'x-amz-acl:public-read\n' + 'x-amz-content-sha256:' + payloadHash + '\n' + 'x-amz-date:' + amzDate + '\n'; const canonicalReq = ['PUT', path, '', canonicalHeaders, signed, payloadHash].join('\n'); const scope = dateStamp + '/' + region + '/s3/aws4_request'; const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256hex(canonicalReq)].join('\n'); const kDate = hmac('AWS4' + process.env.DO_SPACES_SECRET, dateStamp); const kRegion = hmac(kDate, region); const kService = hmac(kRegion, 's3'); const kSigning = hmac(kService, 'aws4_request'); const signature = crypto.createHmac('sha256', kSigning).update(toSign).digest('hex'); const auth = 'AWS4-HMAC-SHA256 Credential=' + process.env.DO_SPACES_KEY + '/' + scope + ', SignedHeaders=' + signed + ', Signature=' + signature; const req = https.request({ host, path, method: 'PUT', timeout: 30000, headers: { 'Content-Type': contentType, 'Content-Length': body.length, 'x-amz-acl': 'public-read', 'x-amz-content-sha256': payloadHash, 'x-amz-date': amzDate, Authorization: auth } }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { const base = process.env.DO_SPACES_CDN || ('https://' + host); resolve(base.replace(/\/$/, '') + path); } else reject(new Error('spaces ' + res.statusCode + ': ' + d.slice(0, 200))); }); }); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('spaces timeout'))); req.end(body); }); } module.exports = { enabled, put };