6 Commits
Author SHA1 Message Date
Claude 0617664d97 Bump the app cache after merging
build-container / build (push) Successful in 6m34s
2026-10-26 12:17:43 +00:00
Claude ce76f4fa8d Merge suggestions 2026-10-26 12:16:47 +00:00
Claude 5fc741ab02 Merge mailscroll 2026-10-26 12:16:47 +00:00
Claude dc72665610 Merge hardening 2026-10-26 12:16:46 +00:00
Claude 1d345e072e Mail: on a phone the message is as tall as its text, so there is one scroll instead of a scrolling box inside a scrolling page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-26 11:46:38 +00:00
Claude d606e11de4 Limit request sizes, answer 413, stop cleanly on SIGTERM
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-26 11:40:31 +00:00
11 changed files with 42 additions and 10 deletions

No files matched your search

+1 -1
View File
@@ -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) });
+1 -1
View File
@@ -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);
+11
View File
@@ -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 };
+1 -1
View File
@@ -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 } */
+1 -1
View File
@@ -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": [],
+10 -1
View File
@@ -33,6 +33,14 @@ function mailDoc(html, images) {
return `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><base target="_blank"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{margin:14px;font:15px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;color:#111;background:#fff;overflow-wrap:anywhere}img{max-width:100%;height:auto}table{max-width:100%}a{color:#1a5fd1}blockquote{margin:0 0 0 6px;padding-left:10px;border-left:3px solid #ccc;color:#555}</style>${html}`;
}
/** On a phone the message is as tall as its text and the page scrolls, so there is one scroll, not a box inside a page. (The box keeps its own scroll on a wide screen.) No scripts run in it: only same-origin so its height can be read. */
function mailFit(fr) {
if (!fr) return;
const fit = () => { if (!fr.isConnected) return; if (innerWidth >= 1024) { fr.style.height = ''; return; } try { const d = fr.contentDocument; d.documentElement.style.overflowY = 'hidden'; fr.style.height = Math.max(160, Math.ceil(d.documentElement.scrollHeight) + 2) + 'px'; } catch {} };
fr.addEventListener('load', () => { fit(); try { fr.contentDocument.addEventListener('load', fit, true); } catch {} setTimeout(fit, 300); setTimeout(fit, 1200); });
if (!mailFit.on) { mailFit.on = true; addEventListener('resize', () => { const f = document.querySelector('.mbody'); if (f) f.dispatchEvent(new Event('fit')); }); }
fr.addEventListener('fit', fit);
}
const mailHasRemote = html => /<img[^>]+src=["']?https?:|url\(\s*["']?https?:/i.test(html || '');
// ---- unread badge and Home card
@@ -462,8 +470,9 @@ function mailDrawReader() {
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="unread">Mark unread</button><button class="btn ghost" data-a="flag">${m.flagged ? 'Unflag' : 'Flag'}</button><button class="btn danger" data-a="delete">Delete</button></div>
${m.attachments.length ? `<div class="matt">${m.attachments.map(a => `<a class="btn ghost" href="/api/mail/attachment?id=${encodeURIComponent(m.id)}&aid=${encodeURIComponent(a.id)}" download>${icon('clip')}${esc(a.name)} <small>${esc(fmtSize(a.size || 0))}</small></a>`).join('')}</div>` : ''}
${remote ? '<div class="mimg">Pictures are hidden to protect your privacy. <button class="btn ghost" id="mshow" style="min-height:32px">Show pictures</button></div>' : ''}</div>
<iframe class="mbody" title="Message" sandbox="allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" srcdoc="${esc(mailDoc(m.html, mail.images))}"></iframe>`;
<iframe class="mbody" title="Message" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" srcdoc="${esc(mailDoc(m.html, mail.images))}"></iframe>`;
if ($('#mshow')) $('#mshow').onclick = () => { mail.images = true; mailDrawReader(); };
mailFit(rd.querySelector('.mbody'));
rd.querySelectorAll('[data-a]').forEach(b => b.onclick = () => mailAction(b.dataset.a));
}
+2 -2
View File
@@ -539,14 +539,14 @@ body.lyropen { overflow: hidden; }
.mact, .matt { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
.mact .btn, .matt .btn { min-height: 38px; }
.mimg { background: var(--panel2); border-radius: 10px; padding: 8px 12px; font-size: 14px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.mbody { width: 100%; flex: 1; min-height: 55dvh; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
.mbody { width: 100%; flex: none; height: 60dvh; min-height: 0; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
@media (min-width: 1024px) {
.mailapp { grid-template-columns: 340px minmax(0, 1fr); height: calc(100dvh - 40px); min-height: 0; }
.mailapp[data-mode] .mlist, .mailapp[data-mode] .mread { display: block; }
.mailapp[data-mode] .mread { display: flex; }
.mlist, .mread { overflow-y: auto; min-height: 0; }
.mback { display: none; }
.mbody { min-height: 0; }
.mbody { flex: 1; height: auto; }
}
@media (min-width: 1500px) {
.mailapp { grid-template-columns: 190px 340px minmax(0, 1fr); }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v49';
const CACHE = 'hub-v50';
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+5 -1
View File
@@ -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); });
+1 -1
View File
@@ -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'];
+8
View File
@@ -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();
});