mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-11 10:17:17 +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>
235 lines
11 KiB
Go
235 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const settingsPath = "/api/account/settings"
|
|
|
|
func settingsDocWith(kv ...string) *settingsDoc {
|
|
d := &settingsDoc{V: 1, Keys: map[string]string{}}
|
|
for i := 0; i+1 < len(kv); i += 2 {
|
|
d.Keys[kv[i]] = kv[i+1]
|
|
}
|
|
return d
|
|
}
|
|
|
|
// rawJSON encodes like a browser's JSON.stringify: no HTML escaping.
|
|
func rawJSON(t *testing.T, v any) string {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return strings.TrimRight(buf.String(), "\n")
|
|
}
|
|
|
|
// doRaw is f.do with a body sent byte for byte (f.do's json.Marshal
|
|
// escapes <, > and &, which JSON.stringify does not).
|
|
func (f *authFixture) doRaw(method, path, body string, mods ...reqMod) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
req.RemoteAddr = "203.0.113.10:5555"
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Origin", testBase)
|
|
for _, m := range mods {
|
|
m(req)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
f.router.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func TestSettingsGetWithoutDocument(t *testing.T) {
|
|
f := newAuthFixture(t)
|
|
c := f.registerAndActivate(t, "sync1@example.org", "Sync One", pw)
|
|
w := f.do("GET", settingsPath, nil, as(c))
|
|
expectStatus(t, w, 200)
|
|
if !strings.Contains(w.Body.String(), `"doc":null`) {
|
|
t.Fatalf("doc not null at revision 0: %s", w.Body.String())
|
|
}
|
|
got := decode[settingsGetResponse](t, w)
|
|
if got.Revision != 0 || got.Generation != "" || got.Doc != nil || len(got.Allowlist) != len(settingsAllowlist) {
|
|
t.Fatalf("GET = rev %d generation %q doc %v allowlist %d", got.Revision, got.Generation, got.Doc, len(got.Allowlist))
|
|
}
|
|
}
|
|
|
|
func TestSettingsPutGetAndConflict(t *testing.T) {
|
|
f := newAuthFixture(t)
|
|
c := f.registerAndActivate(t, "sync2@example.org", "Sync Two", pw)
|
|
w := f.do("PUT", settingsPath, settingsPutRequest{BaseRevision: 0, Doc: settingsDocWith("meshcore-favorites", `["aa"]`)}, as(c))
|
|
expectStatus(t, w, 200)
|
|
first := decode[settingsPutResponse](t, w)
|
|
if first.Revision != 1 || len(first.Generation) != 32 {
|
|
t.Fatalf("first write = %+v; want revision 1 and a generation", first)
|
|
}
|
|
// A second device still at revision 0 gets 409 with the current document.
|
|
w = f.do("PUT", settingsPath, settingsPutRequest{BaseRevision: 0, Doc: settingsDocWith("meshcore-theme", "dark")}, as(c))
|
|
expectStatus(t, w, 409)
|
|
conf := decode[settingsConflictResponse](t, w)
|
|
if conf.Revision != 1 || conf.Generation != first.Generation || conf.Doc == nil || conf.Doc.Keys["meshcore-favorites"] != `["aa"]` {
|
|
t.Fatalf("409 body = %+v", conf)
|
|
}
|
|
w = f.do("PUT", settingsPath, settingsPutRequest{BaseRevision: 1, BaseGeneration: first.Generation, Doc: settingsDocWith("meshcore-favorites", `["aa"]`, "meshcore-theme", "dark")}, as(c))
|
|
expectStatus(t, w, 200)
|
|
if second := decode[settingsPutResponse](t, w); second.Revision != 2 || second.Generation != first.Generation {
|
|
t.Fatalf("second write = %+v; want revision 2 in generation %s", second, first.Generation)
|
|
}
|
|
got := decode[settingsGetResponse](t, f.do("GET", settingsPath, nil, as(c)))
|
|
if got.Revision != 2 || got.Generation != first.Generation || got.Doc.Keys["meshcore-theme"] != "dark" || got.Doc.V != 1 {
|
|
t.Fatalf("GET after two writes = %+v", got)
|
|
}
|
|
}
|
|
|
|
// After DELETE the revisions restart at 1. A device that synced the old
|
|
// document must get 409 even when its revision matches the new document's.
|
|
func TestSettingsStaleGenerationConflicts(t *testing.T) {
|
|
f := newAuthFixture(t)
|
|
c := f.registerAndActivate(t, "sync11@example.org", "Sync Eleven", pw)
|
|
old := decode[settingsPutResponse](t, f.do("PUT", settingsPath, settingsPutRequest{Doc: settingsDocWith("meshcore-theme", "dark")}, as(c)))
|
|
expectStatus(t, f.do("DELETE", settingsPath, nil, as(c)), 200)
|
|
cur := decode[settingsPutResponse](t, f.do("PUT", settingsPath, settingsPutRequest{Doc: settingsDocWith("meshcore-theme", "light")}, as(c)))
|
|
if cur.Revision != old.Revision || cur.Generation == old.Generation {
|
|
t.Fatalf("new document %+v; old %+v", cur, old)
|
|
}
|
|
w := f.do("PUT", settingsPath, settingsPutRequest{BaseRevision: old.Revision, BaseGeneration: old.Generation, Doc: settingsDocWith("meshcore-theme", "stale")}, as(c))
|
|
expectStatus(t, w, 409)
|
|
conf := decode[settingsConflictResponse](t, w)
|
|
if conf.Revision != cur.Revision || conf.Generation != cur.Generation || conf.Doc.Keys["meshcore-theme"] != "light" {
|
|
t.Fatalf("409 body = %+v", conf)
|
|
}
|
|
}
|
|
|
|
func TestSettingsValuesStoredVerbatim(t *testing.T) {
|
|
f := newAuthFixture(t)
|
|
c := f.registerAndActivate(t, "sync3@example.org", "Sync Three", pw)
|
|
val := `{"name":"<b>A&B</b> \"quoted\" é ✓"}`
|
|
body := rawJSON(t, settingsPutRequest{BaseRevision: 0, Doc: settingsDocWith("cs-theme-overrides", val)})
|
|
expectStatus(t, f.doRaw("PUT", settingsPath, body, as(c)), 200)
|
|
got := decode[settingsGetResponse](t, f.do("GET", settingsPath, nil, as(c)))
|
|
if got.Doc.Keys["cs-theme-overrides"] != val {
|
|
t.Fatalf("value = %q; want %q", got.Doc.Keys["cs-theme-overrides"], val)
|
|
}
|
|
}
|
|
|
|
func TestSettingsRejectsBadShapeAndKeys(t *testing.T) {
|
|
f := newAuthFixture(t)
|
|
c := f.registerAndActivate(t, "sync4@example.org", "Sync Four", pw)
|
|
type extraFieldReq struct {
|
|
BaseRevision int64 `json:"baseRevision"`
|
|
BaseGeneration string `json:"baseGeneration"`
|
|
Doc *settingsDoc `json:"doc"`
|
|
Extra int `json:"extra"`
|
|
}
|
|
type numberDoc struct {
|
|
V int `json:"v"`
|
|
Keys map[string]int `json:"keys"`
|
|
}
|
|
type numberValueReq struct {
|
|
BaseRevision int64 `json:"baseRevision"`
|
|
Doc numberDoc `json:"doc"`
|
|
}
|
|
bad := []any{
|
|
settingsPutRequest{BaseRevision: 0},
|
|
settingsPutRequest{BaseRevision: 0, Doc: &settingsDoc{V: 2, Keys: map[string]string{}}},
|
|
settingsPutRequest{BaseRevision: 0, Doc: &settingsDoc{V: 1}},
|
|
settingsPutRequest{BaseRevision: 0, Doc: settingsDocWith("panel-drag-packets", "1")},
|
|
settingsPutRequest{BaseRevision: 0, Doc: settingsDocWith("cs-settings-sync-base", "{}")},
|
|
extraFieldReq{BaseRevision: 0, Doc: settingsDocWith("meshcore-theme", "dark"), Extra: 1},
|
|
numberValueReq{BaseRevision: 0, Doc: numberDoc{V: 1, Keys: map[string]int{"meshcore-theme": 1}}},
|
|
}
|
|
for i, b := range bad {
|
|
if w := f.do("PUT", settingsPath, b, as(c)); w.Code != 400 {
|
|
t.Errorf("case %d: status %d; want 400; body %s", i, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
if got := decode[settingsGetResponse](t, f.do("GET", settingsPath, nil, as(c))); got.Revision != 0 {
|
|
t.Fatalf("a refused PUT stored something: rev %d", got.Revision)
|
|
}
|
|
}
|
|
|
|
func TestSettingsDenylistWinsOverAllowlist(t *testing.T) {
|
|
saved := settingsAllowlist
|
|
t.Cleanup(func() { settingsAllowlist = saved })
|
|
settingsAllowlist = append(append([]settingsKey{}, saved...),
|
|
settingsKey{Key: "corescope_channel_keys", Kind: settingsKindScalar},
|
|
settingsKey{Key: "corescope_channel_other", Kind: settingsKindScalar},
|
|
settingsKey{Key: "meshcore-api-key", Kind: settingsKindScalar})
|
|
f := newAuthFixture(t)
|
|
c := f.registerAndActivate(t, "sync5@example.org", "Sync Five", pw)
|
|
for _, k := range []string{"corescope_channel_keys", "corescope_channel_labels", "corescope_channel_other", "meshcore-api-key"} {
|
|
w := f.do("PUT", settingsPath, settingsPutRequest{BaseRevision: 0, Doc: settingsDocWith(k, "secret")}, as(c))
|
|
expectStatus(t, w, 400)
|
|
if !strings.Contains(w.Body.String(), "never synced") {
|
|
t.Errorf("%s: body %s", k, w.Body.String())
|
|
}
|
|
}
|
|
for _, k := range decode[settingsGetResponse](t, f.do("GET", settingsPath, nil, as(c))).Allowlist {
|
|
if settingsDenied(k.Key) {
|
|
t.Errorf("GET advertises denied key %q", k.Key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSettingsSizeCap(t *testing.T) {
|
|
f := newAuthFixture(t)
|
|
c := f.registerAndActivate(t, "sync6@example.org", "Sync Six", pw)
|
|
envelope := len(`{"v":1,"keys":{"cs-theme-overrides":""}}`)
|
|
put := func(base int64, val string) *httptest.ResponseRecorder {
|
|
return f.doRaw("PUT", settingsPath, rawJSON(t, settingsPutRequest{BaseRevision: base, Doc: settingsDocWith("cs-theme-overrides", val)}), as(c))
|
|
}
|
|
expectStatus(t, put(0, strings.Repeat("x", settingsDocMaxBytes-envelope+1)), 413)
|
|
expectStatus(t, put(0, strings.Repeat("x", settingsBodyMax)), 413)
|
|
// Exactly at the cap is accepted. '<' is 1 byte for JSON.stringify but 6
|
|
// for Go's default encoder: the cap must be measured like the browser.
|
|
expectStatus(t, put(0, strings.Repeat("<", settingsDocMaxBytes-envelope)), 200)
|
|
}
|
|
|
|
func TestSettingsRateLimitPerUser(t *testing.T) {
|
|
f := newAuthFixture(t)
|
|
c := f.registerAndActivate(t, "sync7@example.org", "Sync Seven", pw)
|
|
d := f.registerAndActivate(t, "sync8@example.org", "Sync Eight", pw)
|
|
f.srv.auth.settingsPut = newRateLimiter(2, time.Hour)
|
|
v := decode[settingsPutResponse](t, f.do("PUT", settingsPath, settingsPutRequest{BaseRevision: 0, Doc: settingsDocWith()}, as(c)))
|
|
expectStatus(t, f.do("PUT", settingsPath, settingsPutRequest{BaseRevision: 1, BaseGeneration: v.Generation, Doc: settingsDocWith()}, as(c)), 200)
|
|
w := f.do("PUT", settingsPath, settingsPutRequest{BaseRevision: 2, BaseGeneration: v.Generation, Doc: settingsDocWith()}, as(c))
|
|
expectStatus(t, w, 429)
|
|
if w.Header().Get("Retry-After") == "" {
|
|
t.Fatal("429 without Retry-After")
|
|
}
|
|
// Same IP, other user: not limited.
|
|
expectStatus(t, f.do("PUT", settingsPath, settingsPutRequest{BaseRevision: 0, Doc: settingsDocWith()}, as(d)), 200)
|
|
}
|
|
|
|
func TestSettingsNeedSessionAndCSRF(t *testing.T) {
|
|
f := newAuthFixture(t)
|
|
c := f.registerAndActivate(t, "sync9@example.org", "Sync Nine", pw)
|
|
noCSRF := &client{cookie: c.cookie}
|
|
expectStatus(t, f.do("GET", settingsPath, nil), 401)
|
|
expectStatus(t, f.do("PUT", settingsPath, settingsPutRequest{Doc: settingsDocWith()}, as(noCSRF)), 403)
|
|
expectStatus(t, f.do("PUT", settingsPath, settingsPutRequest{Doc: settingsDocWith()}, as(c), header("Origin", "https://evil.example")), 403)
|
|
expectStatus(t, f.do("DELETE", settingsPath, nil, as(noCSRF)), 403)
|
|
}
|
|
|
|
func TestSettingsDeleteAndAccountDelete(t *testing.T) {
|
|
f := newAuthFixture(t)
|
|
c := f.registerAndActivate(t, "sync10@example.org", "Sync Ten", pw)
|
|
expectStatus(t, f.do("PUT", settingsPath, settingsPutRequest{Doc: settingsDocWith("meshcore-theme", "dark")}, as(c)), 200)
|
|
expectStatus(t, f.do("DELETE", settingsPath, nil, as(c)), 200)
|
|
if got := decode[settingsGetResponse](t, f.do("GET", settingsPath, nil, as(c))); got.Revision != 0 || got.Doc != nil {
|
|
t.Fatalf("after DELETE: %+v", got)
|
|
}
|
|
// The next change starts a new document.
|
|
expectStatus(t, f.do("PUT", settingsPath, settingsPutRequest{Doc: settingsDocWith("meshcore-theme", "light")}, as(c)), 200)
|
|
expectStatus(t, f.do("DELETE", "/api/account", passwordConfirmRequest{CurrentPassword: pw}, as(c)), 200)
|
|
if _, v, err := f.st.GetSettings(c.me.ID); err != nil || v.Revision != 0 {
|
|
t.Fatalf("settings after account delete: %+v, %v", v, err)
|
|
}
|
|
}
|