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
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:
1 parent
2d17ee9939
commit
12a1e50a0e
6 files changed
+57
-9
No files matched your search
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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 => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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')); }
|
||||||
|
|||||||
Reference in new issue
Block a user