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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user