diff --git a/admin.js b/admin.js index 0f55fb5..9b65f29 100644 --- a/admin.js +++ b/admin.js @@ -4,7 +4,7 @@ const users = require('./users'); const auth = require('./auth'); const audit = require('./audit'); -const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); +const { readBody } = require('./body'); const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; const row = u => ({ ...users.publicView(u), tabsChosen: u.tabs || [], passkeys: auth.passkeyCount(u.id) }); diff --git a/auth.js b/auth.js index d6b796e..d1eb5f7 100644 --- a/auth.js +++ b/auth.js @@ -144,7 +144,7 @@ const rp = req => { }; const equalStr = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); }; -const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); +const { readBody } = require('./body'); const send = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', ...(res.getHeader('Set-Cookie') ? { 'Set-Cookie': res.getHeader('Set-Cookie') } : {}) }); res.end(JSON.stringify(obj)); }; const credsOf = uid => store.creds.filter(c => c.uid === uid); diff --git a/body.js b/body.js new file mode 100644 index 0000000..0110333 --- /dev/null +++ b/body.js @@ -0,0 +1,11 @@ +'use strict'; +// Reads a request body as text, and refuses one that is too big, so a huge upload cannot use up the server's memory. +function readBody(req, max = 2e6) { + return new Promise((ok, no) => { + const c = []; let n = 0, done = false; + req.on('data', d => { if (done) return; n += d.length; if (n > max) { done = true; c.length = 0; const e = new Error('That is too large'); e.status = 413; no(e); return; } c.push(d); }); + req.on('end', () => { if (!done) ok(Buffer.concat(c).toString()); }); + req.on('error', e => { if (!done) no(e); }); + }); +} +module.exports = { readBody }; diff --git a/cal.js b/cal.js index b7b25e2..baf5251 100644 --- a/cal.js +++ b/cal.js @@ -306,7 +306,7 @@ function feed(req, res) { } // ---- HTTP API (called by server.js, behind the passkey gate) -const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); +const readBody = req => require('./body').readBody(req, 8e6); const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; /** Every event in the window from all calendars (ICS links, iCloud, NE Hub, shared): { events, errors } */ diff --git a/package.json b/package.json index ec246d8..d153caa 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "start": "node server.js", - "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer; do node tools/$t-test.js || exit 1; done", + "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/server.js b/server.js index 949036c..f7446ff 100644 --- a/server.js +++ b/server.js @@ -166,7 +166,7 @@ const sessions = new Map(); // callback id -> { token, repo, path, exp }; keeps setInterval(() => { for (const [k, v] of sessions) if (v.exp < Date.now()) sessions.delete(k); }, 3600e3).unref(); const DOC_TYPE = { word: 'doc docx odt rtf txt', cell: 'xls xlsx ods csv', slide: 'ppt pptx odp' }; const docType = e => Object.keys(DOC_TYPE).find(k => DOC_TYPE[k].split(' ').includes(e)); -const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); +const { readBody } = require('./body'); const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; const sfGet = async (p, token) => { const r = await fetch(SEAFILE_URL + p, { headers: { Authorization: 'Token ' + token } }); @@ -661,9 +661,13 @@ async function handleRequest(req, res) { http.createServer(async (req, res) => { try { await handleRequest(req, res); } catch (e) { + if (e && e.status === 413) { if (!res.headersSent) { res.writeHead(413, { 'content-type': 'application/json', connection: 'close' }); res.end('{"error":"That is too large"}'); } return; } console.error('[error]', req.method, req.url.split('?')[0], e && e.stack || e); if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Server error: ' + (e && e.message || 'unknown') })); } else res.destroy(); } }).listen(PORT, () => console.log(`Hub on :${PORT} (Seafile ${SEAFILE_URL})`)); process.on('unhandledRejection', e => console.error('[unhandledRejection]', e && e.stack || e)); process.on('uncaughtException', e => console.error('[uncaughtException]', e && e.stack || e)); + +// Docker sends SIGTERM on stop: leave at once instead of waiting to be killed. +for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { console.log('[stop] ' + sig); process.exit(0); }); diff --git a/tasks.js b/tasks.js index 4184ff1..a00f29e 100644 --- a/tasks.js +++ b/tasks.js @@ -12,7 +12,7 @@ const users = require('./users'); const file = () => path.join(users.dir(), 'tasks.json'); // each person has their own const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; } }; const save = d => atomicWrite(file(), JSON.stringify(d, null, 2), { mode: 0o600 }); -const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); +const { readBody } = require('./body'); const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; const isLocal = id => String(id || '').startsWith('local:'); const COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d']; diff --git a/tools/body-test.js b/tools/body-test.js new file mode 100644 index 0000000..613080e --- /dev/null +++ b/tools/body-test.js @@ -0,0 +1,8 @@ +const http = require('http'), assert = require('assert'), { readBody } = require('../body'); +const s = http.createServer(async (req, res) => { try { const t = await readBody(req, 1000); res.end('ok:' + t.length); } catch (e) { res.writeHead(e.status || 500); res.end(e.message); } }); +s.listen(0, async () => { + const P = s.address().port, post = b => fetch(`http://127.0.0.1:${P}/`, { method: 'POST', body: b }).then(async r => [r.status, await r.text()]); + assert.deepStrictEqual(await post('x'.repeat(900)), [200, 'ok:900']); + const big = await post('x'.repeat(5000)); assert.strictEqual(big[0], 413); + console.log('body test ok'); s.close(); +});