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
78 lines
2.2 KiB
Go
78 lines
2.2 KiB
Go
package remote
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// file:// must NOT be a PutSigner: a filesystem path presigned to a browser
|
|
// is meaningless, so callers fall back to uploading through the server.
|
|
func TestLocalBackendCannotSign(t *testing.T) {
|
|
be, err := Open(context.Background(), "file://"+t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer be.Close()
|
|
if _, ok := be.(PutSigner); ok {
|
|
t.Fatal("file:// backend must not implement PutSigner")
|
|
}
|
|
}
|
|
|
|
// Presigning is a local signature computation — no network needed — so it is
|
|
// unit-testable with static fake credentials.
|
|
func TestS3SignPut(t *testing.T) {
|
|
t.Setenv("AWS_ACCESS_KEY_ID", "AKIAFAKEFAKEFAKEFAKE")
|
|
t.Setenv("AWS_SECRET_ACCESS_KEY", "fakefakefakefakefakefakefakefakefakefake")
|
|
t.Setenv("AWS_SESSION_TOKEN", "")
|
|
t.Setenv("AWS_REGION", "us-east-1")
|
|
t.Setenv("AWS_PROFILE", "")
|
|
t.Setenv("AWS_ENDPOINT_URL", "")
|
|
|
|
be, err := Open(context.Background(), "s3://test-bucket/vol1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer be.Close()
|
|
signer, ok := be.(PutSigner)
|
|
if !ok {
|
|
t.Fatal("s3 backend must implement PutSigner")
|
|
}
|
|
|
|
ttl := 5 * time.Minute
|
|
sp, err := signer.SignPut(context.Background(), "blobs/abc123", 42, ttl)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sp.Method != "PUT" {
|
|
t.Fatalf("method = %q", sp.Method)
|
|
}
|
|
u, err := url.Parse(sp.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(u.Host, "test-bucket") || !strings.HasSuffix(u.Path, "/vol1/blobs/abc123") {
|
|
t.Fatalf("url = %s, want bucket + prefixed key", sp.URL)
|
|
}
|
|
q := u.Query()
|
|
if q.Get("X-Amz-Signature") == "" {
|
|
t.Fatalf("url not signed: %s", sp.URL)
|
|
}
|
|
if q.Get("X-Amz-Expires") != "300" {
|
|
t.Fatalf("expires = %s, want 300s", q.Get("X-Amz-Expires"))
|
|
}
|
|
// the URL must be self-contained: no secret key material leaks into it
|
|
if strings.Contains(sp.URL, "fakefakefake") {
|
|
t.Fatal("secret key leaked into signed URL")
|
|
}
|
|
if sp.Expires.Before(time.Now()) || sp.Expires.After(time.Now().Add(ttl+time.Minute)) {
|
|
t.Fatalf("expires = %v, want ~now+%v", sp.Expires, ttl)
|
|
}
|
|
// the signed content-length pins the upload to the declared size
|
|
if got := sp.Headers["Content-Length"]; got != "42" {
|
|
t.Fatalf("signed content-length = %q, want 42 (headers: %v)", got, sp.Headers)
|
|
}
|
|
}
|