Files
beardrive/internal/remote/remote.go
T
69e7231a70 feat(hub): per-project permissions — none/read/write/admin, invite-only projects, honest degraded sync (#46)
Access was binary and org-wide: any org member got full read+write on every
project. Now each project carries four ordered levels, resolved by one
resolver and enforced at one choke point.

- `projectPerm` (perms.go) replaces `projectAllowed`; `proj(level, h)` in
  server.go gates every per-project route by the level it declares at
  registration, so no handler grows its own check.
- `Project` gains Creator/Default/Perms. `Default == ""` means write, so an
  upgraded hub behaves identically until someone edits permissions.
- Creator becomes the first project admin; org owners are implicitly admin
  everywhere in their org and a grant naming one is refused, not ignored; a
  project always keeps at least one explicit admin.
- Default `none` makes a project invite-only. A `none` member is treated
  exactly like a non-member, including on create-or-join by name.
- Rename/delete move from org-owner-only to project `admin`.
- Both metadata backends persist it: the file store rides along, the SQL
  store gains `project_perms` plus an idempotent ALTER for the two new
  columns (migrate() had only ever created tables).

Client side, a refusal stops looking like an outage: `remote.ErrForbidden`
plus `Result.ReadOnly` (push refused → pull-only) and `Result.NoAccess`
(pull refused → paused, working folder untouched). Neither sets Offline,
neither loses a local op, and re-granting self-heals on the next cycle.
`bdrive status`/`sync` and the daemon (once, on transition) say which.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 10:41:57 +09:00

95 lines
3.3 KiB
Go

// Package remote abstracts the cloud object store a volume syncs through.
// beardrive is provider-agnostic: any backend that can put/get/list immutable
// objects works. Built-in schemes:
//
// file:///abs/path local or network-drive directory (also used in tests)
// s3://bucket/prefix Amazon S3 (or S3-compatible via AWS_ENDPOINT_URL)
// gs://bucket/prefix Google Cloud Storage
// https://host:4173 a bdrive web server brokering one of the above —
// the device needs no storage credentials at all
//
// Remote layout: blobs/<sha256> for content, journal/<device>.jsonl for op
// logs. Each device writes only its own journal, so there are no concurrent
// writers per object and no server-side coordination is needed.
package remote
import (
"context"
"errors"
"fmt"
"io"
"net/url"
"strings"
"time"
)
// ErrForbidden marks a refusal by the hub's authorization — the device asked
// correctly and was told no, which is a different thing from being offline.
// The syncer keys its degraded states off it: a refused push means read-only
// (keep pulling), a refused pull means access is gone (pause, touch nothing).
var ErrForbidden = errors.New("forbidden")
type Object struct {
Key string
Size int64
}
// SignedPut is a presigned direct-upload request: whoever holds the URL can
// PUT that one object until Expires, without ever seeing storage credentials.
type SignedPut struct {
URL string // upload here
Method string // always "PUT"
Headers map[string]string // headers that must be sent verbatim (they are signed)
Expires time.Time
}
// PutSigner is implemented by backends that can mint presigned upload URLs
// so clients write to storage directly. Backends without that capability
// (file://) simply don't implement it, and callers fall back to uploading
// through the server.
type PutSigner interface {
SignPut(ctx context.Context, key string, size int64, ttl time.Duration) (*SignedPut, error)
}
type Backend interface {
Put(ctx context.Context, key string, r io.Reader, size int64) error
Get(ctx context.Context, key string) (io.ReadCloser, error)
List(ctx context.Context, prefix string) ([]Object, error)
Exists(ctx context.Context, key string) (bool, error)
Close() error
}
// ReadEvent is one agent file read reported to the hub for its read heatmap.
type ReadEvent struct {
Path string `json:"path"`
Time time.Time `json:"time,omitzero"`
}
// ReadReporter is the optional read-telemetry capability, in the PutSigner
// mold: backends that sync through a hub report the device's agent reads so
// the heat view can split human from agent traffic. Object-store backends
// simply don't implement it — there is no hub to tell.
type ReadReporter interface {
ReportReads(ctx context.Context, reads []ReadEvent) error
}
// Open creates a backend from a remote URL.
func Open(ctx context.Context, raw string) (Backend, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid remote %q: %w", raw, err)
}
switch u.Scheme {
case "file":
return newLocal(u.Path)
case "s3":
return newS3(ctx, u.Host, strings.Trim(u.Path, "/"))
case "gs":
return newGCS(ctx, u.Host, strings.Trim(u.Path, "/"))
case "http", "https":
return newHTTPBackend(raw)
default:
return nil, fmt.Errorf("unsupported remote scheme %q (supported: file://, s3://, gs://, https://)", u.Scheme)
}
}