From 537e7352d406f668ae966e3898b94d3024e6edc7 Mon Sep 17 00:00:00 2001 From: martbost Date: Sun, 6 Sep 2026 09:24:48 -0500 Subject: [PATCH] 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 --- server.js | 11 ++++++++++- spaces.js | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 spaces.js diff --git a/server.js b/server.js index 9b873b0..c1a2150 100644 --- a/server.js +++ b/server.js @@ -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); diff --git a/spaces.js b/spaces.js new file mode 100644 index 0000000..9a46f46 --- /dev/null +++ b/spaces.js @@ -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 };