mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Authentication (previous phase, now landed together with its follow-ups): - Email+password+name accounts behind an AuthProvider interface; the OSS server ships BuiltinAuth only (file-backed auth.json: bcrypt password hashes + SHA-256 token digests, plaintext never stored; server-owned /auth/* pages; managed deployments can swap in another provider). - bdrive login: loopback-callback browser flow (sign-up on the page, the terminal finishes itself) with a device-code fallback for headless machines; long-lived revocable device tokens in settings.json. - Password reset via plain SMTP (stdlib) with a log-link fallback when no SMTP is configured. Move-proof projects: - .bdrive is now a directory; config.json carries a stable mount id. The volume store (~/.bdrive/volumes/<mount-id>/) and registry are keyed by that id — never the folder path — so renames/moves are free. - The daemon re-reads the project config each tick and exits cleanly (propagating nothing) when its folder vanishes; the registry self-heals and the next bdrive command at the new location resumes with zero spurious changes. bdrive init is the front door (mnt/umnt removed; bdrive stop pauses): - Interactive on a TTY (create new / connect existing project from the server's list; whole folder / shared subfolder via the include list), full flag bypass (--name/--project/--shared/--yes), never prompts without a TTY. Runs the login flow first when there is no session. Default server: beardrive.ai (config.DefaultServer). Web history (revert-ready): - Hubs now always require auth; journal ops carry the signed-in account (user/user_name) alongside the git/OS fallback author. - File-backed device registry: per-device name, OS, account, and the public IP the server observed, joined into history at read time. - GET /api/p/<id>/history?path=|prefix= (newest first) and GET /api/p/<id>/blob?sha= stream any exact version — blobs are retained forever, so the next phase's revert is re-putting an old blob. - UI: History button (file versions or project feed), per-folder history shortcut, view/download of any past version. Tests: auth flows (callback, device-code, reset single-use, persistence, gating), history API + device registry, folder-move survival, registry self-heal, ops-carry-account; docs (README/SKILL/CLAUDE) updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
161 lines
3.9 KiB
Go
161 lines
3.9 KiB
Go
package webapp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"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 file-backed device table (devices.json), same
|
|
// load-at-open / atomic-rewrite discipline as the project registry.
|
|
type DeviceRegistry struct {
|
|
path string
|
|
|
|
mu sync.Mutex
|
|
byID map[string]DeviceInfo
|
|
lastSav map[string]time.Time // throttle disk writes per device
|
|
}
|
|
|
|
func OpenDeviceRegistry(path string) (*DeviceRegistry, error) {
|
|
r := &DeviceRegistry{path: path, byID: make(map[string]DeviceInfo), lastSav: make(map[string]time.Time)}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return r, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
var file struct {
|
|
Devices []DeviceInfo `json:"devices"`
|
|
}
|
|
if err := json.Unmarshal(data, &file); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", path, err)
|
|
}
|
|
for _, d := range file.Devices {
|
|
r.byID[d.ID] = d
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func (r *DeviceRegistry) save() error {
|
|
var file struct {
|
|
Devices []DeviceInfo `json:"devices"`
|
|
}
|
|
for _, d := range r.byID {
|
|
file.Devices = append(file.Devices, d)
|
|
}
|
|
data, err := json.MarshalIndent(file, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(r.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(r.path), ".bdrive-tmp-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
if _, err := tmp.Write(append(data, '\n')); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp.Name(), r.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.save() == 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),
|
|
})
|
|
}
|