Files
beardrive/internal/remote/gcs.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

106 lines
2.7 KiB
Go

package remote
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"path"
"strings"
"time"
gcs "cloud.google.com/go/storage"
"google.golang.org/api/iterator"
)
// gcsBackend stores objects in Google Cloud Storage using Application
// Default Credentials (gcloud auth application-default login, or a service
// account via GOOGLE_APPLICATION_CREDENTIALS).
type gcsBackend struct {
client *gcs.Client
bucket *gcs.BucketHandle
prefix string
}
func newGCS(ctx context.Context, bucket, prefix string) (*gcsBackend, error) {
if bucket == "" {
return nil, fmt.Errorf("gs remote needs a bucket: gs://bucket/prefix")
}
client, err := gcs.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("create GCS client: %w", err)
}
return &gcsBackend{client: client, bucket: client.Bucket(bucket), prefix: prefix}, nil
}
func (b *gcsBackend) key(key string) string {
if b.prefix == "" {
return key
}
return path.Join(b.prefix, key)
}
func (b *gcsBackend) Put(ctx context.Context, key string, r io.Reader, _ int64) error {
w := b.bucket.Object(b.key(key)).NewWriter(ctx)
if _, err := io.Copy(w, r); err != nil {
w.Close()
return err
}
return w.Close()
}
// SignPut mints a V4 signed PUT URL. Signing needs credentials that can sign
// bytes (a service account key, or iam.serviceAccounts.signBlob rights);
// plain end-user ADC cannot, in which case callers fall back to uploading
// through the server.
func (b *gcsBackend) SignPut(_ context.Context, key string, _ int64, ttl time.Duration) (*SignedPut, error) {
expires := time.Now().Add(ttl)
u, err := b.bucket.SignedURL(b.key(key), &gcs.SignedURLOptions{
Scheme: gcs.SigningSchemeV4,
Method: http.MethodPut,
Expires: expires,
})
if err != nil {
return nil, fmt.Errorf("sign gcs put: %w", err)
}
return &SignedPut{URL: u, Method: http.MethodPut, Expires: expires}, nil
}
func (b *gcsBackend) Get(ctx context.Context, key string) (io.ReadCloser, error) {
return b.bucket.Object(b.key(key)).NewReader(ctx)
}
func (b *gcsBackend) List(ctx context.Context, prefix string) ([]Object, error) {
it := b.bucket.Objects(ctx, &gcs.Query{Prefix: b.key(prefix)})
strip := b.prefix
if strip != "" {
strip += "/"
}
var out []Object
for {
attrs, err := it.Next()
if errors.Is(err, iterator.Done) {
break
}
if err != nil {
return nil, err
}
out = append(out, Object{Key: strings.TrimPrefix(attrs.Name, strip), Size: attrs.Size})
}
return out, nil
}
func (b *gcsBackend) Exists(ctx context.Context, key string) (bool, error) {
_, err := b.bucket.Object(b.key(key)).Attrs(ctx)
if err == nil {
return true, nil
}
if errors.Is(err, gcs.ErrObjectNotExist) {
return false, nil
}
return false, err
}
func (b *gcsBackend) Close() error { return b.client.Close() }