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:
@@ -17,6 +17,7 @@ const accounts = require('./accounts');
|
||||
const ads = require('./ads');
|
||||
const mailer = require('./mailer');
|
||||
const messages = require('./messages');
|
||||
const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_SPACES_* set)
|
||||
let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ }
|
||||
const chatbot = require('./chatbot');
|
||||
|
||||
@@ -855,8 +856,16 @@ const server = http.createServer(async (req, res) => {
|
||||
(ct === 'video/webm' && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3));
|
||||
if (!magicOk) return json(res, 400, { error: 'That file does not look like a real ' + EXT[ct].toUpperCase() + '.' });
|
||||
const name = crypto.randomBytes(12).toString('hex') + '.' + EXT[ct];
|
||||
fs.writeFileSync(path.join(UPLOADS_DIR, name), buf);
|
||||
uploadCounts.set(key, (uploadCounts.get(key) || 0) + 1);
|
||||
// video goes to DO Spaces when configured (keeps big files off the volume);
|
||||
// images stay local. Falls back to the volume if Spaces isn't set or errors.
|
||||
if (isVideo && spaces.enabled()) {
|
||||
try {
|
||||
const url = await spaces.put('uploads/' + name, buf, ct);
|
||||
return json(res, 200, { url, type: 'video' });
|
||||
} catch (e) { console.error('spaces put', e.message); /* fall through to volume */ }
|
||||
}
|
||||
fs.writeFileSync(path.join(UPLOADS_DIR, name), buf);
|
||||
return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' });
|
||||
}
|
||||
m = /^\/api\/my\/inbox\/(\d+)\/visit$/.exec(p);
|
||||
|
||||
@@ -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