Files
beardrive/internal/webapp/projects.go
T
Snow LeeandClaude Fable 5 a7bb790615 feat: multi-project sync hub, bdrive login/init onboarding, .bdrive rename
The web server (bdrive web) becomes a full sync hub, and client devices
get one-command onboarding — without ever seeing storage info or holding
cloud credentials:

- bdrive web -c config.json: server configurable from a JSON file
  (remote/addr/upload/upload_ttl/projects_db); explicit flags win.
- Hub mode: pointing bdrive web at a storage root hosts many projects,
  each under <root>/<project-id>/ (remote.Prefixed). Projects live in a
  file-backed registry (projects.json — loaded at open, rewritten
  atomically per change) with create-or-join-by-name semantics.
- Per-project APIs: /api/projects (list/create/get) and
  /api/p/<id>/{tree,file,render,download,upload/*,store/*}. The web UI
  grows a project list with per-project browsing and hash deep links.
- Browser uploads and a store proxy for syncing devices: presigned
  direct-to-storage PUTs when the backend can sign (S3 presign, GCS V4
  signed URLs; expiring, credential-free), relayed through the server
  otherwise. Journals are never presigned — only immutable blobs.
  Blobs-before-journal and one-writer-per-journal invariants hold.
- https:// remote backend: a device syncs one hub project through
  /api/p/<id>/store/* — mnt/sync/daemon/log all work unchanged.
- bdrive login <url>: verify a hub and remember it as the device default
  (settings.json). bdrive init: create-or-join a project named after the
  folder (--name/--project override), write .bdrive, seed a starter
  .bdriveignore, mount, and start the daemon — one command per project.
- Hard-break rename: .beardrive->.bdrive, .beardriveignore->.bdriveignore,
  ~/.beardrive->~/.bdrive, BEARDRIVE_HOME->BDRIVE_HOME, temp/conflict
  prefixes; old names are no longer read.
- Tests: presigning, project registry persistence, store API validation
  and gating, project isolation over live HTTP, browser upload flows, and
  two-device convergence through a hub (incl. read-only pull-only mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
2026-07-08 07:13:00 -07:00

155 lines
3.5 KiB
Go

package webapp
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"sync"
"time"
)
// Project is one synced project hosted by this server. Its storage lives
// under <root>/<id>/ in the object store; the id is permanent, the name is a
// renameable label.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Created time.Time `json:"created"`
}
var projectIDRe = regexp.MustCompile(`^p-[0-9a-f]{8}$`)
// ProjectDB is the server's project registry, persisted as a JSON file that
// is loaded on open and rewritten atomically on every change.
type ProjectDB struct {
path string
mu sync.Mutex
byID map[string]Project
}
// OpenProjectDB loads the registry at path; a missing file is an empty
// registry.
func OpenProjectDB(path string) (*ProjectDB, error) {
db := &ProjectDB{path: path, byID: make(map[string]Project)}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return db, nil
}
return nil, err
}
var file struct {
Projects []Project `json:"projects"`
}
if err := json.Unmarshal(data, &file); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
for _, p := range file.Projects {
db.byID[p.ID] = p
}
return db, nil
}
// save persists the registry. Callers hold mu.
func (db *ProjectDB) save() error {
list := db.list()
data, err := json.MarshalIndent(struct {
Projects []Project `json:"projects"`
}{list}, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(db.path), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(db.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(), db.path)
}
// list returns projects sorted by name. Callers hold mu.
func (db *ProjectDB) list() []Project {
out := make([]Project, 0, len(db.byID))
for _, p := range db.byID {
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func (db *ProjectDB) List() []Project {
db.mu.Lock()
defer db.mu.Unlock()
return db.list()
}
func (db *ProjectDB) Get(id string) (Project, bool) {
db.mu.Lock()
defer db.mu.Unlock()
p, ok := db.byID[id]
return p, ok
}
// GetOrCreate returns the project with the given name, creating it (with a
// fresh id) if none exists. Names are matched exactly.
func (db *ProjectDB) GetOrCreate(name string) (Project, bool, error) {
name = trimName(name)
if name == "" {
return Project{}, false, fmt.Errorf("project name must not be empty")
}
db.mu.Lock()
defer db.mu.Unlock()
for _, p := range db.byID {
if p.Name == name {
return p, false, nil
}
}
var buf [4]byte
if _, err := rand.Read(buf[:]); err != nil {
return Project{}, false, err
}
p := Project{ID: "p-" + hex.EncodeToString(buf[:]), Name: name, Created: time.Now().UTC()}
db.byID[p.ID] = p
if err := db.save(); err != nil {
delete(db.byID, p.ID)
return Project{}, false, err
}
return p, true, nil
}
func trimName(s string) string {
out := make([]rune, 0, len(s))
for _, r := range s {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
out = append(out, r)
}
for len(out) > 0 && out[0] == ' ' {
out = out[1:]
}
for len(out) > 0 && out[len(out)-1] == ' ' {
out = out[:len(out)-1]
}
if len(out) > 128 {
out = out[:128]
}
return string(out)
}