From 5c7917e57cdfe96a10ba7304b64099c2593e6ec0 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:36:17 -0400 Subject: [PATCH] Show whether the data folder is a real volume (Settings and startup log) Passkeys, calendars and sessions were being lost on redeploy. The server now reports whether /data is a mounted volume, whether it is writable, what it holds, and where the session secret comes from, so a wiped volume is obvious. Co-Authored-By: Claude Sonnet 5.5 --- public/app.js | 9 +++++++++ server.js | 41 +++++++++++++++-------------------------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/public/app.js b/public/app.js index 42ccb6f..386d367 100644 --- a/public/app.js +++ b/public/app.js @@ -935,6 +935,7 @@ function openNow() { function viewSettings() { const signed = !!sf.token(); $('#view').innerHTML = `

Settings

+

Storage

Checking…

Passkeys

Loading…

Connections

@@ -963,6 +964,14 @@ function viewSettings() { el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server : 'Signed in · no server chosen') : 'Signed out'; if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; } }).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; }); + jfetch('/api/system').then(i => { + const ok = i.mounted !== false && i.writable, row = (t, d, bad) => `
${t}${d}
`; + $('#stor').innerHTML = + row(ok ? 'Data is saved on a volume' : 'Data is NOT being kept', i.mounted === false ? 'The data folder is not a mounted volume, so passkeys, calendars and sign-ins are lost when the container is recreated. Mount a volume or a host folder at ' + esc(i.dir) + '.' : !i.writable ? 'The data folder is not writable by the app.' : esc(i.dir) + (i.mounted ? ' (mounted)' : ''), !ok) + + row('What is stored', `${i.passkeys} passkey${i.passkeys === 1 ? '' : 's'} · ${i.events} NE Hub event${i.events === 1 ? '' : 's'} · ${i.calendarLinks} calendar link${i.calendarLinks === 1 ? '' : 's'}${i.icloud ? ' · iCloud connected' : ''}`) + + row('Session secret', i.secretFromEnv ? 'Set in the stack (sign-ins survive restarts)' : 'Generated and kept in the data folder', false) + + row('Running since', new Date(i.startedAt).toLocaleString(), false); + }).catch(() => { const e = $('#stor'); if (e) e.innerHTML = '
Could not read storage status
'; }); const loadPks = async () => { try { const pks = await (await fetch('/api/auth/passkeys')).json(); diff --git a/server.js b/server.js index 3c07fa6..e437913 100644 --- a/server.js +++ b/server.js @@ -264,33 +264,21 @@ async function plexProxy(req, res) { } catch (e) { if (!res.headersSent) { res.writeHead(502); res.end(); } else res.destroy(); } } -{ // Make it obvious in the log whether data survived a redeploy. - const dir = process.env.DATA_DIR || require('path').join(__dirname, 'data'); - const count = f => { try { const j = JSON.parse(require('fs').readFileSync(require('path').join(dir, f), 'utf8')); return Array.isArray(j) ? j.length : (j.creds || []).length; } catch { return 0; } }; - console.log(`[data] ${dir}: ${count('passkeys.json')} passkeys, ${count('calendars.json')} calendars`); -} - -// ---- DuckDuckGo instant answers for the Search tab (a short summary when DDG has one; many queries return nothing) -const ddgCache = new Map(); -async function ddgAnswer(req, res) { - const q = (new URL(req.url, 'http://x').searchParams.get('q') || '').trim().slice(0, 200); - if (q.length < 2) return json(res, 200, {}); - const hit = ddgCache.get(q.toLowerCase()); - if (hit && Date.now() - hit.t < 10 * 60e3) return json(res, 200, hit.v); - try { - const u = new URL('https://api.duckduckgo.com/'); - u.search = new URLSearchParams({ q, format: 'json', no_html: '1', skip_disambig: '1', t: 'nehub' }); - const r = await fetch(u, { signal: AbortSignal.timeout(6000), headers: { Accept: 'application/json' } }); - const d = await r.json(); - const v = {}; - if (d.AbstractText) Object.assign(v, { heading: d.Heading, text: d.AbstractText.slice(0, 600), source: d.AbstractSource, url: d.AbstractURL, image: d.Image ? 'https://duckduckgo.com' + d.Image : '' }); - else if (d.Definition) Object.assign(v, { heading: d.Heading || q, text: d.Definition.slice(0, 600), source: d.DefinitionSource, url: d.DefinitionURL }); - if (d.Answer && typeof d.Answer === 'string') v.answer = d.Answer.slice(0, 200); - if (ddgCache.size > 200) ddgCache.clear(); - ddgCache.set(q.toLowerCase(), { t: Date.now(), v }); - json(res, 200, v); - } catch { json(res, 200, {}); } // a failed lookup must never break Search +// ---- Storage health: is /data a real mounted volume, and what is in it? (so a wiped volume is obvious) +function storageInfo() { + const fs = require('fs'), path = require('path'); + const dir = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')); + let mounted = null; // null = cannot tell (not Linux) + try { mounted = fs.readFileSync('/proc/self/mountinfo', 'utf8').split('\n').some(l => l.split(' ')[4] === dir); } catch {} + let writable = true; try { const f = path.join(dir, '.write-test'); fs.writeFileSync(f, 'x'); fs.unlinkSync(f); } catch { writable = false; } + const count = (f, pick) => { try { return pick(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); } catch { return 0; } }; + return { dir, mounted, writable, passkeys: count('passkeys.json', j => (j.creds || []).length), calendarLinks: count('calendars.json', j => j.length), + events: count('events.json', j => j.length), icloud: fs.existsSync(path.join(dir, 'icloud.json')), secretFromEnv: !!process.env.COOKIE_SECRET, + startedAt: STARTED }; } +const STARTED = Date.now(); +{ const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted volume: ${i.mounted === null ? 'unknown' : i.mounted ? 'yes' : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`); + if (i.mounted === false) console.warn('[data] WARNING: the data folder is not a mounted volume. Passkeys, calendars and sessions will be lost whenever the container is recreated. Mount a volume or host folder at /data.'); } if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app editing only works if the Document Server has JWT disabled, and save callbacks are unauthenticated. Testing only.'); @@ -312,6 +300,7 @@ http.createServer((req, res) => { if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res); if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res); if (pathname === '/api/ddg') return ddgAnswer(req, res); + if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); } if (req.url.startsWith('/api/cal/')) return cal.handle(req, res); if (req.url.startsWith('/api/plex/')) return plexApi(req, res); if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);