Save settings on the server and sync them to every device
build-container / build (push) Successful in 2m2s
build-container / build (push) Successful in 2m2s
Preferences lived only in the browser, so clearing it, using another device or the separate iOS home-screen storage lost them. They are now saved in /data/settings.json (the audiobooks sign-in encrypted), loaded at start-up, and existing browser-only values are uploaded once. A failed save is reported instead of silent. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d3642a7736
commit
e22d26bf17
5 files changed
+87
-7
No files matched your search
+1
-1
@@ -2,7 +2,7 @@ FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY server.js auth.js cal.js ics.js caldav.js tasks.js ./
|
||||
COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js ./
|
||||
COPY public ./public
|
||||
ENV PORT=8080 DATA_DIR=/data
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
|
||||
@@ -34,6 +34,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
||||
- **Tasks**: to-do lists stored in NE Hub (`/data/tasks.json`), with due dates, notes and completion. If the connected iCloud
|
||||
account exposes reminder lists over CalDAV they appear too and sync both ways. Apple hides Reminders lists that were upgraded
|
||||
for iOS 13+, so for most accounts only NE Hub lists are available.
|
||||
- **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. Per-device items (Seafile sign-in token, music queue) stay in the browser.
|
||||
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
|
||||
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the queue (play next / add to queue) and position are remembered.
|
||||
- **Audio**: embeds audiobooks.necloud.us; sign-in details saved in Settings can be copied with one tap.
|
||||
|
||||
+32
-5
@@ -8,11 +8,37 @@ window.fetch = async (...a) => {
|
||||
};
|
||||
const $ = s => document.querySelector(s);
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
|
||||
// device, or the separate storage of the iOS home-screen app. Per-device things (Seafile token, music queue) stay local.
|
||||
const SYNCED = new Set(['mail_hints', 'ab_creds', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
|
||||
const outbox = new Map(); let outTimer = null, warned = false;
|
||||
function flushSettings() {
|
||||
outTimer = null;
|
||||
for (const [key, value] of [...outbox]) {
|
||||
outbox.delete(key);
|
||||
fetch('/api/settings', { method: 'PUT', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }) })
|
||||
.then(r => { if (!r.ok) throw new Error('save failed'); })
|
||||
.catch(() => { if (!warned) { warned = true; toast('Could not save your settings to the server. They are kept on this device for now.'); } });
|
||||
}
|
||||
}
|
||||
const queueSetting = (k, v) => { if (!SYNCED.has(k)) return; outbox.set(k, v); clearTimeout(outTimer); outTimer = setTimeout(flushSettings, 400); };
|
||||
addEventListener('pagehide', () => { if (outbox.size) flushSettings(); });
|
||||
const store = {
|
||||
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } },
|
||||
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} },
|
||||
del(k) { try { localStorage.removeItem(k); } catch {} },
|
||||
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} queueSetting(k, v); },
|
||||
del(k) { try { localStorage.removeItem(k); } catch {} queueSetting(k, null); },
|
||||
};
|
||||
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const r = await fetch('/api/settings'); if (!r.ok) return;
|
||||
const { values } = await r.json();
|
||||
for (const k of SYNCED) {
|
||||
if (k in values) { try { localStorage.setItem(k, JSON.stringify(values[k])); } catch {} }
|
||||
else { const local = store.get(k); if (local !== undefined && local !== null) queueSetting(k, local); }
|
||||
}
|
||||
} catch {} // offline: keep using what this browser has
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
home: '<path d="M3 11l9-8 9 8M5 10v10h14V10"/>',
|
||||
@@ -1041,11 +1067,11 @@ function viewSettings() {
|
||||
<div class="row"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
|
||||
</div>
|
||||
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}
|
||||
<h2>Mail accounts</h2><p class="sub">Your address for each account, so its inbox and new-message buttons open the right mailbox. Stored only in this browser. Passwords are never saved here.</p>
|
||||
<h2>Mail accounts</h2><p class="sub">Your address for each account, so its inbox and new-message buttons open the right mailbox. Saved on your server, so every device sees them. Passwords are never saved here.</p>
|
||||
<div class="card" style="max-width:none">${MAIL.filter(m => m.id !== 'icloud').map(m => `<label for="h-${m.id}">${esc(m.name)}</label>
|
||||
<input type="email" id="h-${m.id}" data-id="${m.id}" placeholder="you@example.com" autocomplete="off" autocapitalize="off" value="${esc(mailHints()[m.id] || '')}">`).join('')}
|
||||
<p class="sub" style="margin-top:12px">iCloud Mail can't take a hint; use Keychain autofill or stay signed in.</p></div>
|
||||
<h2>Audiobooks sign-in</h2><p class="sub">Saved only in this browser, in plain text, so anyone with access to this device can read it. Leave blank to skip. Use the copy buttons on the Audio tab to paste them into the site's sign-in form.</p>
|
||||
<h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy buttons on the Audio tab to paste them into the site's sign-in form.</p>
|
||||
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
|
||||
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
|
||||
<label for="ab-p">Password</label>
|
||||
@@ -1065,7 +1091,7 @@ function viewSettings() {
|
||||
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('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.settings || 0} saved setting${i.settings === 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>'; });
|
||||
@@ -1371,6 +1397,7 @@ function dirSheet(repo, path, name, dir) {
|
||||
// ---- Boot
|
||||
(async () => {
|
||||
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
|
||||
await loadSettings();
|
||||
addEventListener('hashchange', route);
|
||||
restorePlayer();
|
||||
route();
|
||||
|
||||
@@ -12,6 +12,7 @@ const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.neclou
|
||||
const auth = require('./auth');
|
||||
const cal = require('./cal');
|
||||
const tasks = require('./tasks');
|
||||
const settings = require('./settings');
|
||||
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
||||
const PODCAST_URL = process.env.PODCAST_URL || 'https://podcast.necloud.us/';
|
||||
const PUBLIC = path.join(__dirname, 'public');
|
||||
@@ -274,7 +275,7 @@ function storageInfo() {
|
||||
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,
|
||||
events: count('events.json', j => j.length), settings: count('settings.json', j => Object.keys(j).length), icloud: fs.existsSync(path.join(dir, 'icloud.json')), secretFromEnv: !!process.env.COOKIE_SECRET,
|
||||
startedAt: STARTED };
|
||||
}
|
||||
const STARTED = Date.now();
|
||||
@@ -303,6 +304,7 @@ http.createServer((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 (pathname === '/api/settings') return settings.handle(req, res);
|
||||
if (req.url.startsWith('/api/tasks')) return tasks.handle(req, res);
|
||||
if (req.url.startsWith('/api/cal/')) return cal.handle(req, res);
|
||||
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// User preferences saved on the server (in the data folder) so they survive cleared browsers and follow you across devices.
|
||||
// Sensitive values (the audiobooks sign-in) are encrypted at rest with a key derived from the server secret.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const auth = require('./auth');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const FILE = path.join(DATA_DIR, 'settings.json');
|
||||
|
||||
// Only these keys are accepted. (Per-device things like the Seafile sign-in token and the music queue stay in the browser.)
|
||||
const KEYS = new Set(['mail_hints', 'ab_creds', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
|
||||
const SECRET = new Set(['ab_creds']);
|
||||
const MAX_VALUE = 20000; // bytes of JSON per key
|
||||
|
||||
const key = () => crypto.scryptSync(auth.secret, 'nehub-settings', 32);
|
||||
const seal = v => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
||||
const d = Buffer.concat([c.update(JSON.stringify(v), 'utf8'), c.final()]); return { _sealed: Buffer.concat([iv, c.getAuthTag(), d]).toString('base64') }; };
|
||||
const unseal = o => { try { const b = Buffer.from(o._sealed, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12));
|
||||
d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); } catch { return undefined; } };
|
||||
|
||||
const load = () => { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return {}; } };
|
||||
const save = o => { fs.mkdirSync(DATA_DIR, { recursive: true }); const tmp = FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o, null, 2), { mode: 0o600 }); fs.renameSync(tmp, FILE); }; // write-then-rename: a crash never leaves half a file
|
||||
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 100000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
|
||||
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
||||
|
||||
async function handle(req, res) {
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
const raw = load(), values = {};
|
||||
for (const k of KEYS) { if (!(k in raw)) continue; const v = SECRET.has(k) && raw[k] && raw[k]._sealed ? unseal(raw[k]) : raw[k]; if (v !== undefined) values[k] = v; }
|
||||
return json(res, 200, { values });
|
||||
}
|
||||
if (req.method === 'PUT') {
|
||||
const { key: k, value } = JSON.parse(await readBody(req));
|
||||
if (!KEYS.has(k)) return json(res, 400, { error: 'Unknown setting' });
|
||||
const all = load();
|
||||
if (value === null || value === undefined) delete all[k];
|
||||
else {
|
||||
if (JSON.stringify(value).length > MAX_VALUE) return json(res, 400, { error: 'That setting is too large' });
|
||||
all[k] = SECRET.has(k) ? seal(value) : value;
|
||||
}
|
||||
save(all);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
json(res, 405, { error: 'Method not allowed' });
|
||||
} catch (e) { json(res, 500, { error: e.message }); }
|
||||
}
|
||||
|
||||
module.exports = { handle, KEYS };
|
||||
Reference in new issue
Block a user