mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-11 13:37:44 +00:00
Part B of #2128: a logged-in user's settings follow them across devices. Log in on a phone and your own nodes, favorites, customizer and filters are there; a change on one device reaches the others within a minute or when you return to the tab. **This PR builds on #2129.** Until that one is merged, the diff here includes it. The commits for this part start at `docs(specs): settings sync for optional user management (sub-project B)`. ## The situation Everything a visitor sets up lives in one browser's `localStorage` (about 100 keys in `public/`). A second device or a cleared cache starts from zero (#895). ## What this PR adds **Storage.** `users.db` schema v2: one JSON document per user in `user_settings`, with a revision number and a generation id. A write succeeds only when the client's revision and generation match the stored ones, so two devices cannot overwrite each other silently. **Server.** `GET`, `PUT` and `DELETE /api/account/settings`, behind the same session and CSRF checks as the account routes. - The server owns the list of synced keys (61 keys, [`settings_allowlist.go`](https://github.com/efiten/CoreScope/blob/feat/settings-sync/cmd/server/settings_allowlist.go)) and sends it to the client, so the two cannot drift. - A hard denylist, checked first, refuses `meshcore-api-key`, every `corescope_channel_*` key and `live-channel-colors` (#725). The colour map is keyed by channel hash, and for a user-added channel that hash is `user:<name>`, which would expose hashtag channel names. - Documents are capped at 256 KiB, measured like `JSON.stringify`. PUT is limited to 60 requests per hour per user. A stale revision gets 409 with the current document. **Client** ([`settings-sync.js`](https://github.com/efiten/CoreScope/blob/feat/settings-sync/public/settings-sync.js)). Inert unless the feature is on and someone is logged in. - It wraps `localStorage.setItem` and `removeItem` for allowlisted keys only and pushes 2 seconds after the last change. - It pulls on login, page load, tab focus and every 60 seconds while the tab is visible. - **Merge:** three-way, against a per-device baseline that belongs to one user and one document generation. Lists (own nodes, favorites, saved filters) merge per item, so an item added anywhere is kept and an item removed on one device does not come back from another. Single values: the profile wins unless only this device changed it. - Remote changes are written without a push, theme and colour-blind preset are re-applied, and the current page re-renders (skipped on account pages and while the geofilter editor is open). **UI.** - Logout asks: keep my settings on this device (default), remove them from this device, or cancel. Channel keys are never removed: no copy exists anywhere else. - The account page gets a "Settings sync" section: last synced time, "Sync now", what is and is not synced, and "Delete synced settings from my account". ## Not synced Layout and device state (panel and column widths, collapsed panels, map positions, geofilter drafts), channel data (#725), the API key, and all `sessionStorage`. The full list is in the [spec](https://github.com/efiten/CoreScope/blob/feat/settings-sync/docs/specs/2026-10-06-user-settings-sync-design.md). ## Performance - One GET per page load, tab focus and minute while visible; one debounced PUT per burst of changes. - The `setItem` wrapper costs one Set lookup per write for non-synced keys. A synced write reads one small revision key, not the stored document. - The server reads or writes one row per request. ## Verification - `internal/users` and `cmd/server`: `go vet` and `go test` pass locally (22 new Go tests), including a test that every allowlisted key still occurs in `public/`, and denylist tests. - `tests/unit/test-settings-sync.js`: 79 passing (vm, real module). The cases cover the merge table, two tabs sharing one storage, stale answers after a push, delete while a push is in flight, and logout while the final push fails. - `sh test-all.sh` exits 0. - `tests/e2e/test-user-management-e2e.js` (10 steps, 4 of them new) passed locally with two browser contexts as two devices: a favorite and the packet time window travel from device 1 to device 2, a removal does not come back, and "remove from this device" clears the synced keys while a channel key stays. - Checked by hand on a staging instance with a desktop and a phone on one account. ## Not in this PR - On a shared browser where the previous user chose "keep", the next user's first login merges those settings into their own account. The user guide says to choose "remove" on shared computers. - Saved filter expressions are synced as typed, including any channel names written in them. The guide says so. - Realtime push between devices; the minute pull is the sync interval. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
757 lines
32 KiB
JavaScript
757 lines
32 KiB
JavaScript
/* Settings sync for optional user management
|
|
* (docs/specs/2026-10-06-user-settings-sync-design.md). Inert unless
|
|
* window.MC_USER_MGMT is on and CSAuth reports a logged-in user. While
|
|
* active it wraps Storage.prototype.setItem/removeItem for the keys the
|
|
* server allowlists, merges this device with the account's copy and
|
|
* pushes changes. Exposes window.CSSettingsSync. */
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ── Three-way merge (pure) ──
|
|
// Values are raw localStorage strings; undefined means "not set".
|
|
|
|
function own(o, k) { return o && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined; }
|
|
|
|
// parseList returns the array behind raw ([] when unset), or null when
|
|
// raw is not a JSON array.
|
|
function parseList(raw) {
|
|
if (raw === undefined) return [];
|
|
try {
|
|
var v = JSON.parse(raw);
|
|
return Array.isArray(v) ? v : null;
|
|
} catch (e) { return null; }
|
|
}
|
|
|
|
// identity of a set item: its idField when it is an object that has one,
|
|
// else its JSON (a string item is compared as itself).
|
|
function identity(item, idField) {
|
|
if (idField && item && typeof item === 'object' && item[idField] != null) return 'f:' + String(item[idField]);
|
|
return 'j:' + JSON.stringify(item);
|
|
}
|
|
|
|
function indexList(list, idField) {
|
|
var map = Object.create(null), order = [];
|
|
list.forEach(function (item) {
|
|
var id = identity(item, idField);
|
|
if (id in map) return;
|
|
map[id] = item;
|
|
order.push(id);
|
|
});
|
|
return { map: map, order: order };
|
|
}
|
|
|
|
function sameItem(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
|
|
|
|
// mergeSet computes P + (L - B) - (B - L) by identity. Returns the merged
|
|
// raw string, undefined when neither side has the key, or null when this
|
|
// device or the account holds something that is not a JSON list (the
|
|
// caller then merges it as a scalar). An unparseable baseline counts as
|
|
// none, so nothing added on either side is dropped.
|
|
function mergeSet(l, p, b, idField) {
|
|
var al = parseList(l), ap = parseList(p), ab = parseList(b) || [];
|
|
if (!al || !ap) return null;
|
|
var L = indexList(al, idField), P = indexList(ap, idField), B = indexList(ab, idField);
|
|
var out = [];
|
|
P.order.forEach(function (id) {
|
|
var inL = id in L.map, inB = id in B.map;
|
|
if (inB && !inL) return; // removed on this device since the last sync
|
|
var onlyLocalChanged = inL && inB && !sameItem(L.map[id], B.map[id]) && sameItem(P.map[id], B.map[id]);
|
|
out.push(onlyLocalChanged ? L.map[id] : P.map[id]);
|
|
});
|
|
L.order.forEach(function (id) {
|
|
if (id in P.map || id in B.map) return; // in the profile, or removed on another device
|
|
out.push(L.map[id]); // added on this device since the last sync
|
|
});
|
|
if (!out.length && l === undefined && p === undefined) return undefined;
|
|
var s = JSON.stringify(out);
|
|
// Keep an existing spelling of the same list: formatting alone is no
|
|
// change. The account's spelling goes first, so a list equal to both
|
|
// sides does not count as differing from the account (two devices
|
|
// would otherwise push their spelling back and forth).
|
|
if (p !== undefined && JSON.stringify(ap) === s) return p;
|
|
if (l !== undefined && JSON.stringify(al) === s) return l;
|
|
return s;
|
|
}
|
|
|
|
function mergeScalar(l, p, b) { return (l !== b && p === b) ? l : p; }
|
|
|
|
// mergeDocs merges local, profile and baseline ({key: raw}) for every
|
|
// allowlisted key. localChanges lists the keys this device must write;
|
|
// differsFromProfile says the result must be pushed.
|
|
function mergeDocs(local, profile, base, allowlist) {
|
|
var keys = {}, localChanges = [], differs = false;
|
|
allowlist.forEach(function (entry) {
|
|
var k = entry.key, l = own(local, k), p = own(profile, k), b = own(base, k), v;
|
|
if (entry.kind === 'set') {
|
|
v = mergeSet(l, p, b, entry.id || '');
|
|
if (v === null) {
|
|
console.warn('[settings-sync] ' + k + ' is not a JSON list on this device or in the account; merged as one value');
|
|
v = mergeScalar(l, p, b);
|
|
}
|
|
} else {
|
|
v = mergeScalar(l, p, b);
|
|
}
|
|
if (v !== undefined) keys[k] = v;
|
|
if (v !== l) localChanges.push(k);
|
|
if (v !== p) differs = true;
|
|
});
|
|
return { keys: keys, localChanges: localChanges, differsFromProfile: differs };
|
|
}
|
|
|
|
// ── Sync engine ──
|
|
|
|
var BASE_KEY = 'cs-settings-sync-base';
|
|
var REV_KEY = 'cs-settings-sync-rev';
|
|
var PUSH_DELAY_MS = 2000;
|
|
var PULL_EVERY_MS = 60000;
|
|
var BACKOFF_MIN_MS = 2000;
|
|
var BACKOFF_MAX_MS = 300000;
|
|
var MAX_CONFLICT_RETRIES = 3;
|
|
var FLUSH_TIMEOUT_MS = 5000;
|
|
var FIRST_LOGIN_TEXT = 'Your settings are now saved to your account.';
|
|
// Keys whose change an existing storage listener applies (app.js,
|
|
// cb-presets.js, map-tile-providers.js).
|
|
var LISTENER_KEYS = {
|
|
'meshcore-theme': true, 'meshcore-cb-preset': true,
|
|
'mc-dark-tile-provider': true, 'mc-light-tile-provider': true
|
|
};
|
|
// The map-tile-providers.js setter and getter of each tile key.
|
|
var TILE_API = {
|
|
'mc-dark-tile-provider': { set: 'MC_setDarkTileProvider', get: 'MC_getDarkTileProvider' },
|
|
'mc-light-tile-provider': { set: 'MC_setLightTileProvider', get: 'MC_getLightTileProvider' }
|
|
};
|
|
|
|
// Assigning localStorage.setItem would store a key named "setItem"
|
|
// (Storage has a named-property setter), so the wrap goes on the prototype.
|
|
var proto = window.Storage.prototype;
|
|
var origGet = proto.getItem, origSet = proto.setItem, origRemove = proto.removeItem;
|
|
|
|
var state = {
|
|
active: false, userId: null, policy: null,
|
|
base: {}, rev: 0, gen: '', hold: false, firstUpload: false,
|
|
dirty: false, seq: 0, pushing: null, pushTimer: null, retryTimer: null, pullTimer: null,
|
|
backoff: BACKOFF_MIN_MS, blocked: null, status: 'idle', lastSyncedAt: null, tooLarge: [],
|
|
// epoch changes on every activate/deactivate: an answer to a request
|
|
// from an earlier session is dropped. putsDone counts PUT answers, so a
|
|
// pull can tell a push finished while its GET was out.
|
|
epoch: 0, putsDone: 0
|
|
};
|
|
|
|
function rawGet(k) { var v = origGet.call(window.localStorage, k); return v === null ? undefined : v; }
|
|
function rawSet(k, v) { origSet.call(window.localStorage, k, v); }
|
|
function rawRemove(k) { origRemove.call(window.localStorage, k); }
|
|
|
|
function setPolicy(list) {
|
|
var byKey = Object.create(null);
|
|
list.forEach(function (e) { byKey[e.key] = e; });
|
|
state.policy = { list: list, byKey: byKey };
|
|
}
|
|
|
|
// pick keeps the allowlisted keys of o.
|
|
function pick(o) {
|
|
var out = {};
|
|
state.policy.list.forEach(function (e) { var v = own(o, e.key); if (v !== undefined) out[e.key] = v; });
|
|
return out;
|
|
}
|
|
|
|
function snapshot() {
|
|
var out = {};
|
|
state.policy.list.forEach(function (e) { var v = rawGet(e.key); if (v !== undefined) out[e.key] = v; });
|
|
return out;
|
|
}
|
|
|
|
function sameKeys(a, b) {
|
|
var ka = Object.keys(a);
|
|
return ka.length === Object.keys(b).length && ka.every(function (k) { return own(b, k) === a[k]; });
|
|
}
|
|
|
|
// The baseline is the last document this device and the account agreed
|
|
// on. It belongs to one user: another user's baseline counts as none. gen
|
|
// is the account document's generation: revisions restart at 1 after a
|
|
// delete, so a revision alone does not say which document it belongs to.
|
|
function loadBase(userId) {
|
|
try {
|
|
var b = JSON.parse(rawGet(BASE_KEY) || 'null');
|
|
if (b && b.user === userId && b.keys && typeof b.keys === 'object') {
|
|
return { keys: b.keys, rev: Number(rawGet(REV_KEY)) || 0, gen: typeof b.gen === 'string' ? b.gen : '', hold: !!b.hold };
|
|
}
|
|
} catch (e) { /* a damaged baseline counts as none */ }
|
|
return { keys: {}, rev: 0, gen: '', hold: false };
|
|
}
|
|
|
|
// refreshBase re-reads the stored baseline. localStorage is shared by
|
|
// every tab of this browser and another tab may have synced since this
|
|
// one last looked, so the stored copy is the truth, never the one in
|
|
// memory: merging against an older baseline brings back removals.
|
|
function refreshBase() {
|
|
var b = loadBase(state.userId);
|
|
state.base = b.keys;
|
|
state.rev = b.rev;
|
|
state.gen = b.gen;
|
|
state.hold = b.hold;
|
|
}
|
|
|
|
function saveBase(keys, rev, gen, hold) {
|
|
state.base = keys;
|
|
state.rev = rev;
|
|
state.gen = gen;
|
|
state.hold = hold;
|
|
rawSet(BASE_KEY, JSON.stringify({ user: state.userId, keys: keys, gen: gen, hold: hold }));
|
|
rawSet(REV_KEY, String(rev));
|
|
}
|
|
|
|
function setStatus(s) { state.status = s; renderStatus(); }
|
|
|
|
// quiet: writes made while applying a default are not the user's changes.
|
|
var quiet = false;
|
|
|
|
function watched(storage, k) {
|
|
return !quiet && state.active && !!state.policy && storage === window.localStorage && !!state.policy.byKey[k];
|
|
}
|
|
|
|
function install() {
|
|
proto.setItem = function (k, v) {
|
|
var watch = watched(this, k), before = watch ? origGet.call(this, k) : null;
|
|
origSet.call(this, k, v);
|
|
if (watch && before !== String(v)) markDirty();
|
|
};
|
|
proto.removeItem = function (k) {
|
|
var watch = watched(this, k), before = watch ? origGet.call(this, k) : null;
|
|
origRemove.call(this, k);
|
|
if (watch && before !== null) markDirty();
|
|
};
|
|
}
|
|
|
|
function uninstall() {
|
|
proto.setItem = origSet;
|
|
proto.removeItem = origRemove;
|
|
}
|
|
|
|
// markDirty runs on every allowlisted write (a range input writes many
|
|
// times per second). It needs only the hold flag, and a held baseline
|
|
// always has revision 0 and no keys: a stored revision above 0 rules the
|
|
// hold out without parsing the baseline, which can be 256 KiB. Another
|
|
// tab may have entered the hold, so the stored revision is read each time.
|
|
function markDirty() {
|
|
state.dirty = true;
|
|
state.seq++;
|
|
if (!(Number(rawGet(REV_KEY)) > 0)) {
|
|
refreshBase();
|
|
if (state.hold) saveBase(state.base, state.rev, state.gen, false); // the next change starts a new document
|
|
}
|
|
schedulePush(PUSH_DELAY_MS);
|
|
}
|
|
|
|
function schedulePush(ms) {
|
|
clearTimeout(state.pushTimer);
|
|
state.pushTimer = setTimeout(function () { state.pushTimer = null; push(); }, ms);
|
|
}
|
|
|
|
function retryLater() {
|
|
setStatus('retrying');
|
|
clearTimeout(state.retryTimer);
|
|
var wait = state.backoff;
|
|
state.backoff = Math.min(state.backoff * 2, BACKOFF_MAX_MS);
|
|
state.retryTimer = setTimeout(function () { state.retryTimer = null; push(); }, wait);
|
|
}
|
|
|
|
function synced(seq) {
|
|
state.backoff = BACKOFF_MIN_MS;
|
|
state.lastSyncedAt = new Date();
|
|
clearTimeout(state.retryTimer);
|
|
state.retryTimer = null;
|
|
if (state.seq === seq) state.dirty = false;
|
|
else schedulePush(PUSH_DELAY_MS); // written again while the request was out
|
|
setStatus('ok');
|
|
}
|
|
|
|
function largestKeys(keys) {
|
|
return Object.keys(keys).sort(function (a, b) { return keys[b].length - keys[a].length; }).slice(0, 3);
|
|
}
|
|
|
|
// midEdit: re-rendering now would throw away unsaved input. Account
|
|
// pages hold forms; the geofilter editor (customize-v2.js) is a modal
|
|
// over the page. The page the user opens next reads the new values.
|
|
function midEdit() {
|
|
return /^#\/account(\/|\?|$)/.test(location.hash) || !!document.getElementById('cv2-gf-modal-overlay');
|
|
}
|
|
|
|
// applyDefaultTile re-applies the effective provider of a removed tile
|
|
// key, as customize-v2.js resetAll does: the setter fires the change
|
|
// event the maps listen to, then the key it persisted is removed again.
|
|
function applyDefaultTile(k) {
|
|
var api = TILE_API[k];
|
|
quiet = true;
|
|
try { window[api.set](window[api.get]()); } finally { quiet = false; }
|
|
rawRemove(k);
|
|
}
|
|
|
|
// applyListenerKeys hands changed theme, colour-blind preset and map tile
|
|
// keys to their storage listeners. Those listeners ignore a removal, so a
|
|
// removed key gets the default the app starts with: the OS colour scheme
|
|
// (app.js), no preset (cb-presets.js clearPreset) and the effective tile
|
|
// provider (applyDefaultTile).
|
|
function applyListenerKeys(changed) {
|
|
changed.forEach(function (k) {
|
|
if (!LISTENER_KEYS[k]) return;
|
|
var v = rawGet(k);
|
|
if (v === undefined && k === 'meshcore-cb-preset') { window.MeshCorePresets.clearPreset(); return; }
|
|
if (v === undefined && TILE_API[k]) { applyDefaultTile(k); return; }
|
|
if (v === undefined && k === 'meshcore-theme') v = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
window.dispatchEvent(new StorageEvent('storage', { key: k, newValue: v === undefined ? null : v }));
|
|
});
|
|
}
|
|
|
|
// afterRemoteChange makes the running page show values that arrived from
|
|
// the account: the storage listeners, the customizer pipeline for its
|
|
// overrides, and a router re-render for everything a page reads at init.
|
|
// Before the customizer finished its init the pipeline is skipped: it
|
|
// would render without the server defaults, and the init reads the new
|
|
// overrides itself.
|
|
function afterRemoteChange(changed, firstLogin) {
|
|
applyListenerKeys(changed);
|
|
var cz = window._customizerV2;
|
|
if (changed.indexOf('cs-theme-overrides') !== -1 && cz && cz.initDone) cz.runPipeline();
|
|
if (!firstLogin) window.CSAuth.notify('Settings updated from another device');
|
|
if (!midEdit()) window.navigate();
|
|
}
|
|
|
|
function writeLocal(keys, changed) {
|
|
changed.forEach(function (k) {
|
|
var v = own(keys, k);
|
|
try {
|
|
if (v === undefined) rawRemove(k); else rawSet(k, v);
|
|
} catch (e) { console.warn('[settings-sync] could not store ' + k + ': ' + e.message); }
|
|
});
|
|
}
|
|
|
|
// applyProfile brings the account's document into this device: merge,
|
|
// write what changed, make it the new baseline. Returns true when this
|
|
// device holds values the account lacks. A baseline of another generation
|
|
// (the copy was deleted and started again) counts as none, so nothing on
|
|
// this device is taken for a removal made elsewhere. On the first login
|
|
// (no baseline at all) the first-login toast replaces "updated from
|
|
// another device": after the upload of this device's values, or at once
|
|
// when there is nothing to upload.
|
|
function applyProfile(rev, gen, doc) {
|
|
refreshBase();
|
|
var firstLogin = !state.rev && !state.gen && !state.hold;
|
|
var local = snapshot();
|
|
if (!rev) {
|
|
if (state.rev > 0 || state.hold) {
|
|
// The account's copy was deleted, here or on another device. Keep
|
|
// this device as it is; its next change starts a new document.
|
|
saveBase({}, 0, '', true);
|
|
return false;
|
|
}
|
|
state.firstUpload = true; // first login: this device's values form the first document
|
|
return Object.keys(local).length > 0;
|
|
}
|
|
var profile = pick((doc && doc.keys) || {});
|
|
var m = mergeDocs(local, profile, state.gen === gen ? state.base : {}, state.policy.list);
|
|
writeLocal(m.keys, m.localChanges);
|
|
// The baseline can be 256 KiB: an unchanged pull does not rewrite it.
|
|
if (state.rev !== rev || state.gen !== gen || state.hold || !sameKeys(profile, state.base)) saveBase(profile, rev, gen, false);
|
|
if (m.localChanges.length) afterRemoteChange(m.localChanges, firstLogin);
|
|
if (firstLogin && m.differsFromProfile) state.firstUpload = true;
|
|
else if (firstLogin) window.CSAuth.notify(FIRST_LOGIN_TEXT);
|
|
return m.differsFromProfile;
|
|
}
|
|
|
|
// push sends this device's allowlisted values. A 409 merges the returned
|
|
// document and retries (at most MAX_CONFLICT_RETRIES); a 403 is retried
|
|
// once after onForbidden (retried403 marks that retry); network errors,
|
|
// 5xx and 429 retry with backoff. Resolves true when the account holds
|
|
// this device's values.
|
|
function push(attempt, retried403) {
|
|
if (!state.active || state.blocked || !state.policy) return Promise.resolve(false);
|
|
if (state.pushing) return state.pushing.then(function () { return state.dirty ? push() : true; });
|
|
attempt = attempt || 0;
|
|
refreshBase();
|
|
var keys = snapshot(), seq = state.seq, epoch = state.epoch;
|
|
if (state.rev > 0 && sameKeys(keys, state.base)) { synced(seq); return Promise.resolve(true); }
|
|
setStatus('syncing');
|
|
var p = window.CSAuth.request('PUT', '/api/account/settings', { baseRevision: state.rev, baseGeneration: state.gen, doc: { v: 1, keys: keys } })
|
|
.then(function (r) {
|
|
if (state.epoch !== epoch) return false; // logged out (and maybe in again) meanwhile
|
|
state.pushing = null;
|
|
state.putsDone++;
|
|
if (r.ok) {
|
|
saveBase(keys, r.data.revision, r.data.generation, false);
|
|
synced(seq);
|
|
if (state.firstUpload) {
|
|
state.firstUpload = false;
|
|
window.CSAuth.notify(FIRST_LOGIN_TEXT);
|
|
}
|
|
return true;
|
|
}
|
|
if (r.status === 409 && attempt < MAX_CONFLICT_RETRIES) {
|
|
applyProfile(r.data.revision, r.data.generation, r.data.doc);
|
|
return push(attempt + 1, retried403);
|
|
}
|
|
if (r.status === 413) {
|
|
state.blocked = 'too-large';
|
|
state.tooLarge = largestKeys(keys);
|
|
setStatus('too-large');
|
|
return false;
|
|
}
|
|
if (r.status === 400) {
|
|
console.error('[settings-sync] the server refused the settings: ' + (r.data && r.data.error));
|
|
state.blocked = 'rejected';
|
|
setStatus('rejected');
|
|
return false;
|
|
}
|
|
if (r.status === 403) {
|
|
// Held as the push in flight until the retry starts, so no other
|
|
// push runs while the session is checked.
|
|
var next = onForbidden(epoch, !retried403).then(function (retry) {
|
|
if (state.pushing === next) state.pushing = null;
|
|
return retry ? push(attempt, true) : false;
|
|
});
|
|
state.pushing = next;
|
|
return next;
|
|
}
|
|
if (r.status !== 401) retryLater(); // 401: auth.js logged out, which deactivates this module
|
|
return false;
|
|
}, function () {
|
|
if (state.epoch !== epoch) return false;
|
|
state.pushing = null;
|
|
retryLater();
|
|
return false;
|
|
});
|
|
state.pushing = p;
|
|
return p;
|
|
}
|
|
|
|
// onForbidden handles a 403: this tab's CSRF token belongs to an older
|
|
// session (a logout and login in another tab). It asks who is logged in
|
|
// now, which also stores the session's current token. Another user, or
|
|
// nobody, arrives as cs-auth-changed and re-activates or deactivates this
|
|
// module (the epoch moves). For the same user it resolves true when
|
|
// mayRetry (the request goes again once with the new token); otherwise
|
|
// this tab stops syncing until a reload, because the next attempt would
|
|
// get the same answer (an origin misconfiguration, for one).
|
|
function onForbidden(epoch, mayRetry) {
|
|
clearTimeout(state.retryTimer);
|
|
state.retryTimer = null;
|
|
var stop = function () {
|
|
if (state.epoch !== epoch) return false;
|
|
state.blocked = 'forbidden';
|
|
setStatus('forbidden');
|
|
return false;
|
|
};
|
|
return window.CSAuth.refreshMe().then(function () {
|
|
if (state.epoch !== epoch) return false;
|
|
return mayRetry ? true : stop();
|
|
}, function (e) {
|
|
console.error('[settings-sync] could not check the session after a 403: ' + (e && e.message));
|
|
return stop();
|
|
});
|
|
}
|
|
|
|
// pull fetches the account's document. An answer is dropped when the
|
|
// session changed or a push finished (or the stored baseline moved, also
|
|
// by another tab) while the GET was out: it describes the account before
|
|
// that push, and applying it would revert that change or mistake the
|
|
// first upload's revision-0 answer for a deleted copy. The next pull
|
|
// catches up.
|
|
function pull() {
|
|
if (!state.active) return Promise.resolve();
|
|
if (state.pushing) return state.pushing.then(pull);
|
|
refreshBase();
|
|
var epoch = state.epoch, puts = state.putsDone, rev = state.rev, gen = state.gen;
|
|
var stale = function () {
|
|
if (state.epoch !== epoch || state.putsDone !== puts) return true;
|
|
refreshBase();
|
|
return state.rev !== rev || state.gen !== gen;
|
|
};
|
|
return window.CSAuth.request('GET', '/api/account/settings').then(function (r) {
|
|
if (stale()) return;
|
|
if (!r.ok) {
|
|
if (r.status === 403) onForbidden(epoch, false);
|
|
else if (r.status !== 401) setStatus('retrying');
|
|
return;
|
|
}
|
|
setPolicy(r.data.allowlist || []);
|
|
if (applyProfile(r.data.revision, r.data.generation, r.data.doc)) {
|
|
state.dirty = true;
|
|
state.seq++;
|
|
return push();
|
|
}
|
|
if (state.dirty) return push();
|
|
if (r.data.revision) { state.lastSyncedAt = new Date(); setStatus('ok'); }
|
|
else setStatus(state.hold ? 'held' : 'idle');
|
|
}, function () { if (!stale()) setStatus('retrying'); });
|
|
}
|
|
|
|
// syncNow: the account page button. Retries after a 413 (the user may
|
|
// have trimmed), never after a 400 (that needs a reload).
|
|
function syncNow() {
|
|
if (!state.active) return Promise.resolve();
|
|
if (state.blocked === 'too-large') state.blocked = null;
|
|
state.backoff = BACKOFF_MIN_MS;
|
|
clearTimeout(state.retryTimer);
|
|
state.retryTimer = null;
|
|
return pull();
|
|
}
|
|
|
|
function onVisible() {
|
|
if (document.visibilityState !== 'visible') return;
|
|
clearTimeout(state.retryTimer);
|
|
state.retryTimer = null;
|
|
pull();
|
|
}
|
|
|
|
function activate(user) {
|
|
if (state.active && state.userId === user.id) return Promise.resolve();
|
|
if (state.active) deactivate();
|
|
state.epoch++;
|
|
state.active = true;
|
|
state.userId = user.id;
|
|
state.policy = null;
|
|
refreshBase();
|
|
state.firstUpload = false;
|
|
state.dirty = false;
|
|
state.blocked = null;
|
|
state.backoff = BACKOFF_MIN_MS;
|
|
install();
|
|
window.CSAuth.setLogoutHandler(onLogout);
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
state.pullTimer = setInterval(function () { if (document.visibilityState === 'visible') pull(); }, PULL_EVERY_MS);
|
|
return pull();
|
|
}
|
|
|
|
function deactivate() {
|
|
state.active = false;
|
|
state.epoch++;
|
|
clearTimeout(state.pushTimer);
|
|
clearTimeout(state.retryTimer);
|
|
clearInterval(state.pullTimer);
|
|
state.pushTimer = state.retryTimer = state.pullTimer = null;
|
|
document.removeEventListener('visibilitychange', onVisible);
|
|
uninstall();
|
|
window.CSAuth.setLogoutHandler(null);
|
|
state.policy = null;
|
|
state.userId = null;
|
|
state.pushing = null;
|
|
setStatus('idle');
|
|
}
|
|
|
|
// ── Dialog, logout and the account-page section ──
|
|
|
|
// showDialog uses the app's modal pattern (.modal-overlay + .modal, as
|
|
// the BYOP dialog in packets.js): role=dialog, focus on the choice
|
|
// opts.focus names (default the first), Tab trapped, Escape or a
|
|
// backdrop click dismiss. Resolves with the chosen id, or null when
|
|
// dismissed.
|
|
function showDialog(opts) {
|
|
return new Promise(function (resolve) {
|
|
var prev = document.activeElement;
|
|
var overlay = document.createElement('div');
|
|
overlay.className = 'modal-overlay cs-dialog-overlay';
|
|
overlay.innerHTML = '<div class="modal cs-dialog" role="dialog" aria-modal="true" aria-labelledby="csDialogTitle" aria-describedby="csDialogText">' +
|
|
'<h3 id="csDialogTitle">' + escapeHtml(opts.title) + '</h3>' +
|
|
'<div id="csDialogText">' + opts.text.map(function (t) { return '<p class="account-hint">' + escapeHtml(t) + '</p>'; }).join('') + '</div>' +
|
|
'<div class="cs-dialog-actions">' + opts.choices.map(function (c) {
|
|
return '<button type="button" class="account-btn ' + (c.primary ? 'account-btn-primary' : 'account-btn-secondary') +
|
|
'" data-choice="' + escapeHtml(c.id) + '">' + escapeHtml(c.label) + '</button>';
|
|
}).join('') + '</div></div>';
|
|
document.body.appendChild(overlay);
|
|
var buttons = overlay.querySelectorAll('button');
|
|
function close(choice) {
|
|
overlay.remove();
|
|
if (prev && prev.focus) prev.focus();
|
|
resolve(choice);
|
|
}
|
|
overlay.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(null); return; }
|
|
if (e.key !== 'Tab') return;
|
|
var first = buttons[0], last = buttons[buttons.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
});
|
|
overlay.addEventListener('click', function (e) {
|
|
var btn = e.target.closest && e.target.closest('[data-choice]');
|
|
if (btn) close(btn.getAttribute('data-choice'));
|
|
else if (e.target === overlay) close(null);
|
|
});
|
|
var start = buttons[0];
|
|
for (var i = 0; i < opts.choices.length; i++) if (opts.choices[i].id === opts.focus) start = buttons[i];
|
|
start.focus();
|
|
});
|
|
}
|
|
var dialog = showDialog;
|
|
|
|
// unpushed says whether storage holds allowlisted values the stored
|
|
// baseline lacks. Storage is shared by every tab, so this sees a change
|
|
// another tab made and has not pushed yet. A hold means the account's
|
|
// copy was deleted and nothing changed since (a change ends the hold).
|
|
// Without a policy no write was watched yet, so nothing is pending.
|
|
function unpushed() {
|
|
if (!state.policy) return false;
|
|
refreshBase();
|
|
return !state.hold && !sameKeys(snapshot(), state.base);
|
|
}
|
|
|
|
// flush pushes pending changes now. Resolves true when the account holds
|
|
// everything this device has, false when the push failed or took longer
|
|
// than FLUSH_TIMEOUT_MS (a hanging connection must not hold the logout).
|
|
function flush() {
|
|
clearTimeout(state.pushTimer);
|
|
state.pushTimer = null;
|
|
if (!unpushed()) return Promise.resolve(true);
|
|
return new Promise(function (resolve) {
|
|
var timer = setTimeout(function () {
|
|
console.warn('[settings-sync] pending changes were not pushed within ' + FLUSH_TIMEOUT_MS / 1000 + ' s');
|
|
resolve(false);
|
|
}, FLUSH_TIMEOUT_MS);
|
|
push().then(function (ok) {
|
|
clearTimeout(timer);
|
|
resolve(ok && !state.dirty);
|
|
}, function (e) {
|
|
clearTimeout(timer);
|
|
console.error('[settings-sync] push failed: ' + (e && e.message));
|
|
resolve(false);
|
|
});
|
|
});
|
|
}
|
|
|
|
// removeLocal deletes the synced keys in list and the baseline, then
|
|
// shows the defaults at once: the customizer's own Reset All teardown
|
|
// (it touches only keys removed here) and the theme and preset defaults.
|
|
// It runs right before the logout clears the user, so the wrap comes off
|
|
// first: the teardown's writes are not changes to push. Channel keys and
|
|
// the API key are never on the allowlist, so they stay.
|
|
function removeLocal(list) {
|
|
uninstall();
|
|
var removed = list.map(function (e) { return e.key; }).filter(function (k) { return rawGet(k) !== undefined; });
|
|
removed.forEach(rawRemove);
|
|
rawRemove(BASE_KEY);
|
|
rawRemove(REV_KEY);
|
|
var cz = window._customizerV2;
|
|
if (cz && cz.initDone) { cz.resetAll(); cz.runPipeline(); }
|
|
applyListenerKeys(removed);
|
|
}
|
|
|
|
var LOGOUT_TEXT = [
|
|
'Your settings stay saved in your account.',
|
|
'Channel keys are never synced and stay on this device. They are not removed, because no copy exists anywhere else.'
|
|
];
|
|
|
|
// onLogout is CSAuth's logout handler while active (see auth.js logout).
|
|
function onLogout() {
|
|
return dialog({
|
|
title: 'Log out',
|
|
text: LOGOUT_TEXT,
|
|
choices: [
|
|
{ id: 'keep', label: 'Keep my settings on this device', primary: true },
|
|
{ id: 'remove', label: 'Remove my settings from this device' },
|
|
{ id: 'cancel', label: 'Cancel' }
|
|
]
|
|
}).then(function (choice) {
|
|
if (choice !== 'keep' && choice !== 'remove') return { cancel: true };
|
|
return flush().then(function (saved) {
|
|
if (choice === 'keep') return {};
|
|
if (!saved || !state.policy) {
|
|
// Removing now would lose changes that exist nowhere else.
|
|
return { afterLogout: function () { window.CSAuth.notify('Your latest settings could not be saved to your account, so they stay on this device.'); } };
|
|
}
|
|
var list = state.policy.list;
|
|
return { afterLogout: function () { removeLocal(list); } };
|
|
});
|
|
});
|
|
}
|
|
|
|
// deleteRemote removes the account's copy; this device keeps its values
|
|
// and its next change starts a new document. A push in flight is waited
|
|
// for and pending pushes are cancelled first, so no PUT (or its late
|
|
// answer) recreates the copy or clears the hold. When the DELETE fails,
|
|
// unsynced changes go back to the retry backoff.
|
|
function stopPushing() {
|
|
clearTimeout(state.pushTimer);
|
|
clearTimeout(state.retryTimer);
|
|
state.pushTimer = state.retryTimer = null;
|
|
}
|
|
|
|
function deleteRemote() {
|
|
var retryIfDirty = function () { if (state.active && state.dirty) retryLater(); };
|
|
return Promise.resolve(state.pushing).then(function () {
|
|
stopPushing();
|
|
return window.CSAuth.request('DELETE', '/api/account/settings');
|
|
}).then(function (r) {
|
|
if (r.ok) {
|
|
stopPushing();
|
|
state.dirty = false;
|
|
saveBase({}, 0, '', true);
|
|
setStatus('held');
|
|
} else retryIfDirty();
|
|
return r;
|
|
}, function (e) { retryIfDirty(); throw e; });
|
|
}
|
|
|
|
function statusText() {
|
|
switch (state.status) {
|
|
case 'ok': return 'Last synced ' + state.lastSyncedAt.toLocaleString();
|
|
case 'syncing': return 'Syncing…';
|
|
case 'retrying': return 'Not synced: retrying';
|
|
case 'too-large': return 'Not synced: your settings are larger than your account can hold. Largest: ' + state.tooLarge.join(', ');
|
|
case 'rejected': return 'Not synced: the server refused your settings. Reload the page to try again.';
|
|
case 'forbidden': return 'Not synced: this tab is out of date. Reload the page to sync again.';
|
|
case 'held': return 'No settings saved in your account. Your next change starts a new copy.';
|
|
default: return 'Not synced yet';
|
|
}
|
|
}
|
|
|
|
function renderStatus() {
|
|
var el = document.getElementById('syncStatus');
|
|
if (!el) return;
|
|
el.textContent = statusText();
|
|
el.classList.toggle('ok', state.status === 'ok');
|
|
el.classList.toggle('err', state.status === 'retrying' || state.status === 'too-large' || state.status === 'rejected' || state.status === 'forbidden');
|
|
}
|
|
|
|
function mountSection(el) {
|
|
el.innerHTML =
|
|
'<p class="account-msg" id="syncStatus" role="status" aria-live="polite"></p>' +
|
|
'<div class="account-actions">' +
|
|
'<button type="button" id="syncNow" class="account-btn account-btn-secondary">Sync now</button>' +
|
|
'<button type="button" id="syncDelete" class="account-btn account-btn-secondary">Delete synced settings from my account</button>' +
|
|
'</div>' +
|
|
'<p class="account-hint">Synced: your nodes, favorites, theme and customizer settings, saved packet filters, and the filter, sort and view choices of each page.</p>' +
|
|
'<p class="account-hint">Not synced: channel keys and decrypted messages, the API key, panel and column sizes, collapsed panels and map positions.</p>' +
|
|
'<p class="account-msg" id="syncMsg" role="status" aria-live="polite"></p>';
|
|
renderStatus();
|
|
document.getElementById('syncNow').addEventListener('click', function () { return syncNow(); });
|
|
document.getElementById('syncDelete').addEventListener('click', function () {
|
|
return dialog({
|
|
title: 'Delete synced settings',
|
|
text: ['This deletes the copy of your settings stored in your account. The settings on this device stay. Your next change starts a new copy.'],
|
|
choices: [{ id: 'delete', label: 'Delete synced settings', primary: true }, { id: 'cancel', label: 'Cancel' }],
|
|
focus: 'cancel' // the destructive choice is never the default
|
|
}).then(function (choice) {
|
|
if (choice !== 'delete') return;
|
|
return deleteRemote().then(function (r) {
|
|
window.CSAuth.say('syncMsg', r.ok ? 'Synced settings deleted from your account.' : window.CSAuth.errText(r), r.ok);
|
|
}, function () { window.CSAuth.say('syncMsg', 'Network error, try again.', false); });
|
|
});
|
|
});
|
|
}
|
|
|
|
window.addEventListener('cs-auth-changed', function (e) {
|
|
if (!window.CSAuth.isEnabled()) return;
|
|
if (e.detail) activate(e.detail);
|
|
else if (state.active) deactivate();
|
|
});
|
|
window.CSAuth.ready().then(function (u) { if (u && window.CSAuth.isEnabled()) activate(u); });
|
|
|
|
window.CSSettingsSync = {
|
|
mountSection: mountSection,
|
|
syncNow: syncNow,
|
|
_test: {
|
|
mergeDocs: mergeDocs, state: state, activate: activate, deactivate: deactivate, pull: pull, push: push,
|
|
midEdit: midEdit, flush: flush, onLogout: onLogout, deleteRemote: deleteRemote, statusText: statusText,
|
|
showDialog: showDialog, useDialog: function (fn) { dialog = fn; }
|
|
}
|
|
};
|
|
})();
|