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
330 lines
11 KiB
Go
330 lines
11 KiB
Go
package webapp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
)
|
|
|
|
var webDevice = Identity{ID: "webdev", Name: "webhost", Author: "web@test"}
|
|
|
|
// uploadServer returns an upload-enabled Server over the fake remote, with
|
|
// the backend optionally wrapped (e.g. to add signing).
|
|
func (f *fakeRemote) uploadServer(wrap func(remote.Backend) remote.Backend) *Server {
|
|
f.t.Helper()
|
|
be, err := remote.Open(context.Background(), "file://"+f.dir)
|
|
if err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
f.t.Cleanup(func() { be.Close() })
|
|
if wrap != nil {
|
|
be = wrap(be)
|
|
}
|
|
return &Server{
|
|
Source: &RemoteSource{Backend: be, Device: webDevice},
|
|
Volume: "testvol", Refresh: 0,
|
|
Upload: UploadConfig{Enabled: true},
|
|
}
|
|
}
|
|
|
|
// signingBackend fakes an object store that can presign uploads.
|
|
type signingBackend struct {
|
|
remote.Backend
|
|
signed []string // keys presigned so far
|
|
}
|
|
|
|
func (b *signingBackend) SignPut(_ context.Context, key string, size int64, ttl time.Duration) (*remote.SignedPut, error) {
|
|
b.signed = append(b.signed, key)
|
|
return &remote.SignedPut{
|
|
URL: "https://storage.example/" + key + "?sig=abc", Method: "PUT",
|
|
Headers: map[string]string{"Content-Length": fmt.Sprint(size)},
|
|
Expires: time.Now().Add(ttl),
|
|
}, nil
|
|
}
|
|
|
|
func do(t *testing.T, h http.Handler, method, url string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var rd *bytes.Reader
|
|
switch b := body.(type) {
|
|
case nil:
|
|
rd = bytes.NewReader(nil)
|
|
case []byte:
|
|
rd = bytes.NewReader(b)
|
|
default:
|
|
data, err := json.Marshal(b)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rd = bytes.NewReader(data)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(method, url, rd))
|
|
return rec
|
|
}
|
|
|
|
func shaOf(content string) string {
|
|
sum := sha256.Sum256([]byte(content))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func initReq(path, content string) map[string]any {
|
|
return map[string]any{"path": path, "sha256": shaOf(content), "size": len(content)}
|
|
}
|
|
|
|
func TestConfigExposesNoStorageInfo(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
h := f.server().Handler()
|
|
rec := do(t, h, "GET", "/api/config", nil)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("config: %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if strings.Contains(body, f.dir) || strings.Contains(body, "file://") {
|
|
t.Fatalf("config leaks storage location: %s", body)
|
|
}
|
|
var cfg struct {
|
|
Volume string `json:"volume"`
|
|
Upload struct {
|
|
Enabled bool `json:"enabled"`
|
|
} `json:"upload"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Volume != "testvol" || cfg.Upload.Enabled {
|
|
t.Fatalf("config = %+v, want volume testvol, upload disabled", cfg)
|
|
}
|
|
// the old endpoint that used to expose the remote URL must be gone
|
|
if rec := do(t, h, "GET", "/api/volume", nil); rec.Code == 200 {
|
|
t.Fatalf("/api/volume should not exist, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestUploadDisabledByDefault(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
h := f.server().Handler() // read-only server
|
|
if rec := do(t, h, "POST", "/api/upload/init", initReq("a.md", "x")); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("init on read-only server: %d, want 403", rec.Code)
|
|
}
|
|
if rec := do(t, h, "PUT", "/api/upload/content?path=a.md", []byte("x")); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("content on read-only server: %d, want 403", rec.Code)
|
|
}
|
|
if rec := do(t, h, "POST", "/api/upload/commit", initReq("a.md", "x")); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("commit on read-only server: %d, want 403", rec.Code)
|
|
}
|
|
}
|
|
|
|
// A file:// backend cannot presign, so the server must direct the client to
|
|
// upload through it — and the upload must land as blob + journal op with the
|
|
// server's device identity and a lamport past every existing op.
|
|
func TestServerModeUpload(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
f.put("deva", "old.md", "existing") // pre-existing history from another device
|
|
srv := f.uploadServer(nil)
|
|
h := srv.Handler()
|
|
|
|
rec := do(t, h, "POST", "/api/upload/init", initReq("notes/new.md", "hello"))
|
|
if rec.Code != 200 {
|
|
t.Fatalf("init: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var plan struct {
|
|
Mode string `json:"mode"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &plan); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if plan.Mode != "server" {
|
|
t.Fatalf("mode = %q, want server (file backend cannot presign)", plan.Mode)
|
|
}
|
|
|
|
rec = do(t, h, "PUT", "/api/upload/content?path=notes/new.md", []byte("hello"))
|
|
if rec.Code != 200 {
|
|
t.Fatalf("content: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
// visible immediately (snapshot invalidated), served back intact
|
|
if rec := do(t, h, "GET", "/api/file?path=notes/new.md", nil); rec.Code != 200 || rec.Body.String() != "hello" {
|
|
t.Fatalf("file after upload: %d %q", rec.Code, rec.Body)
|
|
}
|
|
|
|
// blob is content-addressed in the store
|
|
if _, err := os.Stat(filepath.Join(f.dir, "blobs", shaOf("hello"))); err != nil {
|
|
t.Fatalf("blob not in store: %v", err)
|
|
}
|
|
// journaled under the server's own device, never anyone else's
|
|
ops, err := journal.ReadFile(filepath.Join(f.dir, "journal", webDevice.ID+".jsonl"))
|
|
if err != nil || len(ops) != 1 {
|
|
t.Fatalf("web journal ops = %v, %v", ops, err)
|
|
}
|
|
op := ops[0]
|
|
if op.Device != webDevice.ID || op.Author != webDevice.Author || op.Seq != 1 {
|
|
t.Fatalf("op identity = %+v", op)
|
|
}
|
|
if op.Lamport <= 1 { // deva's put had lamport 1; ours must sort after it
|
|
t.Fatalf("lamport = %d, want > 1", op.Lamport)
|
|
}
|
|
if op.Kind != journal.KindPut || op.Path != "notes/new.md" || op.Blob != shaOf("hello") || op.Size != 5 {
|
|
t.Fatalf("op = %+v", op)
|
|
}
|
|
}
|
|
|
|
func TestDirectModeUpload(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
var sb *signingBackend
|
|
srv := f.uploadServer(func(be remote.Backend) remote.Backend {
|
|
sb = &signingBackend{Backend: be}
|
|
return sb
|
|
})
|
|
h := srv.Handler()
|
|
content := "direct content"
|
|
req := initReq("docs/d.md", content)
|
|
|
|
rec := do(t, h, "POST", "/api/upload/init", req)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("init: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var plan struct {
|
|
Mode, URL, Method string
|
|
Exists bool
|
|
Expires time.Time
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &plan); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if plan.Mode != "direct" || plan.Exists {
|
|
t.Fatalf("plan = %+v, want fresh direct upload", plan)
|
|
}
|
|
wantKey := "blobs/" + shaOf(content)
|
|
if !strings.Contains(plan.URL, wantKey) || plan.Method != "PUT" {
|
|
t.Fatalf("plan = %+v, want presigned PUT of %s", plan, wantKey)
|
|
}
|
|
if plan.Expires.IsZero() || plan.Expires.After(time.Now().Add(DefaultUploadTTL+time.Minute)) {
|
|
t.Fatalf("expires = %v, want bounded by ttl", plan.Expires)
|
|
}
|
|
if len(sb.signed) != 1 || sb.signed[0] != wantKey {
|
|
t.Fatalf("signed keys = %v", sb.signed)
|
|
}
|
|
|
|
// committing before the blob arrived must be refused: a journal op must
|
|
// never point at missing content
|
|
if rec := do(t, h, "POST", "/api/upload/commit", req); rec.Code != http.StatusConflict {
|
|
t.Fatalf("commit without blob: %d %s, want 409", rec.Code, rec.Body)
|
|
}
|
|
|
|
// simulate the client's direct PUT to storage
|
|
if err := os.WriteFile(filepath.Join(f.dir, "blobs", shaOf(content)), []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec := do(t, h, "POST", "/api/upload/commit", req); rec.Code != 200 {
|
|
t.Fatalf("commit: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := do(t, h, "GET", "/api/file?path=docs/d.md", nil); rec.Code != 200 || rec.Body.String() != content {
|
|
t.Fatalf("file after direct upload: %d %q", rec.Code, rec.Body)
|
|
}
|
|
|
|
// re-uploading identical content: init should say it's already there
|
|
rec = do(t, h, "POST", "/api/upload/init", initReq("copy.md", content))
|
|
var again struct {
|
|
Mode string
|
|
Exists bool
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again.Mode != "direct" || !again.Exists {
|
|
t.Fatalf("re-init = %+v, want direct+exists", again)
|
|
}
|
|
}
|
|
|
|
func TestUploadPathValidation(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
h := f.uploadServer(nil).Handler()
|
|
for _, bad := range []string{"", "/abs.md", "../escape.md", "a/../../b", "dir/", ".bdrive", "x/.bdrive-tmp-1"} {
|
|
body := map[string]any{"path": bad, "sha256": shaOf("x"), "size": 1}
|
|
if rec := do(t, h, "POST", "/api/upload/init", body); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("init path %q: %d, want 400", bad, rec.Code)
|
|
}
|
|
if rec := do(t, h, "PUT", "/api/upload/content?path="+bad, []byte("x")); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("content path %q: %d, want 400", bad, rec.Code)
|
|
}
|
|
}
|
|
// bad sha256 values
|
|
for _, sha := range []string{"", "xyz", strings.Repeat("A", 64)} {
|
|
body := map[string]any{"path": "ok.md", "sha256": sha, "size": 1}
|
|
if rec := do(t, h, "POST", "/api/upload/init", body); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("init sha %q: %d, want 400", sha, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDirSourceUpload(t *testing.T) {
|
|
root := t.TempDir()
|
|
srv := &Server{
|
|
Source: &DirSource{Root: root}, Volume: "local", Refresh: 0,
|
|
Upload: UploadConfig{Enabled: true},
|
|
}
|
|
h := srv.Handler()
|
|
|
|
// a plain folder cannot presign either → server mode
|
|
rec := do(t, h, "POST", "/api/upload/init", initReq("sub/note.md", "hi"))
|
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"server"`) {
|
|
t.Fatalf("init: %d %s, want server mode", rec.Code, rec.Body)
|
|
}
|
|
if rec := do(t, h, "PUT", "/api/upload/content?path=sub/note.md", []byte("hi")); rec.Code != 200 {
|
|
t.Fatalf("content: %d %s", rec.Code, rec.Body)
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(root, "sub", "note.md"))
|
|
if err != nil || string(data) != "hi" {
|
|
t.Fatalf("file on disk = %q, %v", data, err)
|
|
}
|
|
// direct-mode commit makes no sense for a plain folder
|
|
if rec := do(t, h, "POST", "/api/upload/commit", initReq("sub/note.md", "hi")); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("commit on dir source: %d, want 400", rec.Code)
|
|
}
|
|
// upload config advertises enabled
|
|
rec = do(t, h, "GET", "/api/config", nil)
|
|
if !strings.Contains(rec.Body.String(), `"enabled":true`) {
|
|
t.Fatalf("config = %s, want upload enabled", rec.Body)
|
|
}
|
|
}
|
|
|
|
// Uploads from two requests racing must serialize on the journal: both ops
|
|
// survive with distinct seqs.
|
|
func TestConcurrentCommits(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
h := f.uploadServer(nil).Handler()
|
|
done := make(chan int, 2)
|
|
for i := 0; i < 2; i++ {
|
|
go func(i int) {
|
|
content := fmt.Sprintf("body-%d", i)
|
|
rec := do(t, h, "PUT", fmt.Sprintf("/api/upload/content?path=f%d.md", i), []byte(content))
|
|
done <- rec.Code
|
|
}(i)
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
if code := <-done; code != 200 {
|
|
t.Fatalf("concurrent upload: %d", code)
|
|
}
|
|
}
|
|
ops, err := journal.ReadFile(filepath.Join(f.dir, "journal", webDevice.ID+".jsonl"))
|
|
if err != nil || len(ops) != 2 {
|
|
t.Fatalf("ops = %v, %v; want 2", ops, err)
|
|
}
|
|
if ops[0].Seq == ops[1].Seq || ops[0].Lamport == ops[1].Lamport {
|
|
t.Fatalf("seq/lamport must be distinct: %+v %+v", ops[0], ops[1])
|
|
}
|
|
}
|