Always revalidate the app's files, tell open pages when a newer version exists, and log why a session is refused
build-container / build (push) Successful in 1m58s

An open page kept running the old version after a deploy (the single page never reloads), so the new
People screens did not appear. Static files are now sent with no-cache + ETag (304 when unchanged), icons
are cached for a week, /api/config reports a build stamp, and the app shows "A new version is available -
Reload" when it comes back into view or every 10 minutes. The service worker cache name is bumped.

The server also logs, at most once a minute per reason, why it refused a session cookie (unknown account,
turned off, signed out everywhere, bad signature, expired) without printing the cookie.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 16:55:44 -04:00
1 parent 2d17ee9939
commit 12a1e50a0e
6 files changed
+57 -9

No files matched your search

+18 -5
View File
@@ -42,18 +42,31 @@ const isHttps = req => (req.headers['x-forwarded-proto'] || '') === 'https' || (
const safeEq = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); const safeEq = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
/** Session cookie: uid.epoch.expiry.signature. (Older single-user cookies, expiry.signature, count as the admin.) */ /** Session cookie: uid.epoch.expiry.signature. (Older single-user cookies, expiry.signature, count as the admin.) */
const rejectedAt = new Map();
const whyRejected = (req, why) => { // explain a refused session in the log (at most once a minute per reason), without printing the cookie
const k = why, last = rejectedAt.get(k) || 0; if (Date.now() - last < 60e3) return; rejectedAt.set(k, Date.now());
console.warn(`[auth] session refused: ${why} (${req.method} ${req.url.split('?')[0]})`);
};
function currentUser(req) { function currentUser(req) {
const parts = String(cookies(req).hub_session || '').split('.'); const raw = cookies(req).hub_session;
if (!raw) return null;
const parts = String(raw).split('.');
let user = null, exp; let user = null, exp;
if (parts.length === 4) { if (parts.length === 4) {
const [uid, epoch, e, sig] = parts; exp = Number(e); const [uid, epoch, e, sig] = parts; exp = Number(e);
const u = users.get(uid); const u = users.get(uid);
if (u && !u.disabled && String(u.epoch) === epoch && safeEq(sig, mac(`s${uid}.${epoch}.${e}`))) user = u; if (!u) whyRejected(req, 'unknown account ' + uid);
else if (u.disabled) whyRejected(req, 'account is turned off');
else if (String(u.epoch) !== epoch) whyRejected(req, `signed out everywhere (cookie epoch ${epoch}, account epoch ${u.epoch})`);
else if (!safeEq(sig, mac(`s${uid}.${epoch}.${e}`))) whyRejected(req, 'bad signature (the cookie secret changed, or the cookie was altered)');
else user = u;
} else if (parts.length === 2) { } else if (parts.length === 2) {
const [e, sig] = parts; exp = Number(e); const [e, sig] = parts; exp = Number(e);
if (safeEq(sig, mac('s' + e))) user = users.firstAdmin(); if (safeEq(sig, mac('s' + e))) { user = users.firstAdmin(); if (!user) whyRejected(req, 'old-style cookie but no admin account exists'); }
} else whyRejected(req, 'bad signature on an old-style cookie (the cookie secret changed?)');
return user && exp > Date.now() ? user : null; } else whyRejected(req, 'cookie is not in a known format');
if (user && !(exp > Date.now())) { whyRejected(req, 'session expired'); return null; }
return user;
} }
const isAuthed = req => !!currentUser(req); const isAuthed = req => !!currentUser(req);
+14
View File
@@ -1535,6 +1535,20 @@ function dirSheet(repo, path, name, dir) {
// ---- Boot // ---- Boot
(async () => { (async () => {
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {} try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
const loadedBuild = cfg.build;
// When a new version is deployed, an open page keeps running the old one. Tell the person, and offer a reload.
const checkForUpdate = async () => {
try {
const c = await (await fetch('/api/config', { cache: 'no-store' })).json();
if (c.build && loadedBuild && c.build !== loadedBuild && !document.getElementById('upd')) {
const b = document.createElement('div'); b.id = 'upd';
b.innerHTML = 'A new version of NE Hub is available. <button class="btn" id="updgo">Reload</button>';
document.body.appendChild(b); document.getElementById('updgo').onclick = () => location.reload();
}
} catch {}
};
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkForUpdate(); });
setInterval(checkForUpdate, 10 * 60e3);
await loadMe(); await loadMe();
await loadSettings(); await loadSettings();
addEventListener('hashchange', route); addEventListener('hashchange', route);
+6
View File
@@ -246,3 +246,9 @@ html { -webkit-text-size-adjust: 100%; overscroll-behavior: none; }
body { -webkit-touch-callout: none; } body { -webkit-touch-callout: none; }
button, a, .row { -webkit-user-select: none; user-select: none; } button, a, .row { -webkit-user-select: none; user-select: none; }
input, textarea, [contenteditable], .sheet p, .card p, #view .sub { -webkit-user-select: text; user-select: text; } input, textarea, [contenteditable], .sheet p, .card p, #view .sub { -webkit-user-select: text; user-select: text; }
/* "A new version is available" notice */
#upd { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--nav-h) + var(--sab) + 70px); z-index: 30; display: flex; align-items: center; gap: 12px;
background: var(--text); color: var(--bg); padding: 8px 8px 8px 16px; border-radius: 14px; box-shadow: 0 6px 24px rgba(0,0,0,.35); max-width: 92vw; }
#upd .btn { min-height: 36px; background: var(--accent); color: var(--accent-t); }
@media (min-width: 768px) { #upd { bottom: 24px; } }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network. // App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v4'; const CACHE = 'hub-v5';
const SHELL = ['/style.css', '/app.js', '/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; const SHELL = ['/style.css', '/app.js', '/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
self.addEventListener('install', e => { self.addEventListener('install', e => {
+11 -3
View File
@@ -2,6 +2,7 @@
// (Seafile sends no CORS headers, so the browser talks to /sf/* instead). // (Seafile sends no CORS headers, so the browser talks to /sf/* instead).
const http = require('http'); const http = require('http');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto');
const path = require('path'); const path = require('path');
const { Readable } = require('stream'); const { Readable } = require('stream');
@@ -75,14 +76,21 @@ function serveStatic(req, res) {
fs.readFile(file, (err, data) => { fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404); return res.end('Not found'); } if (err) { res.writeHead(404); return res.end('Not found'); }
const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }; const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' };
if (p === '/sw.js') headers['cache-control'] = 'no-cache'; if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change
else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed
headers['cache-control'] = 'no-cache';
headers.etag = `W/"${data.length}-${crypto.createHash('sha1').update(data).digest('hex').slice(0, 16)}"`;
if (req.headers['if-none-match'] === headers.etag) { res.writeHead(304, { etag: headers.etag, 'cache-control': 'no-cache' }); return res.end(); }
}
res.writeHead(200, headers); res.writeHead(200, headers);
res.end(data); res.end(data);
}); });
} }
// Changes whenever the app's files change, so an open page can tell that a newer version is available.
const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'index.html', 'style.css'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })();
// ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile) // ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile)
const crypto = require('crypto');
const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || ''; const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';
const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, ''); const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, '');
const b64u = b => Buffer.from(b).toString('base64url'); const b64u = b => Buffer.from(b).toString('base64url');
@@ -404,7 +412,7 @@ async function handleRequest(req, res) {
const c = t => users.can(user, t); const c = t => users.can(user, t);
res.writeHead(200, { 'content-type': 'application/json' }); res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '', return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
podcast: c('podcast') ? PODCAST_URL : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET })); podcast: c('podcast') ? PODCAST_URL : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD }));
} }
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res); if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
if (pathname === '/api/ddg') return ddgAnswer(req, res); if (pathname === '/api/ddg') return ddgAnswer(req, res);
+7
View File
@@ -49,6 +49,13 @@ const wait = ms => new Promise(r => setTimeout(r, ms));
const raw = await fetch(base + '/api/tasks', { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: '{not json' }).then(async r => { await r.text(); return r; }).catch(() => ({ status: -1 })); const raw = await fetch(base + '/api/tasks', { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: '{not json' }).then(async r => { await r.text(); return r; }).catch(() => ({ status: -1 }));
ok('malformed JSON gets a clean error, not a crash', raw.status !== 500 && raw.status !== -1 && alive, raw.status); ok('malformed JSON gets a clean error, not a crash', raw.status !== 500 && raw.status !== -1 && alive, raw.status);
ok('signed-out requests are refused', await fetch(base + '/api/tasks/lists').then(r => r.status === 401).catch(() => false)); ok('signed-out requests are refused', await fetch(base + '/api/tasks/lists').then(r => r.status === 401).catch(() => false));
// browsers must always check for a new version of the app's files
const a1 = await fetch(base + '/app.js', { headers: { cookie } }); await a1.text(); const etag = a1.headers.get('etag');
ok('app files are sent with no-cache and an ETag, so a new version is never missed', /no-cache/.test(a1.headers.get('cache-control') || '') && !!etag, [a1.headers.get('cache-control'), etag]);
const a2 = await fetch(base + '/app.js', { headers: { cookie, 'if-none-match': etag } }); await a2.text();
ok('and an unchanged file is answered with a cheap 304', a2.status === 304, a2.status);
const i1 = await fetch(base + '/icons/icon-192.png'); await i1.arrayBuffer(); ok('icons are cached for a week', /max-age=604800/.test(i1.headers.get('cache-control') || ''), i1.headers.get('cache-control'));
const cfg = await (await fetch(base + '/api/config', { headers: { cookie } })).json(); ok('the server reports a build stamp so open pages can tell a newer version exists', /^[0-9a-f]{12}$/.test(cfg.build || ''), cfg.build);
ok('the server is still running after all of that', alive); ok('the server is still running after all of that', alive);
child.kill(); fs.rmSync(dir, { recursive: true, force: true }); child.kill(); fs.rmSync(dir, { recursive: true, force: true });
if (failed) { console.log('\n--- server log ---\n' + log.split('\n').filter(l => /error|Error|not defined/.test(l)).slice(0, 12).join('\n')); } if (failed) { console.log('\n--- server log ---\n' + log.split('\n').filter(l => /error|Error|not defined/.test(l)).slice(0, 12).join('\n')); }