mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* feat(webapp): undo a whole agent run from the run card The run card was grouped for this and stopped one button short: every row inside it carried an action, the header carried none, so reverting a bad run meant clicking file by file and hoping you got them all. POST /api/p/<id>/undo-run works out, for every path the run touched, the op that puts it back — a put at the pre-run blob, or a delete for a file the run created — and writes them all in ONE journal append. That is the atomicity argument, not an optimization: one Put of one object either lands or it does not, so there is no half-undone run to report. appendOps is the batch write every path in the package now goes through; appendOp is its single-op call. Selection is by the journal an op was READ FROM, never op.Device — that field is arbitrary JSON any member with write access can put in their own journal, and the card attributes rows the same way. The note form additionally requires an empty Session, because runs.ts can never file a session-carrying op under a note-keyed card. Append-only throughout: the run's own ops are never edited or removed, so one-writer-per-journal and deterministic replay both survive. The undo's ops carry a note naming the run, so the undo is itself a run card you can undo. The confirm asks the server for the file list rather than deriving it from the loaded feed (paged and filterable, so a client-computed list is wrong exactly when the run is old), lists every path with its action, and names the one thing that can burn someone: a file a teammate changed after the run is reverted too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(webapp): the undo confirm names the paths it will not write planUndo already refuses a path the hub's own upload door would refuse — a peer can push one under .bdrive/ or with a control character in it — but the dialog listed only what the undo WOULD do, which reads as "all of it". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
561 lines
20 KiB
Go
561 lines
20 KiB
Go
package webapp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
)
|
|
|
|
// Uploads run in two modes, chosen by the server per request so the client
|
|
// never needs to know what the storage is:
|
|
//
|
|
// direct: POST /api/upload/init returns a presigned, expiring URL; the
|
|
// client PUTs the content straight to the object store (blob first,
|
|
// keyed by its sha256), then POST /api/upload/commit journals it.
|
|
// Storage credentials never leave the server.
|
|
// server: the backend can't presign (file://, plain folders); the client
|
|
// PUTs the content to /api/upload/content and the server stores it.
|
|
//
|
|
// The blobs-before-journal invariant holds in both modes: commit refuses to
|
|
// journal an op whose blob is not already in the store.
|
|
|
|
// Uploader is implemented by sources that accept writes through the server.
|
|
// who is the signed-in account the write should be attributed to (zero when
|
|
// auth is off), and note rides along on the journaled op — "" for an ordinary
|
|
// browser upload, a stated origin when the HUB itself authored the bytes
|
|
// (seedTemplate). Both are on the interface because the journal is the hub's
|
|
// only audit surface: a write with no human behind it must not be able to
|
|
// present as one by omission.
|
|
type Uploader interface {
|
|
Upload(ctx context.Context, path string, r io.Reader, size int64, who User, note string) error
|
|
}
|
|
|
|
// DirectUploader is additionally implemented by sources whose storage can
|
|
// accept presigned direct uploads.
|
|
type DirectUploader interface {
|
|
Uploader
|
|
SignBlobPut(ctx context.Context, blob string, size int64, ttl time.Duration) (*remote.SignedPut, error)
|
|
// BlobSize reports the stored size of a blob and whether it is there at
|
|
// all. Size comes from storage, never from the caller: in direct mode the
|
|
// server never sees the bytes, so this is the only true byte count it can
|
|
// quota-check and journal.
|
|
BlobSize(ctx context.Context, blob string) (int64, bool, error)
|
|
// note rides along on the journaled op — "" for an ordinary upload,
|
|
// "restore <path>@<sha8>" when the write is a restore.
|
|
Commit(ctx context.Context, path, blob string, size int64, who User, note string) error
|
|
}
|
|
|
|
// ---- RemoteSource: writes go to the object store + our own journal ----
|
|
|
|
// SignBlobPut presigns a direct upload of the blob, if the backend can sign.
|
|
func (r *RemoteSource) SignBlobPut(ctx context.Context, blob string, size int64, ttl time.Duration) (*remote.SignedPut, error) {
|
|
signer, ok := r.Backend.(remote.PutSigner)
|
|
if !ok {
|
|
return nil, fmt.Errorf("backend cannot presign uploads")
|
|
}
|
|
return signer.SignPut(ctx, "blobs/"+blob, size, ttl)
|
|
}
|
|
|
|
func (r *RemoteSource) BlobSize(ctx context.Context, blob string) (int64, bool, error) {
|
|
o, ok, err := r.blobStat(ctx, blob)
|
|
return o.Size, ok, err
|
|
}
|
|
|
|
// blobStat is the stored object behind a blob: its size, its last-modified
|
|
// time (zero on a backend that does not report one), and whether it is there.
|
|
// One metadata call, no egress.
|
|
func (r *RemoteSource) blobStat(ctx context.Context, blob string) (remote.Object, bool, error) {
|
|
key := "blobs/" + blob
|
|
objs, err := r.Backend.List(ctx, key) // full key as prefix: at most one hit
|
|
if err != nil {
|
|
return remote.Object{}, false, err
|
|
}
|
|
for _, o := range objs {
|
|
if o.Key == key {
|
|
return o, true, nil
|
|
}
|
|
}
|
|
return remote.Object{}, false, nil
|
|
}
|
|
|
|
// presignTTL is how long a presigned upload URL to this source stays valid —
|
|
// the window in which a blob is writable by someone other than the hub.
|
|
func (r *RemoteSource) presignTTL() time.Duration {
|
|
if r.PresignTTL > 0 {
|
|
return r.PresignTTL
|
|
}
|
|
return DefaultUploadTTL
|
|
}
|
|
|
|
// spool reads src to a temp file, rewound and ready to re-read, and reports
|
|
// its real size and sha256. The server needs both before it stores anything:
|
|
// a client's declared size and a content address are claims, not facts. The
|
|
// caller closes and removes the file.
|
|
func spool(src io.Reader) (f *os.File, size int64, sum string, err error) {
|
|
tmp, err := os.CreateTemp("", ".bdrive-tmp-upload-")
|
|
if err != nil {
|
|
return nil, 0, "", err
|
|
}
|
|
h := sha256.New()
|
|
size, err = io.Copy(tmp, io.TeeReader(src, h))
|
|
if err == nil {
|
|
_, err = tmp.Seek(0, io.SeekStart)
|
|
}
|
|
if err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmp.Name())
|
|
return nil, 0, "", err
|
|
}
|
|
return tmp, size, hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
// Upload stores content through the server: spool to disk while hashing,
|
|
// push the blob, then journal the op.
|
|
func (r *RemoteSource) Upload(ctx context.Context, p string, src io.Reader, _ int64, who User, note string) error {
|
|
tmp, size, blob, err := spool(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
defer tmp.Close()
|
|
// Blob before journal, always.
|
|
if err := r.Backend.Put(ctx, "blobs/"+blob, tmp, size); err != nil {
|
|
return fmt.Errorf("push blob: %w", err)
|
|
}
|
|
return r.Commit(ctx, p, blob, size, who, note)
|
|
}
|
|
|
|
// Commit appends a put op for path→blob to this server's own journal. It
|
|
// refuses if the blob is not in the store yet (a peer must never see an op
|
|
// whose content is missing). Only this server writes this journal key, so
|
|
// the read-modify-write below has a single writer; upmu serializes it across
|
|
// concurrent requests.
|
|
func (r *RemoteSource) Commit(ctx context.Context, p, blob string, size int64, who User, note string) error {
|
|
if r.Device.ID == "" {
|
|
return fmt.Errorf("no device identity configured for uploads")
|
|
}
|
|
_, ok, err := r.BlobSize(ctx, blob)
|
|
if err != nil {
|
|
return fmt.Errorf("check blob: %w", err)
|
|
}
|
|
if !ok {
|
|
return errBlobMissing
|
|
}
|
|
return r.appendOp(ctx, journal.Op{
|
|
Kind: journal.KindPut, Path: p, Blob: blob, Size: size, Mode: 0o644,
|
|
User: who.Email, UserName: who.Name, Note: note,
|
|
})
|
|
}
|
|
|
|
// nextLamport is maxLamport+1 without the wrap.
|
|
func nextLamport(cur int64) int64 {
|
|
if cur == math.MaxInt64 {
|
|
return cur
|
|
}
|
|
return cur + 1
|
|
}
|
|
|
|
// appendOp stamps op with this server's identity and ordering and appends it
|
|
// to this server's own journal. Callers fill in Kind/Path and the content
|
|
// fields; Seq, Lamport, Time and the device fields belong to us.
|
|
func (r *RemoteSource) appendOp(ctx context.Context, op journal.Op) error {
|
|
return r.appendOps(ctx, []journal.Op{op})
|
|
}
|
|
|
|
// appendOps is the same write for N ops at once, in ONE read-modify-write.
|
|
// Every journal write in this package goes through it — appendOp is the
|
|
// single-op call — so there is exactly one place that stamps the hub's
|
|
// identity and exactly one that rewrites the key.
|
|
//
|
|
// The batch is not an optimization detail: it is what makes a multi-path
|
|
// write atomic. One Put of one object either lands or it does not, so a
|
|
// run-wide undo (undorun.go) can never leave half a run reverted. A caller
|
|
// that loops appendOp instead gets N whole-journal round trips AND a
|
|
// partially-applied write with nothing to report it.
|
|
func (r *RemoteSource) appendOps(ctx context.Context, ops []journal.Op) error {
|
|
// Never rewrite an unchanged journal: a Put of identical bytes still
|
|
// bumps Modified, which invalidates every reader's journal cache.
|
|
if len(ops) == 0 {
|
|
return nil
|
|
}
|
|
r.upmu.Lock()
|
|
defer r.upmu.Unlock()
|
|
|
|
all, err := r.loadOps(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var maxLamport, mySeq int64
|
|
for _, prev := range all {
|
|
maxLamport = max(maxLamport, prev.Lamport)
|
|
if prev.Device == r.Device.ID {
|
|
mySeq = max(mySeq, prev.Seq)
|
|
}
|
|
}
|
|
now := time.Now().UTC()
|
|
lam := maxLamport
|
|
for i := range ops {
|
|
// Saturating, like the client's tickLamport. maxLamport is taken over
|
|
// every journal the hub can see, members' included, and int64 addition
|
|
// wraps: one pushed op carrying MaxInt64 made the hub's next lamport
|
|
// MinInt64 — recomputed on every commit, so every later browser upload
|
|
// in the project silently lost last-writer-wins while commit still
|
|
// answered 200. At saturation the batch's lamports stop increasing and
|
|
// journal.Less orders the rest through (time, device, seq), which is
|
|
// still a total order — so replay stays deterministic.
|
|
lam = nextLamport(lam)
|
|
ops[i].Seq, ops[i].Lamport, ops[i].Time = mySeq+int64(i)+1, lam, now
|
|
ops[i].Device, ops[i].DeviceName, ops[i].Author = r.Device.ID, r.Device.Name, r.Device.Author
|
|
}
|
|
|
|
// Read-modify-write of our own journal. A transient read error must fail
|
|
// the commit — treating it as "no journal yet" would rewrite the key
|
|
// without our earlier ops.
|
|
key := "journal/" + r.Device.ID + ".jsonl"
|
|
var existing []byte
|
|
if exists, err := r.Backend.Exists(ctx, key); err != nil {
|
|
return fmt.Errorf("check journal: %w", err)
|
|
} else if exists {
|
|
rc, err := r.Backend.Get(ctx, key)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch journal: %w", err)
|
|
}
|
|
existing, err = io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
line, err := journal.Marshal(ops)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data := append(existing, line...)
|
|
return r.Backend.Put(ctx, key, strings.NewReader(string(data)), int64(len(data)))
|
|
}
|
|
|
|
var errBlobMissing = fmt.Errorf("content not uploaded yet")
|
|
|
|
// emptyBlob is sha256(""), the only content whose size is legitimately zero.
|
|
const emptyBlob = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
|
|
// sizeFitsContentAddress is the one lie about a declared upload size that is
|
|
// provable before the bytes exist: zero, for content that is not the empty
|
|
// blob. In direct mode the bytes never pass through the hub, so the size is
|
|
// the caller's word — and it is the number the quota is charged against, which
|
|
// makes this exactly the lie that buys an unmetered presigned URL. Shared,
|
|
// because both doors take the number: /upload/init (browsers) and /store/sign
|
|
// (devices), and for a while only one of them checked.
|
|
func sizeFitsContentAddress(sha string, size int64) bool {
|
|
return size > 0 || sha == emptyBlob
|
|
}
|
|
|
|
// ---- DirSource: writes land straight in the folder ----
|
|
|
|
// Upload writes the file atomically under Root. There is no journal here;
|
|
// on a mounted folder the daemon scans, journals, and syncs it like any
|
|
// local edit.
|
|
func (d *DirSource) Upload(_ context.Context, p string, src io.Reader, _ int64, _ User, _ string) error {
|
|
dst := filepath.Join(d.Root, filepath.FromSlash(p))
|
|
// cleanUploadPath rules out "..", but a path with no ".." in it still
|
|
// leaves the folder by walking through a symlinked directory that was
|
|
// already there. The viewer promises "this folder": resolve where the
|
|
// write would actually land and refuse anything outside Root.
|
|
//
|
|
// Judged BEFORE anything is created. underRoot needs a directory that
|
|
// exists (EvalSymlinks), so asking after MkdirAll meant a refused upload
|
|
// had already built the whole parent chain on the other side of the
|
|
// symlink. The deepest existing ancestor answers the same question: every
|
|
// directory MkdirAll then creates is a real directory beneath it.
|
|
if err := underRoot(d.Root, existingDir(filepath.Dir(dst))); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(dst), ".bdrive-tmp-")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
if _, err := io.Copy(tmp, src); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp.Name(), dst)
|
|
}
|
|
|
|
// existingDir walks up from dir to the deepest ancestor that exists on disk.
|
|
func existingDir(dir string) string {
|
|
for {
|
|
if _, err := os.Lstat(dir); err == nil {
|
|
return dir
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return dir
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// underRoot reports whether dir, with every symlink resolved, is root or
|
|
// inside it.
|
|
func underRoot(root, dir string) error {
|
|
realRoot, err := filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
realDir, err := filepath.EvalSymlinks(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if realDir != realRoot && !strings.HasPrefix(realDir, realRoot+string(filepath.Separator)) {
|
|
return fmt.Errorf("path leaves the served folder")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- HTTP handlers ----
|
|
|
|
var blobRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
|
|
// cleanUploadPath validates a client-supplied destination path and returns
|
|
// its normalized form.
|
|
func cleanUploadPath(p string) (string, error) {
|
|
// journal.SafePath is the rule — the same one the /store/* journal door
|
|
// and the device's unsafeRel apply. This door used to carry its own copy
|
|
// of it; the copies disagreed, which is how a control character that this
|
|
// door answered 400 to got journaled through the other one.
|
|
if !journal.SafePath(p) {
|
|
return "", fmt.Errorf("invalid path %q", p)
|
|
}
|
|
cl := p
|
|
// The same set the scan walk would never have uploaded, at any depth:
|
|
// .bdrive/ is the mount's own identity (an upload of it repoints every
|
|
// device that pulls) and .git/ materializes hook scripts that run on a
|
|
// teammate's next commit. materialize re-checks this too — an op can also
|
|
// arrive from a peer's journal — but a hub that journals one has already
|
|
// handed it to every device.
|
|
if config.ReservedPath(cl) {
|
|
return "", fmt.Errorf("reserved name %q", cl)
|
|
}
|
|
return cl, nil
|
|
}
|
|
|
|
// gateUpload enforces the server's upload setting and returns the volume's
|
|
// writable source, failing the request if uploads are off or the source is
|
|
// read-only.
|
|
func (s *Server) gateUpload(v *volume, w http.ResponseWriter) Uploader {
|
|
if !s.Upload.Enabled {
|
|
http.Error(w, "uploads are disabled on this server", http.StatusForbidden)
|
|
return nil
|
|
}
|
|
up := v.uploader()
|
|
if up == nil {
|
|
http.Error(w, "this source is read-only", http.StatusForbidden)
|
|
}
|
|
return up
|
|
}
|
|
|
|
type uploadReq struct {
|
|
Path string `json:"path"`
|
|
SHA256 string `json:"sha256"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
func (s *Server) decodeUpload(w http.ResponseWriter, r *http.Request, needBlob bool) (uploadReq, bool) {
|
|
var req uploadReq
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
return req, false
|
|
}
|
|
p, err := cleanUploadPath(req.Path)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return req, false
|
|
}
|
|
req.Path = p
|
|
if req.Size < 0 {
|
|
http.Error(w, "invalid size", http.StatusBadRequest)
|
|
return req, false
|
|
}
|
|
if needBlob && !blobRe.MatchString(req.SHA256) {
|
|
http.Error(w, "sha256 must be 64 lowercase hex chars", http.StatusBadRequest)
|
|
return req, false
|
|
}
|
|
return req, true
|
|
}
|
|
|
|
// handleUploadInit tells the client how to upload this content: a presigned
|
|
// direct URL when the storage supports it, otherwise through the server.
|
|
func (s *Server) handleUploadInit(v *volume, w http.ResponseWriter, r *http.Request) {
|
|
up := s.gateUpload(v, w)
|
|
if up == nil {
|
|
return
|
|
}
|
|
req, ok := s.decodeUpload(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !sizeFitsContentAddress(req.SHA256, req.Size) {
|
|
http.Error(w, "declared size does not match the content address", http.StatusForbidden)
|
|
return
|
|
}
|
|
project := r.PathValue("project")
|
|
if rs, ok := v.source.(*RemoteSource); ok {
|
|
s.reconcileGrants(r.Context(), project, rs.Backend)
|
|
}
|
|
org := s.orgOf(project)
|
|
if err := s.quota().CheckWrite(org, req.Size+s.reservedBytes(org)); err != nil {
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
if direct, isDirect := up.(DirectUploader); isDirect {
|
|
if _, exists, err := direct.BlobSize(r.Context(), req.SHA256); err == nil && exists {
|
|
// Content already in the store (same file elsewhere, or a retry):
|
|
// skip the upload, go straight to commit.
|
|
writeJSON(w, map[string]any{"mode": "direct", "exists": true})
|
|
return
|
|
}
|
|
// Reserved, exactly like the device door: counted against the cap now,
|
|
// charged when the object is confirmed in storage, released for free
|
|
// if the caller never uploads. A browser upload that never comes back
|
|
// to commit is therefore still billed. Check and reservation are one
|
|
// critical section (see reserveIfFits).
|
|
if err := s.reserveIfFits(project, org, "blobs/"+req.SHA256, req.Size, s.Upload.ttl()); err != nil {
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
signed, err := direct.SignBlobPut(r.Context(), req.SHA256, req.Size, s.Upload.ttl())
|
|
if err == nil {
|
|
writeJSON(w, map[string]any{
|
|
"mode": "direct",
|
|
"url": signed.URL,
|
|
"method": signed.Method,
|
|
"headers": signed.Headers,
|
|
"expires": signed.Expires.UTC(),
|
|
})
|
|
return
|
|
}
|
|
// Backend can't presign right now (e.g. credentials that can't
|
|
// sign): degrade to uploading through the server.
|
|
s.claimGrant(project, "blobs/"+req.SHA256) // nothing was granted
|
|
}
|
|
writeJSON(w, map[string]any{"mode": "server"})
|
|
}
|
|
|
|
// handleUploadContent receives content through the server (server mode).
|
|
func (s *Server) handleUploadContent(v *volume, w http.ResponseWriter, r *http.Request) {
|
|
up := s.gateUpload(v, w)
|
|
if up == nil {
|
|
return
|
|
}
|
|
p, err := cleanUploadPath(r.URL.Query().Get("path"))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Spool first, then charge what actually arrived. Content-Length is -1 on
|
|
// any chunked request, so max(r.ContentLength, 0) admitted an upload of any
|
|
// size against a quota of zero bytes and billed it at zero — the hole round
|
|
// 1 closed on the device door, still open on this one.
|
|
tmp, size, _, err := spool(r.Body)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("store: %v", err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
defer tmp.Close()
|
|
project := r.PathValue("project")
|
|
if rs, ok := v.source.(*RemoteSource); ok {
|
|
s.reconcileGrants(r.Context(), project, rs.Backend)
|
|
}
|
|
org := s.orgOf(project)
|
|
if err := s.quota().CheckWrite(org, size+s.reservedBytes(org)); err != nil {
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
if err := up.Upload(r.Context(), p, tmp, size, s.requestUser(r), ""); err != nil {
|
|
http.Error(w, fmt.Sprintf("store: %v", err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
s.quota().RecordUsage(org, size)
|
|
v.invalidate()
|
|
s.captureChange(r, "browser", 1, 0)
|
|
writeJSON(w, map[string]any{"ok": true, "path": p})
|
|
}
|
|
|
|
// handleUploadCommit journals a direct upload after the blob is in the store.
|
|
func (s *Server) handleUploadCommit(v *volume, w http.ResponseWriter, r *http.Request) {
|
|
up := s.gateUpload(v, w)
|
|
if up == nil {
|
|
return
|
|
}
|
|
direct, isDirect := up.(DirectUploader)
|
|
if !isDirect {
|
|
http.Error(w, "this source has no direct-upload commit", http.StatusBadRequest)
|
|
return
|
|
}
|
|
req, ok := s.decodeUpload(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
// The blob may already sit in storage (direct upload), but commit is what
|
|
// makes it part of the volume — so it is the accounting point, and the
|
|
// only one in direct mode. Size comes from storage: req.Size is the
|
|
// caller's claim, and it would otherwise be both the quota charge and the
|
|
// journaled Op.Size.
|
|
// Bytes are charged once, where they land. If this commit finalizes a
|
|
// grant nothing has confirmed yet, it is the one charging them, from the
|
|
// size storage reports; if the reconciler (or a relayed put) already did,
|
|
// there is nothing left to claim and commit charges nothing — committing
|
|
// a path is not a second copy of the content.
|
|
claimed := s.claimGrant(r.PathValue("project"), "blobs/"+req.SHA256)
|
|
size, exists, err := direct.BlobSize(r.Context(), req.SHA256)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("commit: %v", err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
if !exists {
|
|
http.Error(w, fmt.Sprintf("commit: %v", errBlobMissing), http.StatusConflict)
|
|
return
|
|
}
|
|
org := s.orgOf(r.PathValue("project"))
|
|
if err := s.quota().CheckWrite(org, size); err != nil {
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
if err := direct.Commit(r.Context(), req.Path, req.SHA256, size, s.requestUser(r), ""); err != nil {
|
|
code := http.StatusBadGateway
|
|
if err == errBlobMissing {
|
|
code = http.StatusConflict
|
|
}
|
|
http.Error(w, fmt.Sprintf("commit: %v", err), code)
|
|
return
|
|
}
|
|
if claimed {
|
|
s.quota().RecordUsage(org, size)
|
|
}
|
|
v.invalidate()
|
|
s.captureChange(r, "browser", 1, 0)
|
|
writeJSON(w, map[string]any{"ok": true, "path": req.Path})
|
|
}
|