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
131 lines
3.5 KiB
Go
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 }
|