From 12a1e50a0eefac66b0f6593e5bc64be0b0d96fa9 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:55:44 -0400 Subject: [PATCH] Always revalidate the app's files, tell open pages when a newer version exists, and log why a session is refused 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 --- auth.js | 23 ++++++++++++++++++----- public/app.js | 14 ++++++++++++++ public/style.css | 6 ++++++ public/sw.js | 2 +- server.js | 14 +++++++++++--- tools/smoke-test.js | 7 +++++++ 6 files changed, 57 insertions(+), 9 deletions(-) diff --git a/auth.js b/auth.js index 677282c..48cd8ff 100644 --- a/auth.js +++ b/auth.js @@ -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)); /** 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) { - 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; if (parts.length === 4) { const [uid, epoch, e, sig] = parts; exp = Number(e); 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) { const [e, sig] = parts; exp = Number(e); - if (safeEq(sig, mac('s' + e))) user = users.firstAdmin(); - } - return user && exp > Date.now() ? user : null; + 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?)'); + } 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); diff --git a/public/app.js b/public/app.js index c3c2fb2..8e78d55 100644 --- a/public/app.js +++ b/public/app.js @@ -1535,6 +1535,20 @@ function dirSheet(repo, path, name, dir) { // ---- Boot (async () => { 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. '; + 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 loadSettings(); addEventListener('hashchange', route); diff --git a/public/style.css b/public/style.css index a09315d..c97df3e 100644 --- a/public/style.css +++ b/public/style.css @@ -246,3 +246,9 @@ html { -webkit-text-size-adjust: 100%; overscroll-behavior: none; } body { -webkit-touch-callout: 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; } + +/* "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; } } diff --git a/public/sw.js b/public/sw.js index 0856af3..c6d6e56 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // 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']; self.addEventListener('install', e => { diff --git a/server.js b/server.js index e2cd0c1..016b1ba 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,7 @@ // (Seafile sends no CORS headers, so the browser talks to /sf/* instead). const http = require('http'); const fs = require('fs'); +const crypto = require('crypto'); const path = require('path'); const { Readable } = require('stream'); @@ -75,14 +76,21 @@ function serveStatic(req, res) { fs.readFile(file, (err, data) => { if (err) { res.writeHead(404); return res.end('Not found'); } 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.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) -const crypto = require('crypto'); const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || ''; const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, ''); const b64u = b => Buffer.from(b).toString('base64url'); @@ -404,7 +412,7 @@ async function handleRequest(req, res) { const c = t => users.can(user, t); 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 : '', - 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 (pathname === '/api/ddg') return ddgAnswer(req, res); diff --git a/tools/smoke-test.js b/tools/smoke-test.js index 5b7a52d..cc38809 100644 --- a/tools/smoke-test.js +++ b/tools/smoke-test.js @@ -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 })); 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)); + // 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); 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')); }