Files
hub/settings.js
T
nmemmertandClaude Sonnet 5.5 e22d26bf17
build-container / build (push) Successful in 2m2s
Save settings on the server and sync them to every device
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>
2026-10-20 15:59:01 -04:00

51 lines
3.2 KiB
JavaScript

// 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 };