mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-04 01:06:57 +00:00
a7569a7added credential-less pulls of public images. Three things about it were wrong, all of them in how the appview handled the decision that belongs to the hold. **Scope handling was all-or-nothing.** IsPullOnlyScope required every requested action to already be "pull", but clients routinely ask for more than the operation needs — pull,push is common for a plain read, and some ask for pull,push,delete up front. Those were rejected and challenged, leaving a credential-less client no way to pull even a public image, which is the entire feature. NarrowToPullOnly drops the write actions and issues a token carrying "pull" and nothing else. Granting a subset is what the distribution token spec expects. The allowlist property is preserved: "pull" is the only action that survives, and "*" is deliberately not expanded into it, since a wildcard request is not evidence the caller wants a read. **The appview-side read gate was inert.** checkReadAccess passed p.ctx.DID, the DID of the repository *owner*, not the requester. Any non-empty DID satisfies a private hold's check, and the owner's is never empty, so it asked "may the owner read their own hold", answered yes, and admitted everyone. Worse, CheckReadAccessWithCaptain admitted any authenticated DID to a private hold at all, on an explicitly-MVP assumption that holding a DID was close enough to being a sailor. Every doc says otherwise (docs/hold.md:109 "Crew with blob:read", CLAUDE.md:140, docs/BYOS.md:280) and so does the hold (ValidateBlobReadAccess: owner, or crew carrying blob:read/blob:write). It now takes isCrew and requires owner-or-crew, and callers only pay for the crew lookup when it can change the answer — a public hold or an anonymous caller is decided by the captain record alone. Nothing here loosens access; it brings the local gate into agreement with the authority. **Denials could not reach the client.** distribution's blobHandler.GetBlob maps everything except ErrBlobUnknown to ErrorCodeUnknown, so a 401 raised in the blob store left as a 500 — misreporting an auth failure as a server fault, and giving BearerChallenge no 401 to attach WWW-Authenticate to, so Docker was told "server error" instead of being prompted for credentials. Clients that retry 5xx looped: 4.1s per case in the matrix, now 0.01s. The check moves to Repository(), where an errcode.Error is passed through verbatim by the registry app — the same mechanisma7569a7used for NAME_UNKNOWN. It fails open on a lookup error, since the hold is the authority and a transient failure should not break public pulls. Removes auth.allow_anonymous_pull. It could only ever withhold — captain.Public is what grants — so it was a second flag for a decision the hold already owns, and gating it appview-side was never the intent. Layer bytes 307 straight to S3, so the appview is not even in the path whose cost might have justified an operator-side lever. Tests: TestAuthMatrix only ever ran against a public hold, and its pull cases never fetched a layer — crane.Pull is lazy and img.Digest() needs only the manifest, which ATCR serves from the user's PDS where it is world-readable, so no pull row in the matrix touched blob authorization at all. Pulls now materialize layer bytes, and testharness.WithPrivateHold plus TestAuthMatrixPrivateHold cover public:false + allow_all_crew:true — the production shape, where anyone with an account pulls and pushes and anonymous gets nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
806 lines
24 KiB
Go
806 lines
24 KiB
Go
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/distribution/distribution/v3"
|
|
"github.com/distribution/distribution/v3/registry/api/errcode"
|
|
"github.com/opencontainers/go-digest"
|
|
)
|
|
|
|
const (
|
|
// maxChunkSize is the maximum buffer size before flushing to hold service
|
|
// Matches S3's minimum multipart upload size
|
|
maxChunkSize = 10 * 1024 * 1024 // 10MB
|
|
)
|
|
|
|
// Global upload tracking (shared across all ProxyBlobStore instances)
|
|
// This is necessary because distribution creates new repository/blob store instances per request
|
|
var (
|
|
globalUploads = make(map[string]*ProxyBlobWriter)
|
|
globalUploadsMu sync.RWMutex
|
|
)
|
|
|
|
// ProxyBlobStore proxies blob requests to an external storage service
|
|
type ProxyBlobStore struct {
|
|
ctx *RegistryContext // All context and services
|
|
holdURL string // Resolved HTTP URL for XRPC requests
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewProxyBlobStore creates a new proxy blob store
|
|
func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore {
|
|
// Use pre-resolved URL from RegistryContext (resolved in Registry.Repository())
|
|
holdURL := ctx.HoldURL
|
|
|
|
slog.Debug("NewProxyBlobStore created", "component", "proxy_blob_store", "hold_did", ctx.HoldDID, "hold_url", holdURL, "user_did", ctx.DID, "repo", ctx.Repository)
|
|
|
|
return &ProxyBlobStore{
|
|
ctx: ctx,
|
|
holdURL: holdURL,
|
|
httpClient: &http.Client{
|
|
Timeout: 5 * time.Minute, // Timeout for presigned URL requests and uploads
|
|
Transport: &http.Transport{
|
|
DisableKeepAlives: false, // Re-enable keep-alive
|
|
MaxIdleConns: 100,
|
|
MaxIdleConnsPerHost: 100,
|
|
MaxConnsPerHost: 0, // unlimited
|
|
IdleConnTimeout: 90 * time.Second,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// doAuthenticatedRequest performs an HTTP request to the hold service, attaching
|
|
// the service token when one is present. An empty service token means an
|
|
// anonymous pull: the request is sent without an Authorization header and the
|
|
// hold authorizes it per captain.Public. Write call sites (multipart upload) are
|
|
// push-only and always carry a service token, so they never go out unauthenticated.
|
|
func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
|
|
// Service token was validated and cached by middleware (which fails fast with
|
|
// HTTP 401 if the OAuth session is invalid). Anonymous reads have none.
|
|
if p.ctx.ServiceToken != "" {
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.ctx.ServiceToken))
|
|
}
|
|
|
|
return p.httpClient.Do(req)
|
|
}
|
|
|
|
// checkReadAccess validates that the user has read access to blobs in this hold
|
|
func (p *ProxyBlobStore) checkReadAccess(ctx context.Context) error {
|
|
if p.ctx.Authorizer == nil {
|
|
return nil // No authorization check if authorizer not configured
|
|
}
|
|
// Authorize the *requester*, not the repository owner. p.ctx.DID is the
|
|
// owner whose namespace is being read; passing it here asked "may the owner
|
|
// read their own hold", which is true for every private hold (any non-empty
|
|
// DID satisfies CheckReadAccessWithCaptain), so an anonymous request sailed
|
|
// through this gate and the Anonymous branch below was unreachable. An
|
|
// anonymous request has no identity, so it must be judged as one: only
|
|
// captain.Public can admit it.
|
|
requesterDID := p.ctx.DID
|
|
if p.ctx.Anonymous {
|
|
requesterDID = ""
|
|
}
|
|
allowed, err := p.ctx.Authorizer.CheckReadAccess(ctx, p.ctx.HoldDID, requesterDID)
|
|
if err != nil {
|
|
return fmt.Errorf("authorization check failed: %w", err)
|
|
}
|
|
if !allowed {
|
|
if p.ctx.Anonymous {
|
|
// Anonymous request to a private hold: surface a 401 so the Docker
|
|
// client prompts for credentials rather than treating it as a hard
|
|
// 403. The BearerChallenge middleware attaches WWW-Authenticate.
|
|
return errcode.ErrorCodeUnauthorized.WithMessage("authentication required")
|
|
}
|
|
// Authenticated but unauthorized: 403 Forbidden instead of masquerading
|
|
// as a missing blob, and without bouncing the user back to re-auth.
|
|
return errcode.ErrorCodeDenied.WithMessage("read access denied")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Stat returns the descriptor for a blob
|
|
func (p *ProxyBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) {
|
|
// Check read access
|
|
if err := p.checkReadAccess(ctx); err != nil {
|
|
return distribution.Descriptor{}, err
|
|
}
|
|
|
|
method := "HEAD"
|
|
|
|
url, err := p.getPresignedURL(ctx, method, dgst)
|
|
if err != nil {
|
|
// Preserve an authorization verdict. distribution calls Stat before
|
|
// ServeBlob on both GET and HEAD, so flattening everything to
|
|
// ErrBlobUnknown here turns the hold's "private, authenticate first" into
|
|
// a 404 and leaves BearerChallenge with no 401 to annotate — the client
|
|
// is told the blob doesn't exist instead of being asked for credentials.
|
|
var ecErr errcode.Error
|
|
if errors.As(err, &ecErr) {
|
|
return distribution.Descriptor{}, err
|
|
}
|
|
return distribution.Descriptor{}, distribution.ErrBlobUnknown
|
|
}
|
|
|
|
// Make HEAD request to presigned URL
|
|
req, err := http.NewRequestWithContext(ctx, method, url, nil)
|
|
if err != nil {
|
|
return distribution.Descriptor{}, distribution.ErrBlobUnknown
|
|
}
|
|
|
|
// Go directly to the presigned URL, no need to authenticate
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return distribution.Descriptor{}, distribution.ErrBlobUnknown
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return distribution.Descriptor{}, distribution.ErrBlobUnknown
|
|
}
|
|
|
|
// Return a minimal descriptor with size from Content-Length if available
|
|
size := int64(0)
|
|
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
|
|
if parsed, err := strconv.ParseInt(contentLength, 10, 64); err == nil {
|
|
size = parsed
|
|
}
|
|
}
|
|
|
|
return distribution.Descriptor{
|
|
Digest: dgst,
|
|
Size: size,
|
|
MediaType: "application/octet-stream",
|
|
}, nil
|
|
}
|
|
|
|
// Get retrieves a blob
|
|
func (p *ProxyBlobStore) Get(ctx context.Context, dgst digest.Digest) ([]byte, error) {
|
|
// Check read access
|
|
if err := p.checkReadAccess(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
method := "GET"
|
|
|
|
url, err := p.getPresignedURL(ctx, method, dgst)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Download the blob from presigned URL
|
|
req, err := http.NewRequestWithContext(ctx, method, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Go directly to the presigned URL, no need to authenticate
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, distribution.ErrBlobUnknown
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// Open returns a reader for a blob
|
|
func (p *ProxyBlobStore) Open(ctx context.Context, dgst digest.Digest) (io.ReadSeekCloser, error) {
|
|
// Check read access
|
|
if err := p.checkReadAccess(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
method := "GET"
|
|
|
|
url, err := p.getPresignedURL(ctx, method, dgst)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Download the blob from presigned URL
|
|
req, err := http.NewRequestWithContext(ctx, method, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Go directly to the presigned URL, no need to authenticate
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
return nil, distribution.ErrBlobUnknown
|
|
}
|
|
|
|
// Wrap in a ReadSeekCloser
|
|
return &readSeekCloser{
|
|
ReadCloser: resp.Body,
|
|
}, nil
|
|
}
|
|
|
|
// Put stores a blob using the multipart upload flow
|
|
// This ensures all uploads go through the same XRPC path
|
|
//
|
|
// Write authorization is gated at /auth/token (pkg/appview/authgate); the
|
|
// JWT carries the resolved authorization for its lifetime. Hold-side
|
|
// requireBlobWriteAccess is the final defense (validates the service-token
|
|
// audience). No re-check needed here.
|
|
func (p *ProxyBlobStore) Put(ctx context.Context, mediaType string, content []byte) (distribution.Descriptor, error) {
|
|
// Calculate digest
|
|
dgst := digest.FromBytes(content)
|
|
|
|
// Use Create() flow for all uploads (goes through multipart XRPC endpoints)
|
|
writer, err := p.Create(ctx)
|
|
if err != nil {
|
|
slog.Error("Failed to create writer", "component", "proxy_blob_store/Put", "error", err)
|
|
return distribution.Descriptor{}, err
|
|
}
|
|
|
|
// Write the content
|
|
if _, err := writer.Write(content); err != nil {
|
|
writer.Cancel(ctx)
|
|
slog.Error("Failed to write content", "component", "proxy_blob_store/Put", "error", err)
|
|
return distribution.Descriptor{}, err
|
|
}
|
|
|
|
// Commit with the calculated digest
|
|
desc, err := writer.Commit(ctx, distribution.Descriptor{
|
|
Digest: dgst,
|
|
Size: int64(len(content)),
|
|
MediaType: mediaType,
|
|
})
|
|
if err != nil {
|
|
slog.Error("Failed to commit", "component", "proxy_blob_store/Put", "error", err)
|
|
return distribution.Descriptor{}, err
|
|
}
|
|
|
|
slog.Debug("Upload successful", "component", "proxy_blob_store/Put", "digest", dgst, "size", len(content))
|
|
return desc, nil
|
|
}
|
|
|
|
// Delete removes a blob.
|
|
//
|
|
// Blob deletion is not offered on the client-facing OCI path: layer bytes live
|
|
// in the hold's S3 and are reclaimed by the hold's reference-counted GC once no
|
|
// manifest references them (see PurgeOnHold on manifest delete). Returning the
|
|
// distribution.ErrUnsupported sentinel makes the (always-registered) blob DELETE
|
|
// route respond with a clean OCI UNSUPPORTED error instead of a generic 500.
|
|
func (p *ProxyBlobStore) Delete(ctx context.Context, dgst digest.Digest) error {
|
|
return distribution.ErrUnsupported
|
|
}
|
|
|
|
// ServeBlob serves a blob via HTTP redirect or proxied response
|
|
func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error {
|
|
// Check read access
|
|
if err := p.checkReadAccess(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
url, err := p.getPresignedURL(ctx, r.Method, dgst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Redirect to presigned URL
|
|
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
|
return nil
|
|
}
|
|
|
|
// Create returns a blob writer for uploading using multipart upload.
|
|
//
|
|
// Write authorization is gated at /auth/token; see ProxyBlobStore.Put for
|
|
// the rationale on why we don't re-check here.
|
|
func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) {
|
|
// Parse options
|
|
var opts distribution.CreateOptions
|
|
for _, option := range options {
|
|
if err := option.Apply(&opts); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Generate unique writer ID
|
|
writerID := fmt.Sprintf("upload-%d", time.Now().UnixNano())
|
|
|
|
// Use temp digest for upload location (will be moved to final digest on commit)
|
|
tempDigest := fmt.Sprintf("uploads/temp-%s", writerID)
|
|
|
|
// Start multipart upload via hold service
|
|
uploadID, err := p.startMultipartUpload(ctx, tempDigest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
writer := &ProxyBlobWriter{
|
|
store: p,
|
|
options: opts,
|
|
uploadID: uploadID,
|
|
parts: make([]CompletedPart, 0),
|
|
partNumber: 1,
|
|
buffer: bytes.NewBuffer(make([]byte, 0, maxChunkSize)),
|
|
id: writerID,
|
|
startedAt: time.Now(),
|
|
}
|
|
|
|
// Store in global uploads map for resume support
|
|
globalUploadsMu.Lock()
|
|
globalUploads[writer.id] = writer
|
|
globalUploadsMu.Unlock()
|
|
|
|
return writer, nil
|
|
}
|
|
|
|
// Resume returns a blob writer for resuming an upload
|
|
func (p *ProxyBlobStore) Resume(ctx context.Context, id string) (distribution.BlobWriter, error) {
|
|
// Retrieve upload from global map
|
|
globalUploadsMu.RLock()
|
|
writer, ok := globalUploads[id]
|
|
globalUploadsMu.RUnlock()
|
|
|
|
if !ok {
|
|
return nil, distribution.ErrBlobUploadUnknown
|
|
}
|
|
|
|
// Just return the writer - parts are buffered and flushed on demand
|
|
return writer, nil
|
|
}
|
|
|
|
// getPresignedURL returns the XRPC endpoint URL for blob operations
|
|
func (p *ProxyBlobStore) getPresignedURL(ctx context.Context, operation string, dgst digest.Digest) (string, error) {
|
|
// Use XRPC endpoint: /xrpc/com.atproto.sync.getBlob?did={userDID}&cid={digest}
|
|
// The 'did' parameter is the USER's DID (whose blob we're fetching), not the hold service DID
|
|
// Per migration doc: hold accepts OCI digest directly as cid parameter (checks for sha256: prefix)
|
|
xrpcURL := fmt.Sprintf("%s%s?did=%s&cid=%s&method=%s",
|
|
p.holdURL, atproto.SyncGetBlob, p.ctx.DID, dgst.String(), operation)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", xrpcURL, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := p.doAuthenticatedRequest(ctx, req)
|
|
if err != nil {
|
|
// Don't wrap errcode errors - return them directly
|
|
if _, ok := err.(errcode.Error); ok {
|
|
return "", err
|
|
}
|
|
return "", fmt.Errorf("failed to get presigned URL: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusForbidden && p.ctx.Anonymous {
|
|
// Stale local captain cache let an anonymous request through, but the
|
|
// hold says private. Surface a 401 so the client re-authenticates.
|
|
return "", errcode.ErrorCodeUnauthorized.WithMessage("authentication required")
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("hold service returned error: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
// Parse JSON response to get presigned HEAD URL
|
|
var result struct {
|
|
URL string `json:"url"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", fmt.Errorf("failed to parse hold service response: %w", err)
|
|
}
|
|
|
|
if result.URL == "" {
|
|
return "", fmt.Errorf("hold service returned empty URL")
|
|
}
|
|
|
|
slog.Debug("Got presigned HEAD URL from hold service", "component", "proxy_blob_store", "url", result.URL)
|
|
return result.URL, nil
|
|
}
|
|
|
|
// startMultipartUpload initiates a multipart upload via XRPC initiateUpload endpoint
|
|
func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string) (string, error) {
|
|
reqBody := map[string]any{
|
|
"digest": digest,
|
|
}
|
|
|
|
body, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldInitiateUpload)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Use authenticated request (OAuth with DPoP)
|
|
resp, err := p.doAuthenticatedRequest(ctx, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("start multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var result struct {
|
|
UploadID string `json:"uploadId"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return result.UploadID, nil
|
|
}
|
|
|
|
// PartUploadInfo contains the presigned URL for uploading a part
|
|
type PartUploadInfo struct {
|
|
URL string `json:"url"` // Presigned URL to PUT the part to
|
|
}
|
|
|
|
// getPartUploadInfo gets structured upload info for uploading a specific part via XRPC
|
|
func (p *ProxyBlobStore) getPartUploadInfo(ctx context.Context, digest, uploadID string, partNumber int) (*PartUploadInfo, error) {
|
|
reqBody := map[string]any{
|
|
"uploadId": uploadID,
|
|
"partNumber": partNumber,
|
|
}
|
|
|
|
body, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldGetPartUploadURL)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Use authenticated request (OAuth with DPoP)
|
|
resp, err := p.doAuthenticatedRequest(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("get part URL failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var uploadInfo PartUploadInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&uploadInfo); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &uploadInfo, nil
|
|
}
|
|
|
|
// completeMultipartUpload completes a multipart upload via XRPC completeUpload endpoint
|
|
// The XRPC complete action handles the move from temp to final location internally
|
|
func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error {
|
|
// Convert parts to XRPC format
|
|
xrpcParts := make([]map[string]any, len(parts))
|
|
for i, part := range parts {
|
|
xrpcParts[i] = map[string]any{
|
|
"part_number": part.PartNumber,
|
|
"etag": part.ETag,
|
|
}
|
|
}
|
|
|
|
reqBody := map[string]any{
|
|
"uploadId": uploadID,
|
|
"digest": digest,
|
|
"parts": xrpcParts,
|
|
}
|
|
|
|
body, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldCompleteUpload)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Use authenticated request (OAuth with DPoP)
|
|
resp, err := p.doAuthenticatedRequest(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("complete multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// abortMultipartUpload aborts a multipart upload via XRPC abortUpload endpoint
|
|
func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, uploadID string) error {
|
|
reqBody := map[string]any{
|
|
"uploadId": uploadID,
|
|
}
|
|
|
|
body, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldAbortUpload)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Use authenticated request (OAuth with DPoP)
|
|
resp, err := p.doAuthenticatedRequest(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("abort multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CompletedPart represents an uploaded part with its ETag
|
|
type CompletedPart struct {
|
|
PartNumber int `json:"part_number"`
|
|
ETag string `json:"etag"`
|
|
}
|
|
|
|
// ProxyBlobWriter implements distribution.BlobWriter for proxy uploads using multipart upload
|
|
type ProxyBlobWriter struct {
|
|
store *ProxyBlobStore
|
|
options distribution.CreateOptions
|
|
uploadID string // S3 multipart upload ID
|
|
parts []CompletedPart // Track uploaded parts with ETags
|
|
partNumber int // Current part number (starts at 1)
|
|
buffer *bytes.Buffer // Buffer for current part
|
|
size int64 // Total bytes written
|
|
closed bool
|
|
id string // Distribution's upload ID (for state)
|
|
startedAt time.Time
|
|
}
|
|
|
|
// ID returns the upload ID
|
|
func (w *ProxyBlobWriter) ID() string {
|
|
return w.id
|
|
}
|
|
|
|
// StartedAt returns when the upload started
|
|
func (w *ProxyBlobWriter) StartedAt() time.Time {
|
|
return w.startedAt
|
|
}
|
|
|
|
// Write writes data to the upload
|
|
// Buffers data and flushes when buffer reaches 5MB
|
|
func (w *ProxyBlobWriter) Write(p []byte) (int, error) {
|
|
if w.closed {
|
|
return 0, fmt.Errorf("writer closed")
|
|
}
|
|
|
|
n, err := w.buffer.Write(p)
|
|
w.size += int64(n)
|
|
|
|
// Flush if buffer reaches limit (S3 part size)
|
|
if w.buffer.Len() >= maxChunkSize {
|
|
if err := w.flushPart(); err != nil {
|
|
return n, err
|
|
}
|
|
}
|
|
|
|
return n, err
|
|
}
|
|
|
|
// flushPart uploads the current buffer as a part
|
|
func (w *ProxyBlobWriter) flushPart() error {
|
|
if w.buffer.Len() == 0 {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
// Get structured upload info for this part
|
|
tempDigest := fmt.Sprintf("uploads/temp-%s", w.id)
|
|
uploadInfo, err := w.store.getPartUploadInfo(ctx, tempDigest, w.uploadID, w.partNumber)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get part upload info: %w", err)
|
|
}
|
|
|
|
// Upload part to S3 presigned URL
|
|
req, err := http.NewRequestWithContext(ctx, "PUT", uploadInfo.URL, bytes.NewReader(w.buffer.Bytes()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
|
|
resp, err := w.store.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("part upload failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
// Store ETag for completion
|
|
// For buffered mode, ETag might be in JSON response body
|
|
etag := resp.Header.Get("ETag")
|
|
if etag == "" {
|
|
// Try to parse JSON response for buffered mode
|
|
var result struct {
|
|
ETag string `json:"etag"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.ETag != "" {
|
|
etag = result.ETag
|
|
} else {
|
|
return fmt.Errorf("no ETag in response")
|
|
}
|
|
}
|
|
|
|
w.parts = append(w.parts, CompletedPart{
|
|
PartNumber: w.partNumber,
|
|
ETag: etag,
|
|
})
|
|
|
|
slog.Debug("Part uploaded successfully", "component", "proxy_blob_store/flushPart", "part_number", w.partNumber, "etag", etag)
|
|
|
|
// Reset buffer and increment part number
|
|
w.buffer.Reset()
|
|
w.partNumber++
|
|
|
|
return nil
|
|
}
|
|
|
|
// ReadFrom reads from a reader
|
|
func (w *ProxyBlobWriter) ReadFrom(r io.Reader) (int64, error) {
|
|
if w.closed {
|
|
return 0, fmt.Errorf("writer closed")
|
|
}
|
|
|
|
// Read in chunks and flush when needed
|
|
buf := make([]byte, 32*1024) // 32KB read buffer
|
|
var total int64
|
|
|
|
for {
|
|
nr, err := r.Read(buf)
|
|
if nr > 0 {
|
|
nw, werr := w.Write(buf[:nr])
|
|
total += int64(nw)
|
|
if werr != nil {
|
|
return total, werr
|
|
}
|
|
}
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
}
|
|
|
|
return total, nil
|
|
}
|
|
|
|
// Size returns the current size
|
|
func (w *ProxyBlobWriter) Size() int64 {
|
|
return w.size
|
|
}
|
|
|
|
// Commit finalizes the upload by completing multipart upload and moving to final location
|
|
func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) {
|
|
if w.closed {
|
|
return distribution.Descriptor{}, fmt.Errorf("writer closed")
|
|
}
|
|
w.closed = true
|
|
|
|
// Remove from global uploads map
|
|
globalUploadsMu.Lock()
|
|
delete(globalUploads, w.id)
|
|
globalUploadsMu.Unlock()
|
|
|
|
// Flush any remaining buffered data
|
|
if w.buffer.Len() > 0 {
|
|
slog.Debug("Flushing final buffer", "component", "proxy_blob_store/Commit", "bytes", w.buffer.Len())
|
|
if err := w.flushPart(); err != nil {
|
|
// Try to abort multipart on error
|
|
if err := w.store.abortMultipartUpload(ctx, w.uploadID); err != nil {
|
|
slog.Warn("Failed to abort multipart upload", "component", "proxy_blob_store/Cancel", "error", err)
|
|
// Continue anyway - we want to mark upload as cancelled
|
|
}
|
|
return distribution.Descriptor{}, fmt.Errorf("failed to flush final part: %w", err)
|
|
}
|
|
}
|
|
|
|
// Complete multipart upload - XRPC complete action handles move internally
|
|
// Send the real digest (not tempDigest) so hold can move temp → final location
|
|
slog.Info("Completing multipart upload", "component", "proxy_blob_store/Commit", "upload_id", w.uploadID, "parts", len(w.parts), "digest", desc.Digest)
|
|
if err := w.store.completeMultipartUpload(ctx, desc.Digest.String(), w.uploadID, w.parts); err != nil {
|
|
return distribution.Descriptor{}, fmt.Errorf("failed to complete multipart upload: %w", err)
|
|
}
|
|
|
|
slog.Info("Upload completed successfully", "component", "proxy_blob_store/Commit", "digest", desc.Digest, "size", w.size, "parts", len(w.parts))
|
|
|
|
return distribution.Descriptor{
|
|
Digest: desc.Digest,
|
|
Size: w.size,
|
|
MediaType: desc.MediaType,
|
|
}, nil
|
|
}
|
|
|
|
// Cancel cancels the upload by aborting the multipart upload
|
|
func (w *ProxyBlobWriter) Cancel(ctx context.Context) error {
|
|
w.closed = true
|
|
|
|
slog.Debug("Cancelling upload", "component", "proxy_blob_store/Cancel", "id", w.id)
|
|
|
|
// Remove from global uploads map
|
|
globalUploadsMu.Lock()
|
|
delete(globalUploads, w.id)
|
|
globalUploadsMu.Unlock()
|
|
|
|
// Abort multipart upload
|
|
if err := w.store.abortMultipartUpload(ctx, w.uploadID); err != nil {
|
|
slog.Warn("Failed to abort multipart upload", "component", "proxy_blob_store/Cancel", "error", err)
|
|
// Continue anyway - we want to mark upload as cancelled
|
|
}
|
|
|
|
slog.Debug("Upload cancelled", "component", "proxy_blob_store/Cancel", "id", w.id)
|
|
return nil
|
|
}
|
|
|
|
// Close closes the writer
|
|
// Parts are flushed on demand, so this is a no-op
|
|
func (w *ProxyBlobWriter) Close() error {
|
|
// Don't set w.closed = true - allow resuming for next PATCH
|
|
return nil
|
|
}
|
|
|
|
// readSeekCloser wraps an io.ReadCloser to implement ReadSeekCloser
|
|
type readSeekCloser struct {
|
|
io.ReadCloser
|
|
}
|
|
|
|
func (r *readSeekCloser) Seek(offset int64, whence int) (int64, error) {
|
|
// Not implemented - would need buffering or re-downloading
|
|
return 0, fmt.Errorf("seek not supported")
|
|
}
|