Files
instantadpay/spaces.js
T
martbost 537e7352d4 DO Spaces adapter (feature-flagged): route video uploads to object storage off the volume
- spaces.js: zero-dep SigV4 PUT to DO Spaces, public-read; inert unless DO_SPACES_* env is set
- /api/my/upload sends video to Spaces when configured, falls back to volume otherwise
- Images stay local; Spaces URLs are https so they pass the video/media validators + CSP

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-06 09:24:48 -05:00

60 lines
3.1 KiB
JavaScript

// 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 };