Files
meshcore-analyzer/cmd/server/auth_ratelimit.go
T
efitenandClaude Opus 5.5 f3ae8ac25a feat: sync a logged-in user's settings across devices (part B) (#2130)
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>
2026-10-07 11:20:44 +02:00

113 lines
3.2 KiB
Go

package main
import (
"log"
"math"
"net/http"
"strconv"
"sync"
"time"
)
// rateLimiter is a keyed token bucket: n requests per period, refilled
// continuously. In-memory, so limits reset on restart. That is acceptable
// for brute-force damping, not a quota system.
type rateLimiter struct {
mu sync.Mutex
rate float64 // tokens per second
burst float64
buckets map[string]*rlBucket
now func() time.Time
maxBuckets int // cap on len(buckets); a field so tests can lower it
warnFull sync.Once
}
const rateLimiterMaxBuckets = 100000
type rlBucket struct {
tokens float64
last time.Time
}
func newRateLimiter(n int, per time.Duration) *rateLimiter {
return &rateLimiter{rate: float64(n) / per.Seconds(), burst: float64(n),
buckets: map[string]*rlBucket{}, now: time.Now, maxBuckets: rateLimiterMaxBuckets}
}
// take consumes one token for key. When none is left it reports how long
// until the next one.
func (l *rateLimiter) take(key string) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
b := l.buckets[key]
if b == nil {
if len(l.buckets) >= l.maxBuckets {
l.sweepLocked(now)
if len(l.buckets) >= l.maxBuckets {
l.warnFull.Do(func() {
log.Printf("[users] rate limiter full (%d keys), refusing new keys until buckets refill", l.maxBuckets)
})
return false, time.Duration(1 / l.rate * float64(time.Second))
}
}
b = &rlBucket{tokens: l.burst, last: now}
l.buckets[key] = b
}
b.tokens = math.Min(l.burst, b.tokens+now.Sub(b.last).Seconds()*l.rate)
b.last = now
if b.tokens >= 1 {
b.tokens--
return true, 0
}
return false, time.Duration((1 - b.tokens) / l.rate * float64(time.Second))
}
// gc drops buckets that have refilled completely (they carry no state).
func (l *rateLimiter) gc() {
l.mu.Lock()
defer l.mu.Unlock()
l.sweepLocked(l.now())
}
// sweepLocked deletes fully refilled buckets. The caller holds l.mu.
func (l *rateLimiter) sweepLocked(now time.Time) {
for k, b := range l.buckets {
if b.tokens+now.Sub(b.last).Seconds()*l.rate >= l.burst {
delete(l.buckets, k)
}
}
}
// allow applies l to the client IP (only when IPs can be told apart; same
// rule as the /ws limiter, see ws_limits.go) and to each extra key. It
// writes 429 + Retry-After and returns false when any bucket is empty.
func (a *authService) allow(w http.ResponseWriter, r *http.Request, l *rateLimiter, extraKeys ...string) bool {
keys := extraKeys
if ip, distinct := a.ipr.clientIP(r); distinct && ip != nil {
keys = append([]string{"ip:" + ip.String()}, extraKeys...)
} else {
a.warnIndistinct.Do(func() {
log.Printf("[users] rate limits by IP are off: requests arrive from a proxy and userManagement.trustedProxies is empty; per-address limits still apply, and the webhook limiter is off too")
})
}
for _, k := range keys {
if ok, wait := l.take(k); !ok {
writeTooManyRequests(w, wait)
return false
}
}
return true
}
// writeTooManyRequests answers 429 with a Retry-After of at least 1 s.
func writeTooManyRequests(w http.ResponseWriter, wait time.Duration) {
secs := int(math.Ceil(wait.Seconds()))
if secs < 1 {
secs = 1
}
w.Header().Set("Retry-After", strconv.Itoa(secs))
writeError(w, http.StatusTooManyRequests, "too many attempts, try again later")
}