diff --git a/Dockerfile b/Dockerfile
index 1a72fa9..89625a2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/README.md b/README.md
index c1aab3b..dd4c572 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/public/app.js b/public/app.js
index 4b85dbd..1a54b1a 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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: '',
@@ -1041,11 +1067,11 @@ function viewSettings() {
OnlyOffice${esc(cfg.onlyoffice)}
${signed ? '' : ''}
-
Mail accounts
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.
+
Mail accounts
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.
iCloud Mail can't take a hint; use Keychain autofill or stay signed in.
-
Audiobooks sign-in
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.
+
Audiobooks sign-in
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.
`;
$('#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 = '
Could not read storage status
'; });
@@ -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();
diff --git a/server.js b/server.js
index 04e248f..2474fa0 100644
--- a/server.js
+++ b/server.js
@@ -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);
diff --git a/settings.js b/settings.js
new file mode 100644
index 0000000..c075096
--- /dev/null
+++ b/settings.js
@@ -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 };