uploads: support up to 1 GB attachments, streamed to disk (was 25 MB, buffered in memory) (batch145)

- Server streams the upload body straight to /data/uploads (a .part temp file, atomic rename on
  success), backpressure-aware, so a 1 GB file never buffers in RAM. MAX_UPLOAD_MB env (default
  1024 = 1 GB) controls the cap; error message reflects it.
- Client size guard raised 25 MB -> 1 GB.
- docker-compose documents MAX_UPLOAD_MB and the required Nginx Proxy Manager client_max_body_size.
NOTE: the actual bottleneck for the user's 9.7 MB reject is almost certainly NPM's client_max_body_size
(nginx default 1 MB) — that must be raised in the NPM admin; the app change alone can't lift it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 16:06:19 +05:30
parent 90d49d29d0
commit 5960efb612
3 changed files with 36 additions and 14 deletions
+5
View File
@@ -17,6 +17,11 @@ services:
# Chat uploads / recordings / transcripts on the persistent volume too, so they survive image # Chat uploads / recordings / transcripts on the persistent volume too, so they survive image
# rebuilds (otherwise old shared images 404 as "broken image" after every deploy). # rebuilds (otherwise old shared images 404 as "broken image" after every deploy).
- UPLOADS_DIR=/data/uploads - UPLOADS_DIR=/data/uploads
# Max chat-attachment size in MB (default 1024 = 1 GB). The app streams uploads to /data/uploads, so
# large files don't buffer in memory. IMPORTANT: also set Nginx Proxy Manager's client_max_body_size
# for remote.bizgaze.com to at least this (Advanced tab: `client_max_body_size 1024m;`) or the proxy
# rejects big uploads before they reach the app.
- MAX_UPLOAD_MB=1024
- REC_DIR=/data/recordings - REC_DIR=/data/recordings
- TRANS_DIR=/data/transcripts - TRANS_DIR=/data/transcripts
# Secrets (TURN credentials, SSO_SECRET, BIZGAZE_WEBHOOK_URL, etc.) live in # Secrets (TURN credentials, SSO_SECRET, BIZGAZE_WEBHOOK_URL, etc.) live in
+2 -2
View File
@@ -1158,7 +1158,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-20-batch144';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-20-batch145';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network. // Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
// //
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from // We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -2178,7 +2178,7 @@ function fmtSize(b){ b=+b||0; if(b<1024) return b+' B'; if(b<1048576) return Mat
// (and a %), making it obvious when it's safe to hit Send. // (and a %), making it obvious when it's safe to hit Send.
function uploadFile(file){ function uploadFile(file){
if(!file) return; if(!file) return;
if(file.size>25*1024*1024){ toast('“'+file.name+'” is too large (max 25 MB)'); return; } if(file.size>1024*1024*1024){ toast('“'+file.name+'” is too large (max 1 GB)'); return; }
const ph={ id:'up-'+Math.random().toString(36).slice(2), name:file.name, mime:file.type||'', uploading:true, pct:0, size:file.size }; const ph={ id:'up-'+Math.random().toString(36).slice(2), name:file.name, mime:file.type||'', uploading:true, pct:0, size:file.size };
pendingAttachs.push(ph); renderAttachBar(); pendingAttachs.push(ph); renderAttachBar();
const fail=(msg)=>{ const i=pendingAttachs.indexOf(ph); if(i>=0) pendingAttachs.splice(i,1); renderAttachBar(); toast(msg||'Upload failed'); }; const fail=(msg)=>{ const i=pendingAttachs.indexOf(ph); if(i>=0) pendingAttachs.splice(i,1); renderAttachBar(); toast(msg||'Upload failed'); };
+28 -11
View File
@@ -131,7 +131,8 @@ const mailer = require('./mailer');
// Basic email validation for external meeting invitees (#4). // Basic email validation for external meeting invitees (#4).
const isEmail = (s) => typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim()); const isEmail = (s) => typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
const crypto = require('crypto'); const crypto = require('crypto');
const MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per chat attachment const MAX_UPLOAD_MB = parseInt(process.env.MAX_UPLOAD_MB, 10) || 1024; // default 1 GB per chat attachment
const MAX_FILE_BYTES = MAX_UPLOAD_MB * 1024 * 1024; // NOTE: also raise Nginx Proxy Manager's client_max_body_size to match (default is 1 MB) or large uploads are rejected at the proxy before reaching here.
// Mint a LiveKit access token (HS256 JWT signed with the API secret) — same hand-rolled JWT // Mint a LiveKit access token (HS256 JWT signed with the API secret) — same hand-rolled JWT
// approach as push.js's FCM/APNs tokens, so no extra dependency. Grants the holder join+publish+ // approach as push.js's FCM/APNs tokens, so no extra dependency. Grants the holder join+publish+
@@ -1699,18 +1700,34 @@ route('POST', '/api/messages/upload', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const name = decodeURIComponent(req.headers['x-filename'] || 'file').slice(0, 200); const name = decodeURIComponent(req.headers['x-filename'] || 'file').slice(0, 200);
const mime = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim(); const mime = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
const chunks = []; let total = 0, aborted = false; // STREAM the body straight to disk (never buffer the whole file in memory — a 1 GB attachment would OOM the
req.on('data', (c) => { total += c.length; if (total > MAX_FILE_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); }); // container). Write to a .part temp file, then atomically rename on success. Backpressure-aware so a fast
req.on('end', () => { // uploader on a slow disk can't blow up memory either.
if (aborted) return json(res, 413, { error: 'file too large (max 25 MB)' });
if (!total) return json(res, 400, { error: 'empty file' });
const id = A.id(); const id = A.id();
try { fs.writeFileSync(path.join(UPLOADS_DIR, id), Buffer.concat(chunks)); } const tmp = path.join(UPLOADS_DIR, id + '.part');
catch (e) { return json(res, 500, { error: 'could not store file' }); } let ws, total = 0, aborted = false, done = false;
R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total }); try { ws = fs.createWriteStream(tmp); } catch (e) { return json(res, 500, { error: 'could not store file' }); }
json(res, 200, { id, name, mime, size: total }); const cleanup = () => { try { ws.destroy(); } catch (_) {} try { fs.unlinkSync(tmp); } catch (_) {} };
const finish = (code, body) => { if (done) return; done = true; json(res, code, body); };
ws.on('error', () => { aborted = true; cleanup(); finish(500, { error: 'could not store file' }); });
req.on('data', (c) => {
if (aborted) return;
total += c.length;
if (total > MAX_FILE_BYTES) { aborted = true; cleanup(); finish(413, { error: 'file too large (max ' + MAX_UPLOAD_MB + ' MB)' }); try { req.destroy(); } catch (_) {} return; }
if (!ws.write(c)) { req.pause(); ws.once('drain', () => { if (!aborted) req.resume(); }); } // respect backpressure
}); });
req.on('error', () => { try { res.end(); } catch (e) {} }); req.on('end', () => {
if (aborted) return;
ws.end(() => {
if (!total) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(400, { error: 'empty file' }); }
try { fs.renameSync(tmp, path.join(UPLOADS_DIR, id)); }
catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(500, { error: 'could not store file' }); }
R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total });
finish(200, { id, name, mime, size: total });
});
});
req.on('error', () => { aborted = true; cleanup(); try { res.end(); } catch (e) {} });
req.on('aborted', () => { aborted = true; cleanup(); }); // client hung up mid-upload → drop the partial file
}); });
// API versioning: alias every /api/* route under /api/v1/* — a frozen contract for // API versioning: alias every /api/* route under /api/v1/* — a frozen contract for