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:
+29
-12
@@ -131,7 +131,8 @@ const mailer = require('./mailer');
|
||||
// Basic email validation for external meeting invitees (#4).
|
||||
const isEmail = (s) => typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
|
||||
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
|
||||
// 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' });
|
||||
const name = decodeURIComponent(req.headers['x-filename'] || 'file').slice(0, 200);
|
||||
const mime = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
|
||||
const chunks = []; let total = 0, aborted = false;
|
||||
req.on('data', (c) => { total += c.length; if (total > MAX_FILE_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
|
||||
req.on('end', () => {
|
||||
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();
|
||||
try { fs.writeFileSync(path.join(UPLOADS_DIR, id), Buffer.concat(chunks)); }
|
||||
catch (e) { return json(res, 500, { error: 'could not store file' }); }
|
||||
R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total });
|
||||
json(res, 200, { id, name, mime, size: total });
|
||||
// STREAM the body straight to disk (never buffer the whole file in memory — a 1 GB attachment would OOM the
|
||||
// container). Write to a .part temp file, then atomically rename on success. Backpressure-aware so a fast
|
||||
// uploader on a slow disk can't blow up memory either.
|
||||
const id = A.id();
|
||||
const tmp = path.join(UPLOADS_DIR, id + '.part');
|
||||
let ws, total = 0, aborted = false, done = false;
|
||||
try { ws = fs.createWriteStream(tmp); } catch (e) { return json(res, 500, { error: 'could not store file' }); }
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user