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>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user