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

131 lines
3.5 KiB
Go

package remote
import (
"context"
"errors"
"fmt"
"io"
"path"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
// s3Backend stores objects in Amazon S3 (or any S3-compatible store via the
// standard AWS_ENDPOINT_URL / AWS_PROFILE environment configuration).
type s3Backend struct {
client *s3.Client
bucket string
prefix string
}
func newS3(ctx context.Context, bucket, prefix string) (*s3Backend, error) {
if bucket == "" {
return nil, fmt.Errorf("s3 remote needs a bucket: s3://bucket/prefix")
}
cfg, err := awsconfig.LoadDefaultConfig(ctx)
if err != nil {
return nil, fmt.Errorf("load AWS config: %w", err)
}
return &s3Backend{client: s3.NewFromConfig(cfg), bucket: bucket, prefix: prefix}, nil
}
func (b *s3Backend) key(key string) string {
if b.prefix == "" {
return key
}
return path.Join(b.prefix, key)
}
func (b *s3Backend) Put(ctx context.Context, key string, r io.Reader, size int64) error {
_, err := b.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(b.bucket),
Key: aws.String(b.key(key)),
Body: r,
ContentLength: aws.Int64(size),
})
return err
}
// SignPut presigns a PUT of exactly size bytes to the given key. Presigning
// is a local signature computation — no network round-trip, no credentials in
// the result.
func (b *s3Backend) SignPut(ctx context.Context, key string, size int64, ttl time.Duration) (*SignedPut, error) {
req, err := s3.NewPresignClient(b.client).PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(b.bucket),
Key: aws.String(b.key(key)),
ContentLength: aws.Int64(size),
}, s3.WithPresignExpires(ttl))
if err != nil {
return nil, fmt.Errorf("presign s3 put: %w", err)
}
headers := make(map[string]string)
for k, vs := range req.SignedHeader {
if len(vs) > 0 && !strings.EqualFold(k, "Host") {
headers[k] = vs[0]
}
}
return &SignedPut{URL: req.URL, Method: req.Method, Headers: headers, Expires: time.Now().Add(ttl)}, nil
}
func (b *s3Backend) Get(ctx context.Context, key string) (io.ReadCloser, error) {
out, err := b.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(b.bucket),
Key: aws.String(b.key(key)),
})
if err != nil {
return nil, err
}
return out.Body, nil
}
func (b *s3Backend) List(ctx context.Context, prefix string) ([]Object, error) {
full := b.key(prefix)
var out []Object
p := s3.NewListObjectsV2Paginator(b.client, &s3.ListObjectsV2Input{
Bucket: aws.String(b.bucket),
Prefix: aws.String(full),
})
strip := b.prefix
if strip != "" {
strip += "/"
}
for p.HasMorePages() {
page, err := p.NextPage(ctx)
if err != nil {
return nil, err
}
for _, o := range page.Contents {
key := strings.TrimPrefix(aws.ToString(o.Key), strip)
out = append(out, Object{Key: key, Size: aws.ToInt64(o.Size)})
}
}
return out, nil
}
func (b *s3Backend) Exists(ctx context.Context, key string) (bool, error) {
_, err := b.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(b.bucket),
Key: aws.String(b.key(key)),
})
if err == nil {
return true, nil
}
var nf *types.NotFound
if errors.As(err, &nf) {
return false, nil
}
var ae smithy.APIError
if errors.As(err, &ae) && (ae.ErrorCode() == "NotFound" || ae.ErrorCode() == "404") {
return false, nil
}
return false, err
}
func (b *s3Backend) Close() error { return nil }