Show whether the data folder is a real volume (Settings and startup log)
build-container / build (push) Successful in 1m56s
build-container / build (push) Successful in 1m56s
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
18266267e5
commit
5c7917e57c
2 files changed
+24
-26
No files matched your search
@@ -935,6 +935,7 @@ function openNow() {
|
||||
function viewSettings() {
|
||||
const signed = !!sf.token();
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Settings</h1>
|
||||
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
||||
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
|
||||
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
|
||||
<h2>Connections</h2><div class="list">
|
||||
@@ -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) => `<div class="row"><span class="grow"><b>${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
|
||||
$('#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 = '<div class="row"><small>Could not read storage status</small></div>'; });
|
||||
const loadPks = async () => {
|
||||
try {
|
||||
const pks = await (await fetch('/api/auth/passkeys')).json();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user