mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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
155 lines
3.5 KiB
Go
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)
|
|
}
|