@@ -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);