010e8d7ffc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
60 lines
3.1 KiB
JavaScript
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 };
|