A pending device registration used to be announced to EVERY account's Devices page, and whoever clicked Approve first bound the device — and its freshly minted sync token — to their account. Worse, the pending card displayed the registration UUID, which is also the bearer credential on the unauthenticated /api/devices/register/status poll channel: any family user who could see the list already held everything needed to take the device. On a family server that is a wrong-account-binding bug (accidental or deliberate); this commit removes the class of problem rather than patching the race. The new model is the Netflix/YouTube TV device flow: the registering device displays a 6-digit pairing code, and the only person who can read it — whoever holds the device — types it into THEIR account on the Devices page. Possession of the device screen becomes the credential; nothing is broadcast; the reject-on-someone-else's-behalf hole dies with the list. Server changes: - POST /api/devices/register now mints pairing_code (crypto/rand, rejection-sampled for uniformity, collision-checked against live registrations) and retires prior unclaimed registrations with the same device_identifier, so re-registering leaves exactly one live attempt. - POST /api/devices/claim (JWT) binds the registration behind a code to the caller's account, reusing the rotate-or-create approval logic factored out of ApproveDevice (shared bindRegistrationToUser helper; the Android self-approval route keeps working unchanged). - POST /api/devices/claim/lookup (JWT) returns the confirm-card data without binding. Lookup and claim share a per-account budget (12 attempts/min, DeviceRateLimiter gained a claim case) — the 1M-code space over a 5-minute TTL makes guessing infeasible without it. - POST /api/devices/register/cancel lets the registering device end its own attempt (registration UUID bearer, idempotent, never touches a claimed registration). - ListPendingRegistrations / GetPendingRegistrationsData are deprecated-by-emptiness: unclaimed registrations return to nobody. - RejectDevice and its route are deleted — the broadcast list was its only consumer, and there is nothing left for anyone to reject. Additive-only for pinned surfaces: the register response gains pairing_code (Android DTO untouched), the four KOReader sync surfaces and approve keep byte-identical shapes. contract_pins.md changelog records the widening and the reject removal. Docs, schema registry, and bruno mirrors move together per the drift gate; bruno_gen.go now keeps hand-authored bruno/podcasts/ (API docs never existed for them, so the next -update run would have swept them — latent landmine, defused). Unit tests: code shape, live-collision avoidance, typed-code normalization, and the resolve scopes (live/expired/claimed/miss).
180 lines
3.7 KiB
Go
180 lines
3.7 KiB
Go
package middleware
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type DeviceRateLimiter struct {
|
|
requests map[string]*DeviceRequestInfo
|
|
mu sync.RWMutex
|
|
cleanup *time.Ticker
|
|
}
|
|
|
|
type DeviceRequestInfo struct {
|
|
Requests []time.Time
|
|
LastSeen time.Time
|
|
DeviceID string
|
|
}
|
|
|
|
type DeviceRateLimitConfig struct {
|
|
SyncRequestsPerMinute int
|
|
ProgressUpdatesPerMinute int
|
|
MetadataRequestsPerMinute int
|
|
ClaimAttemptsPerMinute int
|
|
}
|
|
|
|
const (
|
|
DefaultSyncRequestsPerMinute = 60
|
|
DefaultProgressUpdatesPerMinute = 120
|
|
DefaultMetadataRequestsPerMinute = 30
|
|
DefaultClaimAttemptsPerMinute = 12
|
|
CleanupInterval = 5 * time.Minute
|
|
RequestWindow = 1 * time.Minute
|
|
)
|
|
|
|
func NewDeviceRateLimiter() *DeviceRateLimiter {
|
|
limiter := &DeviceRateLimiter{
|
|
requests: make(map[string]*DeviceRequestInfo),
|
|
cleanup: time.NewTicker(CleanupInterval),
|
|
}
|
|
|
|
go limiter.cleanupOldEntries()
|
|
|
|
return limiter
|
|
}
|
|
|
|
func (drl *DeviceRateLimiter) CheckRateLimit(deviceID string, requestType string, config DeviceRateLimitConfig) bool {
|
|
drl.mu.Lock()
|
|
defer drl.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
key := deviceID + ":" + requestType
|
|
|
|
info, exists := drl.requests[key]
|
|
if !exists {
|
|
info = &DeviceRequestInfo{
|
|
Requests: []time.Time{},
|
|
DeviceID: deviceID,
|
|
LastSeen: now,
|
|
}
|
|
drl.requests[key] = info
|
|
}
|
|
|
|
var limit int
|
|
switch requestType {
|
|
case "sync":
|
|
limit = config.SyncRequestsPerMinute
|
|
case "progress":
|
|
limit = config.ProgressUpdatesPerMinute
|
|
case "metadata":
|
|
limit = config.MetadataRequestsPerMinute
|
|
case "claim":
|
|
limit = config.ClaimAttemptsPerMinute
|
|
default:
|
|
limit = DefaultSyncRequestsPerMinute
|
|
}
|
|
|
|
if limit == 0 {
|
|
switch requestType {
|
|
case "progress":
|
|
limit = DefaultProgressUpdatesPerMinute
|
|
case "metadata":
|
|
limit = DefaultMetadataRequestsPerMinute
|
|
case "claim":
|
|
limit = DefaultClaimAttemptsPerMinute
|
|
default:
|
|
limit = DefaultSyncRequestsPerMinute
|
|
}
|
|
}
|
|
|
|
info.Requests = append(info.Requests, now)
|
|
info.LastSeen = now
|
|
|
|
cutoff := now.Add(-RequestWindow)
|
|
validRequests := 0
|
|
for _, reqTime := range info.Requests {
|
|
if reqTime.After(cutoff) {
|
|
validRequests++
|
|
}
|
|
}
|
|
info.Requests = info.Requests[len(info.Requests)-validRequests:]
|
|
|
|
return validRequests <= limit
|
|
}
|
|
|
|
func (drl *DeviceRateLimiter) GetRemainingRequests(deviceID string, requestType string, config DeviceRateLimitConfig) int {
|
|
drl.mu.RLock()
|
|
defer drl.mu.RUnlock()
|
|
|
|
key := deviceID + ":" + requestType
|
|
info, exists := drl.requests[key]
|
|
if !exists {
|
|
var limit int
|
|
switch requestType {
|
|
case "sync":
|
|
limit = config.SyncRequestsPerMinute
|
|
case "progress":
|
|
limit = config.ProgressUpdatesPerMinute
|
|
case "metadata":
|
|
limit = config.MetadataRequestsPerMinute
|
|
default:
|
|
limit = DefaultSyncRequestsPerMinute
|
|
}
|
|
if limit == 0 {
|
|
limit = DefaultSyncRequestsPerMinute
|
|
}
|
|
return limit
|
|
}
|
|
|
|
now := time.Now()
|
|
cutoff := now.Add(-RequestWindow)
|
|
validRequests := 0
|
|
for _, reqTime := range info.Requests {
|
|
if reqTime.After(cutoff) {
|
|
validRequests++
|
|
}
|
|
}
|
|
|
|
var limit int
|
|
switch requestType {
|
|
case "sync":
|
|
limit = config.SyncRequestsPerMinute
|
|
case "progress":
|
|
limit = config.ProgressUpdatesPerMinute
|
|
case "metadata":
|
|
limit = config.MetadataRequestsPerMinute
|
|
default:
|
|
limit = DefaultSyncRequestsPerMinute
|
|
}
|
|
if limit == 0 {
|
|
limit = DefaultSyncRequestsPerMinute
|
|
}
|
|
|
|
remaining := limit - validRequests
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
|
|
return remaining
|
|
}
|
|
|
|
func (drl *DeviceRateLimiter) cleanupOldEntries() {
|
|
for range drl.cleanup.C {
|
|
drl.mu.Lock()
|
|
cutoff := time.Now().Add(-10 * time.Minute)
|
|
|
|
for key, info := range drl.requests {
|
|
if info.LastSeen.Before(cutoff) {
|
|
delete(drl.requests, key)
|
|
}
|
|
}
|
|
|
|
drl.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
func (drl *DeviceRateLimiter) Stop() {
|
|
drl.cleanup.Stop()
|
|
}
|