Files
beardrive/internal/webapp/devices.go
Snow LeeandClaude Opus 4.8 f14601a983 refactor(web): extract MetaStore + typed repos; file backend (phase 1)
Introduce a pluggable metadata persistence layer: a MetaStore of typed
repositories (AccountRepo, ProjectRepo, OrgRepo, ShareRepo, DeviceRepo).
The five registries keep their in-memory maps + logic and now persist each
change as one record through a repo, instead of rewriting a whole JSON file
inline. The `file` backend (db_file.go) reproduces the exact on-disk JSON
formats, so a running hub loads unchanged. Open*(path) constructors stay as
thin wrappers over the file backend.

Zero behavior change: full webapp suite green; the example hub boots and
existing auth.json/orgs.json/projects.json load and sign-in works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
2026-07-10 00:01:56 -07:00

124 lines
3.2 KiB
Go

package webapp
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// DeviceInfo is what the server knows about one syncing device: self-reported
// name/OS (headers sent by the client), plus what the server itself observed
// (public IP of the last push, last activity, the signed-in account). History
// joins ops against this registry, so IPs are real — as the server saw them —
// and ops stay small.
type DeviceInfo struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
OS string `json:"os,omitempty"`
User string `json:"user,omitempty"` // account email last seen using this device
IP string `json:"ip,omitempty"` // as observed by the server
LastSeen time.Time `json:"last_seen"`
}
// DeviceRegistry is the in-memory device table over a MetaStore DeviceRepo.
type DeviceRegistry struct {
repo DeviceRepo
mu sync.Mutex
byID map[string]DeviceInfo
lastSav map[string]time.Time // throttle writes per device
}
// NewDeviceRegistry builds the registry over a repo, loading its contents.
func NewDeviceRegistry(repo DeviceRepo) (*DeviceRegistry, error) {
r := &DeviceRegistry{repo: repo, byID: make(map[string]DeviceInfo), lastSav: make(map[string]time.Time)}
list, err := repo.Load()
if err != nil {
return nil, err
}
for _, d := range list {
r.byID[d.ID] = d
}
return r, nil
}
// OpenDeviceRegistry loads the file-backed registry at path.
func OpenDeviceRegistry(path string) (*DeviceRegistry, error) {
return NewDeviceRegistry(newFileDeviceRepo(path))
}
// Observe merges what a request revealed about a device. Disk writes are
// throttled: identity changes persist immediately, bare last-seen bumps at
// most once a minute.
func (r *DeviceRegistry) Observe(d DeviceInfo) {
if r == nil || d.ID == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
cur := r.byID[d.ID]
changed := cur.ID == "" || cur.Name != d.Name || cur.OS != d.OS || cur.User != d.User || cur.IP != d.IP
cur.ID = d.ID
if d.Name != "" {
cur.Name = d.Name
}
if d.OS != "" {
cur.OS = d.OS
}
if d.User != "" {
cur.User = d.User
}
if d.IP != "" {
cur.IP = d.IP
}
cur.LastSeen = time.Now().UTC()
r.byID[d.ID] = cur
if changed || time.Since(r.lastSav[d.ID]) > time.Minute {
if r.repo.Put(cur) == nil {
r.lastSav[d.ID] = time.Now()
}
}
}
func (r *DeviceRegistry) Get(id string) (DeviceInfo, bool) {
if r == nil {
return DeviceInfo{}, false
}
r.mu.Lock()
defer r.mu.Unlock()
d, ok := r.byID[id]
return d, ok
}
// requestIP extracts the client address the server actually saw.
func requestIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// observeDevice records the device behind a store-API request.
func (s *Server) observeDevice(r *http.Request) {
if s.Devices == nil {
return
}
id := r.Header.Get("X-Bdrive-Device")
if id == "" {
return
}
s.Devices.Observe(DeviceInfo{
ID: id,
Name: r.Header.Get("X-Bdrive-Device-Name"),
OS: r.Header.Get("X-Bdrive-Os"),
User: s.requestUser(r).Email,
IP: requestIP(r),
})
}