Files
beardrive/internal/webapp/store.go
Snow LeeandClaude Fable 5 94ef66a4a9 feat(hub): organizations — projects walled by team membership, with invites
Every hub project now belongs to exactly one org (file-backed orgs.json,
same load-at-open + atomic-rewrite discipline as the other registries).
Membership (email -> owner|member) gates every per-project route: the
viewer APIs, uploads, history, blobs, shares management, and the device
sync store proxy; /api/projects lists only your orgs' projects, and
project names are now scoped per org. Public share links (/s/) stay
public by design.

Design choices, per the simplest-consistent rule:
- Migration: a pre-org hub sweeps all org-less projects into a "default"
  org at startup; ALL existing accounts join it (they could all see every
  project before, so anything narrower would lock someone out), oldest
  account as owner. Zero manual steps.
- An account in no org that creates a project gets a fresh org named
  after itself, so nobody is ever blocked from starting to sync.
- Invites are expiring multi-use links (default 7 days): an owner mints
  /#join/<token>, any signed-in account that opens it joins as member.
  The web UI shows the org in a sidebar footer (members on click,
  Invite button for owners).

bdrive init needed no changes: its connect-existing flow lists projects
through the now-filtered API.

Tests: OrgDB + migration units; a 403/access matrix over every
per-project route; invite mint/redeem flow; and a multi-device syncer
test proving a device holding an org-B token can neither pull org A's
files nor push into its store (sync degrades to Offline, never partial).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
2026-07-08 17:03:48 -07:00

190 lines
5.6 KiB
Go

package webapp
import (
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"github.com/runbear-io/beardrive/internal/remote"
)
// The store API (/api/store/*) lets other devices sync through this server
// instead of talking to the object store themselves: the server is the only
// machine that knows where the storage is or holds credentials. It exposes
// the same key space every backend uses (blobs/<sha256>, journal/<dev>.jsonl)
// so the regular sync machinery works unchanged over it.
//
// Reads are always allowed (this is the same data the viewer serves). Writes
// follow the server's upload setting and go direct-to-storage via presigned
// URLs when the backend can sign, exactly like browser uploads.
var (
blobKeyRe = regexp.MustCompile(`^blobs/[0-9a-f]{64}$`)
journalKeyRe = regexp.MustCompile(`^journal/[A-Za-z0-9._-]+\.jsonl$`)
)
func validStoreKey(key string) bool {
return blobKeyRe.MatchString(key) || journalKeyRe.MatchString(key)
}
// storeSource returns the volume's RemoteSource; only real beardrive
// remotes have a store to expose.
func storeSource(v *volume, w http.ResponseWriter) *RemoteSource {
rs, ok := v.source.(*RemoteSource)
if !ok {
http.Error(w, "this server does not front a beardrive remote", http.StatusNotFound)
return nil
}
return rs
}
func (s *Server) storeKey(w http.ResponseWriter, r *http.Request) (string, bool) {
key := r.URL.Query().Get("key")
if !validStoreKey(key) {
http.Error(w, fmt.Sprintf("invalid store key %q", key), http.StatusBadRequest)
return "", false
}
return key, true
}
func (s *Server) handleStoreList(v *volume, w http.ResponseWriter, r *http.Request) {
rs := storeSource(v, w)
if rs == nil {
return
}
s.observeDevice(r)
prefix := r.URL.Query().Get("prefix")
if prefix != "" && prefix != "journal/" && prefix != "blobs/" &&
!strings.HasPrefix(prefix, "journal/") && !strings.HasPrefix(prefix, "blobs/") {
http.Error(w, fmt.Sprintf("invalid prefix %q", prefix), http.StatusBadRequest)
return
}
objs, err := rs.Backend.List(r.Context(), prefix)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]any{"objects": objs})
}
func (s *Server) handleStoreGet(v *volume, w http.ResponseWriter, r *http.Request) {
rs := storeSource(v, w)
if rs == nil {
return
}
s.observeDevice(r)
key, ok := s.storeKey(w, r)
if !ok {
return
}
rc, err := rs.Backend.Get(r.Context(), key)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
defer rc.Close()
w.Header().Set("Content-Type", "application/octet-stream")
io.Copy(w, rc)
}
func (s *Server) handleStoreExists(v *volume, w http.ResponseWriter, r *http.Request) {
rs := storeSource(v, w)
if rs == nil {
return
}
s.observeDevice(r)
key, ok := s.storeKey(w, r)
if !ok {
return
}
exists, err := rs.Backend.Exists(r.Context(), key)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]any{"exists": exists})
}
// handleStoreSign answers how a client should upload a key: a presigned
// direct-to-storage URL when the backend can sign, through the server
// otherwise — same contract as browser uploads.
func (s *Server) handleStoreSign(v *volume, w http.ResponseWriter, r *http.Request) {
rs := storeSource(v, w)
if rs == nil {
return
}
s.observeDevice(r)
if !s.Upload.Enabled {
http.Error(w, "uploads are disabled on this server", http.StatusForbidden)
return
}
var req struct {
Key string `json:"key"`
Size int64 `json:"size"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
if !validStoreKey(req.Key) || req.Size < 0 {
http.Error(w, fmt.Sprintf("invalid store key %q", req.Key), http.StatusBadRequest)
return
}
if err := s.quota().CheckWrite(s.orgOf(r.PathValue("project")), req.Size); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
// Only blobs are presigned. They are content-addressed and immutable, so
// a leaked URL can at worst re-upload identical bytes. Journals are
// mutable state and always flow through the server.
if strings.HasPrefix(req.Key, "blobs/") {
if exists, err := rs.Backend.Exists(r.Context(), req.Key); err == nil && exists {
writeJSON(w, map[string]any{"mode": "direct", "exists": true})
return
}
if signer, ok := rs.Backend.(remote.PutSigner); ok {
if signed, err := signer.SignPut(r.Context(), req.Key, req.Size, s.Upload.ttl()); err == nil {
writeJSON(w, map[string]any{
"mode": "direct", "url": signed.URL, "method": signed.Method,
"headers": signed.Headers, "expires": signed.Expires.UTC(),
})
return
}
}
}
writeJSON(w, map[string]any{"mode": "server"})
}
func (s *Server) handleStorePut(v *volume, w http.ResponseWriter, r *http.Request) {
rs := storeSource(v, w)
if rs == nil {
return
}
s.observeDevice(r)
if !s.Upload.Enabled {
http.Error(w, "uploads are disabled on this server", http.StatusForbidden)
return
}
key, ok := s.storeKey(w, r)
if !ok {
return
}
org, size := s.orgOf(r.PathValue("project")), max(r.ContentLength, 0)
if err := s.quota().CheckWrite(org, size); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if err := rs.Backend.Put(r.Context(), key, r.Body, r.ContentLength); err != nil {
http.Error(w, fmt.Sprintf("store: %v", err), http.StatusBadGateway)
return
}
s.quota().RecordUsage(org, size)
if strings.HasPrefix(key, "journal/") {
v.invalidate() // new ops should show in the viewer immediately
}
writeJSON(w, map[string]any{"ok": true})
}