···77### Legacy HTTP Endpoints (cmd/hold/main.go)
8899```go
1010+// Unified presigned URL endpoint (handles upload AND download)
1111+mux.HandleFunc("/presigned-url", service.HandlePresignedURL)
1212+1313+// Internal move operation (used by multipart complete)
1414+mux.HandleFunc("/move", service.HandleMove)
1515+1016// Multipart upload endpoints
1117mux.HandleFunc("/start-multipart", service.HandleStartMultipart)
1218mux.HandleFunc("/part-presigned-url", service.HandleGetPartURL)
···4551- Currently not used by XRPC handlers
46524753**pkg/hold/handlers.go:**
5454+- `HandlePresignedURL()` - Unified endpoint for GET/HEAD/PUT presigned URLs
5555+- `HandleMove()` - Moves blob from temp to final location (internal operation)
4856- `HandleStartMultipart()` - Starts upload, returns uploadID
4957- `HandleGetPartURL()` - Returns presigned URL for part
5050-- `HandleCompleteMultipart()` - Finalizes upload, assembles parts
5858+- `HandleCompleteMultipart()` - Finalizes upload, assembles parts (calls Move internally)
5159- `HandleAbortMultipart()` - Cancels upload
5260- `HandleMultipartPartUpload()` - Buffered part upload fallback
53616262+## Legacy Endpoint Mapping
6363+6464+### `/presigned-url` → Multiple XRPC Operations
6565+6666+The legacy `/presigned-url` endpoint is a **unified endpoint** that handles both upload and download operations based on the `operation` field in the JSON body:
6767+6868+**Legacy format:**
6969+```
7070+POST /presigned-url
7171+Content-Type: application/json
7272+7373+{
7474+ "operation": "GET", // or "HEAD" or "PUT"
7575+ "did": "did:plc:alice123",
7676+ "digest": "sha256:abc123...",
7777+ "size": 1234567890 // Only for PUT operations
7878+}
7979+8080+Response:
8181+{
8282+ "url": "https://s3.amazonaws.com/...",
8383+ "expires_at": "2025-10-16T..."
8484+}
8585+```
8686+8787+**XRPC mapping:**
8888+- `operation: "GET"` → `GET /xrpc/com.atproto.sync.getBlob?did=...&cid=sha256:abc...`
8989+- `operation: "HEAD"` → `HEAD /xrpc/com.atproto.sync.getBlob?did=...&cid=sha256:abc...`
9090+- `operation: "PUT"` → `com.atproto.repo.uploadBlob` (single upload via presigned URL)
9191+9292+**Note:** For GET/HEAD operations, AppView passes OCI digest directly as `cid` parameter. Hold detects `sha256:` prefix and uses digest directly (no CID conversion needed).
9393+9494+### `/move` → Internal to Multipart Complete
9595+9696+The legacy `/move` endpoint moves a blob from temporary location to final digest-based location:
9797+9898+**Legacy format:**
9999+```
100100+POST /move?from=uploads/temp-123&to=sha256:abc123...&did=did:plc:alice123
101101+102102+Response: 200 OK
103103+```
104104+105105+**Purpose:** Server-side S3 copy after multipart assembly. Used in this flow:
106106+107107+1. Multipart parts uploaded → `uploads/temp-{uploadID}/part-1`, `part-2`, etc.
108108+2. Complete multipart → S3 assembles parts at `uploads/temp-{uploadID}`
109109+3. **Move operation** → S3 copy from `uploads/temp-{uploadID}` → `blobs/sha256/ab/abc123...`
110110+111111+**XRPC mapping:**
112112+- **Not a separate endpoint** - becomes internal operation in `uploadBlob?action=complete`
113113+- The `complete` action automatically handles the move after multipart assembly
114114+- AppView doesn't need to call move explicitly in XRPC flow
115115+54116## New Unified Design
5511756118### Single Endpoint: `com.atproto.repo.uploadBlob`
···59121- `application/octet-stream` → Standard blob upload (profile images, small media)
60122- `application/json` → Multipart operations (large OCI layers)
61123124124+### Complementary Endpoint: `com.atproto.sync.getBlob`
125125+126126+For blob downloads (maps from legacy `/presigned-url` with operation=GET/HEAD):
127127+128128+**Standard ATProto blobs (CID):**
129129+```
130130+GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid=bafyreib...
131131+132132+Response: 307 Temporary Redirect
133133+Location: https://s3.amazonaws.com/bucket/...?presigned-params
134134+```
135135+136136+**OCI container layers (digest):**
137137+```
138138+GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid=sha256:abc123...
139139+140140+Response: 307 Temporary Redirect
141141+Location: https://s3.amazonaws.com/bucket/...?presigned-params
142142+```
143143+144144+**Implementation - Flexible CID parameter:**
145145+```go
146146+func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
147147+ cidOrDigest := r.URL.Query().Get("cid")
148148+149149+ var digest string
150150+ if strings.HasPrefix(cidOrDigest, "sha256:") {
151151+ // OCI digest - use directly (no conversion needed)
152152+ digest = cidOrDigest
153153+ } else {
154154+ // Standard CID - convert to digest
155155+ c, _ := cid.Decode(cidOrDigest)
156156+ digest = cidToDigest(c) // bafyreib... → sha256:abc...
157157+ }
158158+159159+ // Generate presigned URL for S3
160160+ url := h.blobStore.GetPresignedDownloadURL(digest)
161161+ http.Redirect(w, r, url, http.StatusTemporaryRedirect)
162162+}
163163+```
164164+165165+**Key insight:** The `cid` parameter accepts both formats. Hold service checks prefix and handles accordingly. This keeps the endpoint spec-compliant (GET with query params) while supporting OCI digests natively.
166166+62167### API Specification
6316864169#### Standard Single Upload (ATProto Spec Compliant)
···201306- Retrieve session: `multipartMgr.GetSession(uploadID)`
202307- For S3Native: Record parts via `session.RecordS3Part()`
203308- Call `service.CompleteMultipartUploadWithManager(ctx, session, multipartMgr)`
309309+ - This internally calls S3 CompleteMultipartUpload to assemble parts
310310+ - Then performs server-side S3 copy from temp location to final digest location
311311+ - Equivalent to legacy `/move` endpoint operation
204312- Convert digest to CID for response
205313206314#### Multipart Abort (ATCR Extension)
···547655548656Create new XRPC client or update ProxyBlobStore to use unified endpoint:
549657658658+**Download (GET/HEAD):**
550659```go
551551-// In ProxyBlobStore or new XRPCBlobStore
660660+func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error {
661661+ // Pass digest directly as cid parameter (no conversion)
662662+ url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
663663+ p.storageEndpoint, p.holdDID, dgst.String()) // cid=sha256:abc...
664664+665665+ http.Redirect(w, r, url, http.StatusTemporaryRedirect)
666666+ return nil
667667+}
668668+```
552669670670+**Multipart Upload:**
671671+```go
553672func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string) (string, error) {
554673 reqBody := map[string]any{
555674 "action": "start",
···612731**cmd/hold/main.go - Remove:**
613732```go
614733// DELETE these lines
734734+mux.HandleFunc("/presigned-url", service.HandlePresignedURL)
735735+mux.HandleFunc("/move", service.HandleMove)
615736mux.HandleFunc("/start-multipart", service.HandleStartMultipart)
616737mux.HandleFunc("/part-presigned-url", service.HandleGetPartURL)
617738mux.HandleFunc("/complete-multipart", service.HandleCompleteMultipart)
···619740mux.HandleFunc("/multipart-parts/", ...)
620741```
621742622622-**pkg/hold/handlers.go - Mark as deprecated:**
743743+**pkg/hold/handlers.go - Remove HTTP handler wrappers:**
623744```go
624624-// Keep methods for now (used by service internals)
625625-// But remove HTTP handler wrappers
745745+// DELETE these functions:
746746+// - HandlePresignedURL() - replaced by uploadBlob + getBlob XRPC endpoints
747747+// - HandleMove() - now internal operation in CompleteMultipartUploadWithManager()
748748+// - HandleStartMultipart() - replaced by uploadBlob?action=start
749749+// - HandleGetPartURL() - replaced by uploadBlob?action=part
750750+// - HandleCompleteMultipart() - replaced by uploadBlob?action=complete
751751+// - HandleAbortMultipart() - replaced by uploadBlob?action=abort
752752+// - HandleMultipartPartUpload() - replaced by uploadBlob PUT with headers
753753+754754+// KEEP internal service methods:
755755+// - s.getPresignedURL() - still used by blobstore_adapter
756756+// - s.driver.Move() - still used for temp→final move
757757+// - s.StartMultipartUploadWithManager() - core multipart logic
758758+// - s.GetPartUploadURL() - presigned URL generation
759759+// - s.CompleteMultipartUploadWithManager() - includes move operation
760760+// - s.AbortMultipartUploadWithManager() - cleanup logic
626761```
627762628763## Key Design Decisions
6297646307651. **Content-Type discrimination**: Natural way to distinguish single vs multipart uploads
631631-2. **JSON bodies for multipart**: Follows XRPC conventions (like putRecord, deleteRecord)
766766+2. **JSON bodies for all parameters**: Follows XRPC conventions (like putRecord, deleteRecord)
767767+ - **No query parameters** - all operation details in request body
768768+ - Makes requests more inspectable and debuggable
769769+ - Easier to extend with new fields
6327703. **Preserve standard uploadBlob**: Raw bytes still work for profile images, small media
6337714. **Reuse existing code**: HoldService multipart logic unchanged, just new HTTP layer
6347725. **Backward compatibility**: Both endpoints active during transition
6357736. **Action-based routing**: Clear, extensible JSON structure
774774+7. **Move is internal**: `/move` endpoint logic absorbed into multipart complete operation
775775+ - No separate XRPC endpoint needed
776776+ - Simplifies AppView client code
777777+8. **Unified presigned URL handling**: Single `uploadBlob`/`getBlob` pair replaces operation-based routing
778778+9. **Flexible CID parameter**: `getBlob` accepts both standard CIDs and OCI digests via prefix detection
779779+ - Keeps endpoint spec-compliant (GET with query params)
780780+ - No conversion overhead on AppView side
781781+ - Hold does simple prefix check: `sha256:` → use directly, else → convert CID
636782637783## Benefits
638784
+156-6
pkg/hold/blobstore_adapter.go
···11package hold
2233import (
44+ "bytes"
45 "context"
66+ "crypto/sha256"
77+ "fmt"
88+ "io"
59610 "atcr.io/pkg/hold/pds"
1111+ "github.com/ipfs/go-cid"
1212+ "github.com/multiformats/go-multihash"
713)
814915// HoldServiceBlobStore adapts the hold service to implement the pds.BlobStore interface
···2127}
22282329// GetPresignedDownloadURL returns a presigned URL for downloading a blob
2424-func (b *HoldServiceBlobStore) GetPresignedDownloadURL(digest string) (string, error) {
2525- // Use the hold service's existing presigned URL logic
3030+func (b *HoldServiceBlobStore) GetPresignedDownloadURL(digest, did string) (string, error) {
3131+ // Use provided DID if given, otherwise fall back to hold's DID
3232+ // ATProto blobs require DID for per-user storage
3333+ // OCI blobs (sha256:...) use content-addressed storage
3434+ if did == "" {
3535+ did = b.holdDID
3636+ }
3737+2638 ctx := context.Background()
2727- url, err := b.service.GetPresignedURL(ctx, OperationGet, digest, b.holdDID)
3939+ url, err := b.service.GetPresignedURL(ctx, OperationGet, digest, did)
2840 if err != nil {
2941 return "", err
3042 }
···3244}
33453446// GetPresignedUploadURL returns a presigned URL for uploading a blob
3535-func (b *HoldServiceBlobStore) GetPresignedUploadURL(digest string) (string, error) {
3636- // Use the hold service's existing presigned URL logic
4747+func (b *HoldServiceBlobStore) GetPresignedUploadURL(digest, did string) (string, error) {
4848+ // Use provided DID if given, otherwise fall back to hold's DID
4949+ // ATProto blobs require DID for per-user storage
5050+ // OCI blobs (sha256:...) use content-addressed storage
5151+ if did == "" {
5252+ did = b.holdDID
5353+ }
5454+3755 ctx := context.Background()
3838- url, err := b.service.GetPresignedURL(ctx, OperationPut, digest, b.holdDID)
5656+ url, err := b.service.GetPresignedURL(ctx, OperationPut, digest, did)
3957 if err != nil {
4058 return "", err
4159 }
4260 return url, nil
4361}
6262+6363+// StartMultipartUpload initiates a multipart upload
6464+func (b *HoldServiceBlobStore) StartMultipartUpload(ctx context.Context, digest string) (string, string, error) {
6565+ uploadID, mode, err := b.service.StartMultipartUploadWithManager(ctx, digest, b.service.MultipartMgr)
6666+ if err != nil {
6767+ return "", "", err
6868+ }
6969+7070+ // Convert mode to string for XRPC response
7171+ var modeStr string
7272+ switch mode {
7373+ case S3Native:
7474+ modeStr = "s3native"
7575+ case Buffered:
7676+ modeStr = "buffered"
7777+ default:
7878+ modeStr = "unknown"
7979+ }
8080+8181+ return uploadID, modeStr, nil
8282+}
8383+8484+// GetPartUploadURL returns a presigned URL for uploading a specific part
8585+func (b *HoldServiceBlobStore) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (string, error) {
8686+ session, err := b.service.MultipartMgr.GetSession(uploadID)
8787+ if err != nil {
8888+ return "", err
8989+ }
9090+9191+ return b.service.GetPartUploadURL(ctx, session, partNumber, did)
9292+}
9393+9494+// CompleteMultipartUpload finalizes a multipart upload
9595+func (b *HoldServiceBlobStore) CompleteMultipartUpload(ctx context.Context, uploadID string, parts []pds.PartInfo) error {
9696+ session, err := b.service.MultipartMgr.GetSession(uploadID)
9797+ if err != nil {
9898+ return err
9999+ }
100100+101101+ // For S3Native mode, record parts from XRPC request (they have ETags from S3)
102102+ if session.Mode == S3Native {
103103+ for _, p := range parts {
104104+ session.RecordS3Part(p.PartNumber, p.ETag, 0)
105105+ }
106106+ }
107107+108108+ return b.service.CompleteMultipartUploadWithManager(ctx, session, b.service.MultipartMgr)
109109+}
110110+111111+// AbortMultipartUpload cancels a multipart upload
112112+func (b *HoldServiceBlobStore) AbortMultipartUpload(ctx context.Context, uploadID string) error {
113113+ session, err := b.service.MultipartMgr.GetSession(uploadID)
114114+ if err != nil {
115115+ return err
116116+ }
117117+118118+ return b.service.AbortMultipartUploadWithManager(ctx, session, b.service.MultipartMgr)
119119+}
120120+121121+// HandleBufferedPartUpload handles uploading a part in buffered mode
122122+func (b *HoldServiceBlobStore) HandleBufferedPartUpload(ctx context.Context, uploadID string, partNumber int, data []byte) (string, error) {
123123+ session, err := b.service.MultipartMgr.GetSession(uploadID)
124124+ if err != nil {
125125+ return "", err
126126+ }
127127+128128+ if session.Mode != Buffered {
129129+ return "", fmt.Errorf("session is not in buffered mode")
130130+ }
131131+132132+ etag := session.StorePart(partNumber, data)
133133+ return etag, nil
134134+}
135135+136136+// UploadBlob receives raw blob bytes, computes CID, and stores via distribution driver
137137+// This is used for standard ATProto blob uploads (profile pics, small media)
138138+func (b *HoldServiceBlobStore) UploadBlob(ctx context.Context, did string, data io.Reader) (cid.Cid, int64, error) {
139139+ // Use provided DID if given, otherwise fall back to hold's DID
140140+ if did == "" {
141141+ did = b.holdDID
142142+ }
143143+144144+ // Read all data into memory to compute CID
145145+ // For large files, this should use multipart upload instead
146146+ blobData, err := io.ReadAll(data)
147147+ if err != nil {
148148+ return cid.Undef, 0, fmt.Errorf("failed to read blob data: %w", err)
149149+ }
150150+151151+ size := int64(len(blobData))
152152+153153+ // Compute SHA-256 hash
154154+ hash := sha256.Sum256(blobData)
155155+156156+ // Create CIDv1 with SHA-256 multihash
157157+ mh, err := multihash.EncodeName(hash[:], "sha2-256")
158158+ if err != nil {
159159+ return cid.Undef, 0, fmt.Errorf("failed to encode multihash: %w", err)
160160+ }
161161+162162+ // Create CIDv1 with raw codec (0x55)
163163+ // ATProto uses CIDv1 with raw codec for blobs
164164+ blobCID := cid.NewCidV1(0x55, mh)
165165+166166+ // Store blob via distribution driver at ATProto path
167167+ // Path: /repos/{did}/blobs/{cid}/data
168168+ path := atprotoBlobPath(did, blobCID.String())
169169+170170+ // Write blob to storage using distribution driver
171171+ writer, err := b.service.driver.Writer(ctx, path, false)
172172+ if err != nil {
173173+ return cid.Undef, 0, fmt.Errorf("failed to create writer: %w", err)
174174+ }
175175+176176+ // Write data
177177+ n, err := io.Copy(writer, bytes.NewReader(blobData))
178178+ if err != nil {
179179+ writer.Cancel(ctx)
180180+ return cid.Undef, 0, fmt.Errorf("failed to write blob: %w", err)
181181+ }
182182+183183+ // Commit the write
184184+ if err := writer.Commit(ctx); err != nil {
185185+ return cid.Undef, 0, fmt.Errorf("failed to commit blob: %w", err)
186186+ }
187187+188188+ if n != size {
189189+ return cid.Undef, 0, fmt.Errorf("size mismatch: wrote %d bytes, expected %d", n, size)
190190+ }
191191+192192+ return blobCID, size, nil
193193+}
+235-17
pkg/hold/pds/xrpc.go
···2233import (
44 "bytes"
55+ "context"
56 "encoding/json"
67 "fmt"
88+ "io"
79 "net/http"
810 "strconv"
911 "strings"
···3032// BlobStore interface wraps the existing hold service storage operations
3133type BlobStore interface {
3234 // GetPresignedDownloadURL returns a presigned URL for downloading a blob
3333- GetPresignedDownloadURL(digest string) (string, error)
3535+ // For ATProto blobs (CID), did is required for per-DID storage
3636+ // For OCI blobs (sha256:...), did may be empty
3737+ GetPresignedDownloadURL(digest, did string) (string, error)
3438 // GetPresignedUploadURL returns a presigned URL for uploading a blob
3535- GetPresignedUploadURL(digest string) (string, error)
3939+ // For ATProto blobs (CID), did is required for per-DID storage
4040+ // For OCI blobs (sha256:...), did may be empty
4141+ GetPresignedUploadURL(digest, did string) (string, error)
4242+4343+ // UploadBlob receives raw blob bytes, computes CID, and stores via distribution driver
4444+ // Used for standard ATProto blob uploads (profile pics, small media)
4545+ // Returns CID and size of stored blob
4646+ UploadBlob(ctx context.Context, did string, data io.Reader) (cid cid.Cid, size int64, err error)
4747+4848+ // Multipart upload operations (used for OCI container layers only)
4949+ // StartMultipartUpload initiates a multipart upload, returns uploadID and mode
5050+ StartMultipartUpload(ctx context.Context, digest string) (uploadID string, mode string, err error)
5151+ // GetPartUploadURL returns a presigned URL for uploading a specific part
5252+ GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (url string, err error)
5353+ // CompleteMultipartUpload finalizes a multipart upload
5454+ CompleteMultipartUpload(ctx context.Context, uploadID string, parts []PartInfo) error
5555+ // AbortMultipartUpload cancels a multipart upload
5656+ AbortMultipartUpload(ctx context.Context, uploadID string) error
5757+ // HandleBufferedPartUpload handles uploading a part in buffered mode
5858+ HandleBufferedPartUpload(ctx context.Context, uploadID string, partNumber int, data []byte) (etag string, err error)
5959+}
6060+6161+// PartInfo represents a completed part in a multipart upload
6262+type PartInfo struct {
6363+ PartNumber int `json:"partNumber"`
6464+ ETag string `json:"etag"`
3665}
37663867// NewXRPCHandler creates a new XRPC handler
···669698 }()
670699}
671700672672-// HandleUploadBlob wraps existing presigned upload URL logic
701701+// HandleUploadBlob handles blob uploads with support for multipart operations
702702+// Supports three modes:
703703+// 1. Buffered part upload: PUT with X-Upload-Id and X-Part-Number headers
704704+// 2. Multipart operations: POST with JSON body containing action field
705705+// 3. Direct blob upload: POST with raw bytes (ATProto-compliant)
673706func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
707707+ contentType := r.Header.Get("Content-Type")
708708+709709+ // Mode 1: Buffered part upload (PUT with headers)
710710+ if r.Method == http.MethodPut {
711711+ uploadID := r.Header.Get("X-Upload-Id")
712712+ partNumberStr := r.Header.Get("X-Part-Number")
713713+714714+ if uploadID != "" && partNumberStr != "" {
715715+ h.handleBufferedPartUpload(w, r, uploadID, partNumberStr)
716716+ return
717717+ }
718718+ http.Error(w, "PUT requires X-Upload-Id and X-Part-Number headers", http.StatusBadRequest)
719719+ return
720720+ }
721721+722722+ // Ensure POST method for remaining modes
674723 if r.Method != http.MethodPost {
675724 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
676725 return
677726 }
678727728728+ // Mode 2: Multipart operations (JSON body with action field)
729729+ if strings.Contains(contentType, "application/json") {
730730+ h.handleMultipartOperation(w, r)
731731+ return
732732+ }
733733+734734+ // Mode 3: Direct blob upload (ATProto-compliant)
735735+ // Receives raw bytes, computes CID, stores via distribution driver
679736 // TODO: Authentication check
680737681681- // Read digest from query or calculate from body
682682- digest := r.URL.Query().Get("digest")
683683- if digest == "" {
684684- http.Error(w, "digest required", http.StatusBadRequest)
738738+ // Extract DID for ATProto blob storage (per-DID paths)
739739+ did := r.URL.Query().Get("did")
740740+ if did == "" {
741741+ // TODO: Extract from auth context when authentication is implemented
742742+ // For now, use hold's DID as fallback
743743+ did = h.pds.DID()
744744+ }
745745+746746+ // Upload blob directly - blobStore will compute CID and store
747747+ blobCID, size, err := h.blobStore.UploadBlob(r.Context(), did, r.Body)
748748+ if err != nil {
749749+ http.Error(w, fmt.Sprintf("failed to upload blob: %v", err), http.StatusInternalServerError)
685750 return
686751 }
687752688688- // Get presigned upload URL from existing blob store
689689- uploadURL, err := h.blobStore.GetPresignedUploadURL(digest)
753753+ // Return ATProto-compliant blob response
754754+ response := map[string]any{
755755+ "blob": map[string]any{
756756+ "$type": "blob",
757757+ "ref": map[string]any{
758758+ "$link": blobCID.String(),
759759+ },
760760+ "mimeType": "application/octet-stream",
761761+ "size": size,
762762+ },
763763+ }
764764+765765+ w.Header().Set("Content-Type", "application/json")
766766+ json.NewEncoder(w).Encode(response)
767767+}
768768+769769+// handleBufferedPartUpload handles uploading a part in buffered mode
770770+func (h *XRPCHandler) handleBufferedPartUpload(w http.ResponseWriter, r *http.Request, uploadID, partNumberStr string) {
771771+ ctx := r.Context()
772772+773773+ // Parse part number
774774+ partNumber, err := strconv.Atoi(partNumberStr)
690775 if err != nil {
691691- http.Error(w, fmt.Sprintf("failed to get upload URL: %v", err), http.StatusInternalServerError)
776776+ http.Error(w, fmt.Sprintf("invalid part number: %v", err), http.StatusBadRequest)
777777+ return
778778+ }
779779+780780+ // Read part data from body
781781+ data, err := io.ReadAll(r.Body)
782782+ if err != nil {
783783+ http.Error(w, fmt.Sprintf("failed to read part data: %v", err), http.StatusInternalServerError)
784784+ return
785785+ }
786786+787787+ // Store part via blob store
788788+ etag, err := h.blobStore.HandleBufferedPartUpload(ctx, uploadID, partNumber, data)
789789+ if err != nil {
790790+ http.Error(w, fmt.Sprintf("failed to upload part: %v", err), http.StatusInternalServerError)
791791+ return
792792+ }
793793+794794+ // Return ETag in response
795795+ w.Header().Set("Content-Type", "application/json")
796796+ json.NewEncoder(w).Encode(map[string]any{
797797+ "etag": etag,
798798+ })
799799+}
800800+801801+// handleMultipartOperation handles multipart upload operations via JSON request
802802+func (h *XRPCHandler) handleMultipartOperation(w http.ResponseWriter, r *http.Request) {
803803+ ctx := r.Context()
804804+805805+ // Parse JSON body
806806+ var req struct {
807807+ Action string `json:"action"`
808808+ Digest string `json:"digest,omitempty"`
809809+ UploadID string `json:"uploadId,omitempty"`
810810+ PartNumber int `json:"partNumber,omitempty"`
811811+ Parts []PartInfo `json:"parts,omitempty"`
812812+ }
813813+814814+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
815815+ http.Error(w, fmt.Sprintf("invalid JSON body: %v", err), http.StatusBadRequest)
692816 return
693817 }
694818695695- // Return 302 redirect to presigned URL
696696- http.Redirect(w, r, uploadURL, http.StatusFound)
819819+ // Route based on action
820820+ switch req.Action {
821821+ case "start":
822822+ // Start multipart upload
823823+ if req.Digest == "" {
824824+ http.Error(w, "digest required for start action", http.StatusBadRequest)
825825+ return
826826+ }
827827+828828+ uploadID, mode, err := h.blobStore.StartMultipartUpload(ctx, req.Digest)
829829+ if err != nil {
830830+ http.Error(w, fmt.Sprintf("failed to start multipart upload: %v", err), http.StatusInternalServerError)
831831+ return
832832+ }
833833+834834+ w.Header().Set("Content-Type", "application/json")
835835+ json.NewEncoder(w).Encode(map[string]any{
836836+ "uploadId": uploadID,
837837+ "mode": mode,
838838+ })
839839+840840+ case "part":
841841+ // Get part upload URL
842842+ if req.UploadID == "" || req.PartNumber == 0 {
843843+ http.Error(w, "uploadId and partNumber required for part action", http.StatusBadRequest)
844844+ return
845845+ }
846846+847847+ // Extract DID from query or header (for authorization)
848848+ did := r.URL.Query().Get("did")
849849+ if did == "" {
850850+ did = r.Header.Get("X-ATCR-DID")
851851+ }
852852+853853+ url, err := h.blobStore.GetPartUploadURL(ctx, req.UploadID, req.PartNumber, did)
854854+ if err != nil {
855855+ http.Error(w, fmt.Sprintf("failed to get part URL: %v", err), http.StatusInternalServerError)
856856+ return
857857+ }
858858+859859+ w.Header().Set("Content-Type", "application/json")
860860+ json.NewEncoder(w).Encode(map[string]any{
861861+ "url": url,
862862+ })
863863+864864+ case "complete":
865865+ // Complete multipart upload
866866+ if req.UploadID == "" || len(req.Parts) == 0 {
867867+ http.Error(w, "uploadId and parts required for complete action", http.StatusBadRequest)
868868+ return
869869+ }
870870+871871+ if err := h.blobStore.CompleteMultipartUpload(ctx, req.UploadID, req.Parts); err != nil {
872872+ http.Error(w, fmt.Sprintf("failed to complete multipart upload: %v", err), http.StatusInternalServerError)
873873+ return
874874+ }
875875+876876+ w.Header().Set("Content-Type", "application/json")
877877+ json.NewEncoder(w).Encode(map[string]any{
878878+ "status": "completed",
879879+ })
880880+881881+ case "abort":
882882+ // Abort multipart upload
883883+ if req.UploadID == "" {
884884+ http.Error(w, "uploadId required for abort action", http.StatusBadRequest)
885885+ return
886886+ }
887887+888888+ if err := h.blobStore.AbortMultipartUpload(ctx, req.UploadID); err != nil {
889889+ http.Error(w, fmt.Sprintf("failed to abort multipart upload: %v", err), http.StatusInternalServerError)
890890+ return
891891+ }
892892+893893+ w.Header().Set("Content-Type", "application/json")
894894+ json.NewEncoder(w).Encode(map[string]any{
895895+ "status": "aborted",
896896+ })
897897+898898+ default:
899899+ http.Error(w, fmt.Sprintf("unknown action: %s", req.Action), http.StatusBadRequest)
900900+ }
697901}
698902699903// HandleGetBlob wraps existing presigned download URL logic
904904+// Supports both ATProto CIDs and OCI sha256 digests
700905func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
701701- if r.Method != http.MethodGet {
906906+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
702907 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
703908 return
704909 }
705910706911 did := r.URL.Query().Get("did")
707707- digest := r.URL.Query().Get("cid")
912912+ cidOrDigest := r.URL.Query().Get("cid")
708913709709- if did == "" || digest == "" {
914914+ if did == "" || cidOrDigest == "" {
710915 http.Error(w, "missing required parameters", http.StatusBadRequest)
711916 return
712917 }
···716921 return
717922 }
718923924924+ // Flexible digest parsing: accept both CID and sha256 digest formats
925925+ var digest string
926926+ if strings.HasPrefix(cidOrDigest, "sha256:") {
927927+ // OCI digest format - use directly
928928+ digest = cidOrDigest
929929+ } else {
930930+ // Standard ATProto CID - for ATCR OCI use case, we expect sha256 digests
931931+ // If a real CID is provided, we could convert it here, but for now
932932+ // we'll just pass it through and let the blob store handle it
933933+ digest = cidOrDigest
934934+ }
935935+719936 // Get presigned download URL from existing blob store
720720- downloadURL, err := h.blobStore.GetPresignedDownloadURL(digest)
937937+ // Pass DID for ATProto blob storage (per-DID paths)
938938+ downloadURL, err := h.blobStore.GetPresignedDownloadURL(digest, did)
721939 if err != nil {
722940 http.Error(w, fmt.Sprintf("failed to get download URL: %v", err), http.StatusInternalServerError)
723941 return
724942 }
725943726944 // Return 302 redirect to presigned URL
727727- http.Redirect(w, r, downloadURL, http.StatusFound)
945945+ http.Redirect(w, r, downloadURL, http.StatusTemporaryRedirect)
728946}
729947730948// HandleListRepos lists all repositories in this PDS
+427
pkg/hold/pds/xrpc_multipart_test.go
···11+package pds
22+33+import (
44+ "bytes"
55+ "net/http"
66+ "net/http/httptest"
77+ "testing"
88+)
99+1010+// ATCR-Specific Tests: Non-standard multipart upload extensions
1111+//
1212+// This file contains tests for ATCR's custom multipart upload extensions
1313+// to the ATProto blob endpoints. These are not part of the official ATProto spec.
1414+//
1515+// Standard ATProto blob tests are in xrpc_test.go
1616+1717+// Tests for HandleUploadBlob - Multipart Start
1818+1919+// TestHandleUploadBlob_MultipartStart tests multipart upload start operation
2020+// Non-standard ATCR extension for large blob uploads
2121+func TestHandleUploadBlob_MultipartStart(t *testing.T) {
2222+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
2323+2424+ digest := "sha256:largefile123"
2525+ body := map[string]string{
2626+ "action": "start",
2727+ "digest": digest,
2828+ }
2929+3030+ req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
3131+ w := httptest.NewRecorder()
3232+3333+ handler.HandleUploadBlob(w, req)
3434+3535+ if w.Code != http.StatusOK {
3636+ t.Errorf("Expected status 200, got %d", w.Code)
3737+ }
3838+3939+ // Verify response contains uploadId and mode
4040+ result := assertJSONResponse(t, w, http.StatusOK)
4141+4242+ if uploadID, ok := result["uploadId"].(string); !ok || uploadID == "" {
4343+ t.Error("Expected uploadId string in response")
4444+ }
4545+4646+ if mode, ok := result["mode"].(string); !ok || mode == "" {
4747+ t.Error("Expected mode string in response")
4848+ }
4949+5050+ // Verify blob store was called
5151+ if len(blobStore.startCalls) != 1 || blobStore.startCalls[0] != digest {
5252+ t.Errorf("Expected StartMultipartUpload to be called with %s", digest)
5353+ }
5454+}
5555+5656+// TestHandleUploadBlob_MultipartStart_MissingDigest tests missing digest in start operation
5757+func TestHandleUploadBlob_MultipartStart_MissingDigest(t *testing.T) {
5858+ handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
5959+6060+ body := map[string]string{
6161+ "action": "start",
6262+ }
6363+6464+ req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
6565+ w := httptest.NewRecorder()
6666+6767+ handler.HandleUploadBlob(w, req)
6868+6969+ if w.Code != http.StatusBadRequest {
7070+ t.Errorf("Expected status 400, got %d", w.Code)
7171+ }
7272+}
7373+7474+// Tests for HandleUploadBlob - Multipart Part URL
7575+7676+// TestHandleUploadBlob_MultipartPart tests getting presigned URL for a part
7777+// Non-standard ATCR extension for multipart uploads
7878+func TestHandleUploadBlob_MultipartPart(t *testing.T) {
7979+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
8080+8181+ uploadID := "test-upload-123"
8282+ partNumber := 1
8383+ did := "did:plc:testuser"
8484+8585+ body := map[string]any{
8686+ "action": "part",
8787+ "uploadId": uploadID,
8888+ "partNumber": partNumber,
8989+ }
9090+9191+ req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob?did="+did, body)
9292+ w := httptest.NewRecorder()
9393+9494+ handler.HandleUploadBlob(w, req)
9595+9696+ if w.Code != http.StatusOK {
9797+ t.Errorf("Expected status 200, got %d", w.Code)
9898+ }
9999+100100+ // Verify response contains URL
101101+ result := assertJSONResponse(t, w, http.StatusOK)
102102+103103+ if url, ok := result["url"].(string); !ok || url == "" {
104104+ t.Error("Expected url string in response")
105105+ }
106106+107107+ // Verify blob store was called
108108+ if len(blobStore.partURLCalls) != 1 {
109109+ t.Fatalf("Expected GetPartUploadURL to be called once")
110110+ }
111111+ call := blobStore.partURLCalls[0]
112112+ if call.uploadID != uploadID || call.partNumber != partNumber || call.did != did {
113113+ t.Errorf("Expected GetPartUploadURL(%s, %d, %s), got (%s, %d, %s)",
114114+ uploadID, partNumber, did, call.uploadID, call.partNumber, call.did)
115115+ }
116116+}
117117+118118+// TestHandleUploadBlob_MultipartPart_MissingParams tests missing parameters
119119+func TestHandleUploadBlob_MultipartPart_MissingParams(t *testing.T) {
120120+ handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
121121+122122+ tests := []struct {
123123+ name string
124124+ body map[string]any
125125+ }{
126126+ {
127127+ name: "missing uploadId",
128128+ body: map[string]any{
129129+ "action": "part",
130130+ "partNumber": 1,
131131+ },
132132+ },
133133+ {
134134+ name: "missing partNumber",
135135+ body: map[string]any{
136136+ "action": "part",
137137+ "uploadId": "test-123",
138138+ },
139139+ },
140140+ {
141141+ name: "partNumber zero",
142142+ body: map[string]any{
143143+ "action": "part",
144144+ "uploadId": "test-123",
145145+ "partNumber": 0,
146146+ },
147147+ },
148148+ }
149149+150150+ for _, tt := range tests {
151151+ t.Run(tt.name, func(t *testing.T) {
152152+ req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", tt.body)
153153+ w := httptest.NewRecorder()
154154+155155+ handler.HandleUploadBlob(w, req)
156156+157157+ if w.Code != http.StatusBadRequest {
158158+ t.Errorf("Expected status 400, got %d", w.Code)
159159+ }
160160+ })
161161+ }
162162+}
163163+164164+// Tests for HandleUploadBlob - Multipart Complete
165165+166166+// TestHandleUploadBlob_MultipartComplete tests completing a multipart upload
167167+// Non-standard ATCR extension for multipart uploads
168168+func TestHandleUploadBlob_MultipartComplete(t *testing.T) {
169169+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
170170+171171+ uploadID := "test-upload-123"
172172+ parts := []PartInfo{
173173+ {PartNumber: 1, ETag: "etag1"},
174174+ {PartNumber: 2, ETag: "etag2"},
175175+ }
176176+177177+ body := map[string]any{
178178+ "action": "complete",
179179+ "uploadId": uploadID,
180180+ "parts": parts,
181181+ }
182182+183183+ req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
184184+ w := httptest.NewRecorder()
185185+186186+ handler.HandleUploadBlob(w, req)
187187+188188+ if w.Code != http.StatusOK {
189189+ t.Errorf("Expected status 200, got %d", w.Code)
190190+ }
191191+192192+ // Verify response
193193+ result := assertJSONResponse(t, w, http.StatusOK)
194194+195195+ if status, ok := result["status"].(string); !ok || status != "completed" {
196196+ t.Errorf("Expected status='completed', got %v", result["status"])
197197+ }
198198+199199+ // Verify blob store was called
200200+ if len(blobStore.completeCalls) != 1 || blobStore.completeCalls[0] != uploadID {
201201+ t.Errorf("Expected CompleteMultipartUpload to be called with %s", uploadID)
202202+ }
203203+}
204204+205205+// TestHandleUploadBlob_MultipartComplete_MissingParams tests missing parameters
206206+func TestHandleUploadBlob_MultipartComplete_MissingParams(t *testing.T) {
207207+ handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
208208+209209+ tests := []struct {
210210+ name string
211211+ body map[string]any
212212+ }{
213213+ {
214214+ name: "missing uploadId",
215215+ body: map[string]any{
216216+ "action": "complete",
217217+ "parts": []PartInfo{{PartNumber: 1, ETag: "etag1"}},
218218+ },
219219+ },
220220+ {
221221+ name: "missing parts",
222222+ body: map[string]any{
223223+ "action": "complete",
224224+ "uploadId": "test-123",
225225+ },
226226+ },
227227+ {
228228+ name: "empty parts array",
229229+ body: map[string]any{
230230+ "action": "complete",
231231+ "uploadId": "test-123",
232232+ "parts": []PartInfo{},
233233+ },
234234+ },
235235+ }
236236+237237+ for _, tt := range tests {
238238+ t.Run(tt.name, func(t *testing.T) {
239239+ req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", tt.body)
240240+ w := httptest.NewRecorder()
241241+242242+ handler.HandleUploadBlob(w, req)
243243+244244+ if w.Code != http.StatusBadRequest {
245245+ t.Errorf("Expected status 400, got %d", w.Code)
246246+ }
247247+ })
248248+ }
249249+}
250250+251251+// Tests for HandleUploadBlob - Multipart Abort
252252+253253+// TestHandleUploadBlob_MultipartAbort tests aborting a multipart upload
254254+// Non-standard ATCR extension for multipart uploads
255255+func TestHandleUploadBlob_MultipartAbort(t *testing.T) {
256256+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
257257+258258+ uploadID := "test-upload-123"
259259+260260+ body := map[string]string{
261261+ "action": "abort",
262262+ "uploadId": uploadID,
263263+ }
264264+265265+ req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
266266+ w := httptest.NewRecorder()
267267+268268+ handler.HandleUploadBlob(w, req)
269269+270270+ if w.Code != http.StatusOK {
271271+ t.Errorf("Expected status 200, got %d", w.Code)
272272+ }
273273+274274+ // Verify response
275275+ result := assertJSONResponse(t, w, http.StatusOK)
276276+277277+ if status, ok := result["status"].(string); !ok || status != "aborted" {
278278+ t.Errorf("Expected status='aborted', got %v", result["status"])
279279+ }
280280+281281+ // Verify blob store was called
282282+ if len(blobStore.abortCalls) != 1 || blobStore.abortCalls[0] != uploadID {
283283+ t.Errorf("Expected AbortMultipartUpload to be called with %s", uploadID)
284284+ }
285285+}
286286+287287+// TestHandleUploadBlob_MultipartAbort_MissingUploadID tests missing uploadId
288288+func TestHandleUploadBlob_MultipartAbort_MissingUploadID(t *testing.T) {
289289+ handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
290290+291291+ body := map[string]string{
292292+ "action": "abort",
293293+ }
294294+295295+ req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
296296+ w := httptest.NewRecorder()
297297+298298+ handler.HandleUploadBlob(w, req)
299299+300300+ if w.Code != http.StatusBadRequest {
301301+ t.Errorf("Expected status 400, got %d", w.Code)
302302+ }
303303+}
304304+305305+// Tests for HandleUploadBlob - Buffered Part Upload
306306+307307+// TestHandleUploadBlob_BufferedPartUpload tests uploading a part in buffered mode
308308+// Non-standard ATCR extension for multipart uploads without S3 presigned URLs
309309+func TestHandleUploadBlob_BufferedPartUpload(t *testing.T) {
310310+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
311311+312312+ uploadID := "test-upload-123"
313313+ partNumber := "1"
314314+ data := []byte("test data for part 1")
315315+316316+ req := httptest.NewRequest(http.MethodPut, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(data))
317317+ req.Header.Set("X-Upload-Id", uploadID)
318318+ req.Header.Set("X-Part-Number", partNumber)
319319+ w := httptest.NewRecorder()
320320+321321+ handler.HandleUploadBlob(w, req)
322322+323323+ if w.Code != http.StatusOK {
324324+ t.Errorf("Expected status 200, got %d", w.Code)
325325+ }
326326+327327+ // Verify response contains ETag
328328+ result := assertJSONResponse(t, w, http.StatusOK)
329329+330330+ if etag, ok := result["etag"].(string); !ok || etag == "" {
331331+ t.Error("Expected etag string in response")
332332+ }
333333+334334+ // Verify blob store was called
335335+ if len(blobStore.partUploadCalls) != 1 {
336336+ t.Fatalf("Expected HandleBufferedPartUpload to be called once")
337337+ }
338338+ call := blobStore.partUploadCalls[0]
339339+ if call.uploadID != uploadID || call.partNumber != 1 || call.dataSize != len(data) {
340340+ t.Errorf("Expected HandleBufferedPartUpload(%s, 1, %d bytes), got (%s, %d, %d bytes)",
341341+ uploadID, len(data), call.uploadID, call.partNumber, call.dataSize)
342342+ }
343343+}
344344+345345+// TestHandleUploadBlob_BufferedPartUpload_MissingHeaders tests missing required headers
346346+func TestHandleUploadBlob_BufferedPartUpload_MissingHeaders(t *testing.T) {
347347+ handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
348348+349349+ tests := []struct {
350350+ name string
351351+ uploadID string
352352+ partNumber string
353353+ setUploadID bool
354354+ setPartNumber bool
355355+ }{
356356+ {
357357+ name: "missing both headers",
358358+ setUploadID: false,
359359+ setPartNumber: false,
360360+ },
361361+ {
362362+ name: "missing X-Part-Number",
363363+ uploadID: "test-123",
364364+ setUploadID: true,
365365+ setPartNumber: false,
366366+ },
367367+ {
368368+ name: "missing X-Upload-Id",
369369+ partNumber: "1",
370370+ setUploadID: false,
371371+ setPartNumber: true,
372372+ },
373373+ }
374374+375375+ for _, tt := range tests {
376376+ t.Run(tt.name, func(t *testing.T) {
377377+ req := httptest.NewRequest(http.MethodPut, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte("data")))
378378+ if tt.setUploadID {
379379+ req.Header.Set("X-Upload-Id", tt.uploadID)
380380+ }
381381+ if tt.setPartNumber {
382382+ req.Header.Set("X-Part-Number", tt.partNumber)
383383+ }
384384+ w := httptest.NewRecorder()
385385+386386+ handler.HandleUploadBlob(w, req)
387387+388388+ if w.Code != http.StatusBadRequest {
389389+ t.Errorf("Expected status 400, got %d", w.Code)
390390+ }
391391+ })
392392+ }
393393+}
394394+395395+// TestHandleUploadBlob_BufferedPartUpload_InvalidPartNumber tests invalid part number
396396+func TestHandleUploadBlob_BufferedPartUpload_InvalidPartNumber(t *testing.T) {
397397+ handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
398398+399399+ req := httptest.NewRequest(http.MethodPut, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte("data")))
400400+ req.Header.Set("X-Upload-Id", "test-123")
401401+ req.Header.Set("X-Part-Number", "not-a-number")
402402+ w := httptest.NewRecorder()
403403+404404+ handler.HandleUploadBlob(w, req)
405405+406406+ if w.Code != http.StatusBadRequest {
407407+ t.Errorf("Expected status 400 for invalid part number, got %d", w.Code)
408408+ }
409409+}
410410+411411+// TestHandleUploadBlob_UnknownAction tests unknown action value
412412+func TestHandleUploadBlob_UnknownAction(t *testing.T) {
413413+ handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
414414+415415+ body := map[string]string{
416416+ "action": "invalid",
417417+ }
418418+419419+ req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
420420+ w := httptest.NewRecorder()
421421+422422+ handler.HandleUploadBlob(w, req)
423423+424424+ if w.Code != http.StatusBadRequest {
425425+ t.Errorf("Expected status 400 for unknown action, got %d", w.Code)
426426+ }
427427+}
+459
pkg/hold/pds/xrpc_test.go
···44 "bytes"
55 "context"
66 "encoding/json"
77+ "fmt"
78 "io"
89 "net/http"
910 "net/http/httptest"
···1314 "testing"
14151516 "atcr.io/pkg/atproto"
1717+ "github.com/ipfs/go-cid"
1618)
17191820// Test helpers
···13161318 t.Errorf("Expected DID string, got %s", body)
13171319 }
13181320}
13211321+13221322+// Mock BlobStore for testing blob endpoints
13231323+13241324+// mockBlobStore implements BlobStore interface for testing
13251325+type mockBlobStore struct {
13261326+ // Control behavior
13271327+ downloadURLError error
13281328+ uploadURLError error
13291329+ uploadBlobError error
13301330+ startError error
13311331+ partURLError error
13321332+ completeError error
13331333+ abortError error
13341334+ partUploadError error
13351335+13361336+ // Track calls
13371337+ downloadCalls []string // Track digests requested for download
13381338+ uploadCalls []string // Track digests requested for upload
13391339+ uploadBlobCalls []uploadBlobCall // Track direct blob uploads
13401340+ startCalls []string // Track digests for multipart start
13411341+ partURLCalls []partURLCall
13421342+ completeCalls []string
13431343+ abortCalls []string
13441344+ partUploadCalls []partUploadCall
13451345+}
13461346+13471347+type uploadBlobCall struct {
13481348+ did string
13491349+ dataSize int
13501350+}
13511351+13521352+type partURLCall struct {
13531353+ uploadID string
13541354+ partNumber int
13551355+ did string
13561356+}
13571357+13581358+type partUploadCall struct {
13591359+ uploadID string
13601360+ partNumber int
13611361+ dataSize int
13621362+}
13631363+13641364+func newMockBlobStore() *mockBlobStore {
13651365+ return &mockBlobStore{
13661366+ downloadCalls: []string{},
13671367+ uploadCalls: []string{},
13681368+ uploadBlobCalls: []uploadBlobCall{},
13691369+ startCalls: []string{},
13701370+ partURLCalls: []partURLCall{},
13711371+ completeCalls: []string{},
13721372+ abortCalls: []string{},
13731373+ partUploadCalls: []partUploadCall{},
13741374+ }
13751375+}
13761376+13771377+func (m *mockBlobStore) GetPresignedDownloadURL(digest, did string) (string, error) {
13781378+ m.downloadCalls = append(m.downloadCalls, digest)
13791379+ if m.downloadURLError != nil {
13801380+ return "", m.downloadURLError
13811381+ }
13821382+ return "https://s3.example.com/download/" + digest, nil
13831383+}
13841384+13851385+func (m *mockBlobStore) GetPresignedUploadURL(digest, did string) (string, error) {
13861386+ m.uploadCalls = append(m.uploadCalls, digest)
13871387+ if m.uploadURLError != nil {
13881388+ return "", m.uploadURLError
13891389+ }
13901390+ return "https://s3.example.com/upload/" + digest, nil
13911391+}
13921392+13931393+func (m *mockBlobStore) UploadBlob(ctx context.Context, did string, data io.Reader) (cid.Cid, int64, error) {
13941394+ // Read data to get size
13951395+ blobData, err := io.ReadAll(data)
13961396+ if err != nil {
13971397+ return cid.Undef, 0, err
13981398+ }
13991399+14001400+ m.uploadBlobCalls = append(m.uploadBlobCalls, uploadBlobCall{
14011401+ did: did,
14021402+ dataSize: len(blobData),
14031403+ })
14041404+14051405+ if m.uploadBlobError != nil {
14061406+ return cid.Undef, 0, m.uploadBlobError
14071407+ }
14081408+14091409+ // Return a test CID (just use a fixed one for testing)
14101410+ testCID, _ := cid.Decode("bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku")
14111411+ return testCID, int64(len(blobData)), nil
14121412+}
14131413+14141414+func (m *mockBlobStore) StartMultipartUpload(ctx context.Context, digest string) (string, string, error) {
14151415+ m.startCalls = append(m.startCalls, digest)
14161416+ if m.startError != nil {
14171417+ return "", "", m.startError
14181418+ }
14191419+ return "test-upload-id", "s3native", nil
14201420+}
14211421+14221422+func (m *mockBlobStore) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (string, error) {
14231423+ m.partURLCalls = append(m.partURLCalls, partURLCall{uploadID, partNumber, did})
14241424+ if m.partURLError != nil {
14251425+ return "", m.partURLError
14261426+ }
14271427+ return "https://s3.example.com/part/" + uploadID, nil
14281428+}
14291429+14301430+func (m *mockBlobStore) CompleteMultipartUpload(ctx context.Context, uploadID string, parts []PartInfo) error {
14311431+ m.completeCalls = append(m.completeCalls, uploadID)
14321432+ if m.completeError != nil {
14331433+ return m.completeError
14341434+ }
14351435+ return nil
14361436+}
14371437+14381438+func (m *mockBlobStore) AbortMultipartUpload(ctx context.Context, uploadID string) error {
14391439+ m.abortCalls = append(m.abortCalls, uploadID)
14401440+ if m.abortError != nil {
14411441+ return m.abortError
14421442+ }
14431443+ return nil
14441444+}
14451445+14461446+func (m *mockBlobStore) HandleBufferedPartUpload(ctx context.Context, uploadID string, partNumber int, data []byte) (string, error) {
14471447+ m.partUploadCalls = append(m.partUploadCalls, partUploadCall{uploadID, partNumber, len(data)})
14481448+ if m.partUploadError != nil {
14491449+ return "", m.partUploadError
14501450+ }
14511451+ return "test-etag-" + uploadID, nil
14521452+}
14531453+14541454+// setupTestXRPCHandlerWithBlobs creates handler with mock blob store
14551455+func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockBlobStore, context.Context) {
14561456+ t.Helper()
14571457+14581458+ ctx := context.Background()
14591459+ tmpDir := t.TempDir()
14601460+14611461+ dbPath := filepath.Join(tmpDir, "pds.db")
14621462+ keyPath := filepath.Join(tmpDir, "signing-key")
14631463+14641464+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath)
14651465+ if err != nil {
14661466+ t.Fatalf("Failed to create test PDS: %v", err)
14671467+ }
14681468+14691469+ // Bootstrap with a test owner, suppressing stdout to avoid log spam
14701470+ ownerDID := "did:plc:testowner123"
14711471+14721472+ // Redirect stdout to suppress bootstrap logging
14731473+ oldStdout := os.Stdout
14741474+ r, w, _ := os.Pipe()
14751475+ os.Stdout = w
14761476+14771477+ err = pds.Bootstrap(ctx, ownerDID, true, false)
14781478+14791479+ // Restore stdout
14801480+ w.Close()
14811481+ os.Stdout = oldStdout
14821482+ io.ReadAll(r) // Drain the pipe
14831483+14841484+ if err != nil {
14851485+ t.Fatalf("Failed to bootstrap PDS: %v", err)
14861486+ }
14871487+14881488+ // Create mock blob store
14891489+ blobStore := newMockBlobStore()
14901490+14911491+ // Create XRPC handler with mock blob store
14921492+ handler := NewXRPCHandler(pds, "https://hold.example.com", blobStore, nil)
14931493+14941494+ return handler, blobStore, ctx
14951495+}
14961496+14971497+// Tests for HandleUploadBlob
14981498+14991499+// TestHandleUploadBlob tests com.atproto.repo.uploadBlob with direct upload
15001500+// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
15011501+func TestHandleUploadBlob(t *testing.T) {
15021502+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
15031503+15041504+ // Test data - a simple text blob
15051505+ blobData := []byte("Hello, ATProto!")
15061506+15071507+ // Test standard single blob upload (POST with raw bytes)
15081508+ req := httptest.NewRequest(http.MethodPost, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(blobData))
15091509+ req.Header.Set("Content-Type", "application/octet-stream")
15101510+ w := httptest.NewRecorder()
15111511+15121512+ handler.HandleUploadBlob(w, req)
15131513+15141514+ // Should return 200 OK with blob metadata
15151515+ if w.Code != http.StatusOK {
15161516+ t.Errorf("Expected status 200 OK, got %d", w.Code)
15171517+ }
15181518+15191519+ // Verify response contains blob metadata
15201520+ result := assertJSONResponse(t, w, http.StatusOK)
15211521+15221522+ blob, ok := result["blob"].(map[string]any)
15231523+ if !ok {
15241524+ t.Fatal("Expected blob object in response")
15251525+ }
15261526+15271527+ if blobType, ok := blob["$type"].(string); !ok || blobType != "blob" {
15281528+ t.Errorf("Expected $type='blob', got %v", blob["$type"])
15291529+ }
15301530+15311531+ ref, ok := blob["ref"].(map[string]any)
15321532+ if !ok {
15331533+ t.Fatal("Expected ref object in blob")
15341534+ }
15351535+15361536+ if link, ok := ref["$link"].(string); !ok || link == "" {
15371537+ t.Error("Expected $link (CID) in ref")
15381538+ }
15391539+15401540+ if size, ok := blob["size"].(float64); !ok || int(size) != len(blobData) {
15411541+ t.Errorf("Expected size=%d, got %v", len(blobData), blob["size"])
15421542+ }
15431543+15441544+ // Verify blob store was called
15451545+ if len(blobStore.uploadBlobCalls) != 1 {
15461546+ t.Errorf("Expected UploadBlob to be called once, got %d calls", len(blobStore.uploadBlobCalls))
15471547+ }
15481548+15491549+ if blobStore.uploadBlobCalls[0].dataSize != len(blobData) {
15501550+ t.Errorf("Expected UploadBlob to receive %d bytes, got %d", len(blobData), blobStore.uploadBlobCalls[0].dataSize)
15511551+ }
15521552+}
15531553+15541554+// TestHandleUploadBlob_EmptyBody tests empty blob upload
15551555+// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
15561556+func TestHandleUploadBlob_EmptyBody(t *testing.T) {
15571557+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
15581558+15591559+ // Empty blob should succeed (edge case)
15601560+ req := httptest.NewRequest(http.MethodPost, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte{}))
15611561+ req.Header.Set("Content-Type", "application/octet-stream")
15621562+ w := httptest.NewRecorder()
15631563+15641564+ handler.HandleUploadBlob(w, req)
15651565+15661566+ // Should succeed with empty blob
15671567+ if w.Code != http.StatusOK {
15681568+ t.Errorf("Expected status 200, got %d", w.Code)
15691569+ }
15701570+15711571+ // Verify blob store was called with 0 bytes
15721572+ if len(blobStore.uploadBlobCalls) != 1 || blobStore.uploadBlobCalls[0].dataSize != 0 {
15731573+ t.Errorf("Expected UploadBlob with 0 bytes")
15741574+ }
15751575+}
15761576+15771577+// TestHandleUploadBlob_MethodNotAllowed tests wrong HTTP method
15781578+// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
15791579+func TestHandleUploadBlob_MethodNotAllowed(t *testing.T) {
15801580+ handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
15811581+15821582+ // GET is not allowed for upload (only POST and PUT)
15831583+ req := httptest.NewRequest(http.MethodGet, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte("test")))
15841584+ w := httptest.NewRecorder()
15851585+15861586+ handler.HandleUploadBlob(w, req)
15871587+15881588+ if w.Code != http.StatusMethodNotAllowed {
15891589+ t.Errorf("Expected status 405, got %d", w.Code)
15901590+ }
15911591+}
15921592+15931593+// TestHandleUploadBlob_BlobStoreError tests blob store returning error
15941594+// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
15951595+func TestHandleUploadBlob_BlobStoreError(t *testing.T) {
15961596+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
15971597+15981598+ // Configure mock to return error
15991599+ blobStore.uploadBlobError = fmt.Errorf("storage driver unavailable")
16001600+16011601+ req := httptest.NewRequest(http.MethodPost, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte("test data")))
16021602+ req.Header.Set("Content-Type", "application/octet-stream")
16031603+ w := httptest.NewRecorder()
16041604+16051605+ handler.HandleUploadBlob(w, req)
16061606+16071607+ if w.Code != http.StatusInternalServerError {
16081608+ t.Errorf("Expected status 500, got %d", w.Code)
16091609+ }
16101610+}
16111611+16121612+// Tests for HandleGetBlob
16131613+16141614+// TestHandleGetBlob tests com.atproto.sync.getBlob
16151615+// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
16161616+func TestHandleGetBlob(t *testing.T) {
16171617+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
16181618+16191619+ holdDID := "did:web:hold.example.com"
16201620+ cid := "bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke"
16211621+16221622+ req := makeXRPCGetRequest("/xrpc/com.atproto.sync.getBlob", map[string]string{
16231623+ "did": holdDID,
16241624+ "cid": cid,
16251625+ })
16261626+ w := httptest.NewRecorder()
16271627+16281628+ handler.HandleGetBlob(w, req)
16291629+16301630+ // Should redirect to presigned download URL (307 Temporary Redirect)
16311631+ if w.Code != http.StatusTemporaryRedirect {
16321632+ t.Errorf("Expected status 307 (Temporary Redirect), got %d", w.Code)
16331633+ }
16341634+16351635+ location := w.Header().Get("Location")
16361636+ expectedURL := "https://s3.example.com/download/" + cid
16371637+ if location != expectedURL {
16381638+ t.Errorf("Expected redirect to %s, got %s", expectedURL, location)
16391639+ }
16401640+16411641+ // Verify blob store was called
16421642+ if len(blobStore.downloadCalls) != 1 || blobStore.downloadCalls[0] != cid {
16431643+ t.Errorf("Expected GetPresignedDownloadURL to be called with %s", cid)
16441644+ }
16451645+}
16461646+16471647+// TestHandleGetBlob_SHA256Digest tests getBlob with OCI sha256 digest format
16481648+// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
16491649+func TestHandleGetBlob_SHA256Digest(t *testing.T) {
16501650+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
16511651+16521652+ holdDID := "did:web:hold.example.com"
16531653+ digest := "sha256:abc123def456" // OCI digest format
16541654+16551655+ req := makeXRPCGetRequest("/xrpc/com.atproto.sync.getBlob", map[string]string{
16561656+ "did": holdDID,
16571657+ "cid": digest,
16581658+ })
16591659+ w := httptest.NewRecorder()
16601660+16611661+ handler.HandleGetBlob(w, req)
16621662+16631663+ // Should redirect to presigned download URL
16641664+ if w.Code != http.StatusTemporaryRedirect {
16651665+ t.Errorf("Expected status 307, got %d", w.Code)
16661666+ }
16671667+16681668+ // Verify blob store received the sha256 digest
16691669+ if len(blobStore.downloadCalls) != 1 || blobStore.downloadCalls[0] != digest {
16701670+ t.Errorf("Expected GetPresignedDownloadURL to be called with %s, got %v", digest, blobStore.downloadCalls)
16711671+ }
16721672+}
16731673+16741674+// TestHandleGetBlob_HeadMethod tests HEAD request support
16751675+// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
16761676+func TestHandleGetBlob_HeadMethod(t *testing.T) {
16771677+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
16781678+16791679+ holdDID := "did:web:hold.example.com"
16801680+ cid := "bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke"
16811681+16821682+ // Use HEAD instead of GET
16831683+ req := httptest.NewRequest(http.MethodHead, "/xrpc/com.atproto.sync.getBlob?did="+holdDID+"&cid="+cid, nil)
16841684+ w := httptest.NewRecorder()
16851685+16861686+ handler.HandleGetBlob(w, req)
16871687+16881688+ // Should still redirect
16891689+ if w.Code != http.StatusTemporaryRedirect {
16901690+ t.Errorf("Expected status 307 for HEAD request, got %d", w.Code)
16911691+ }
16921692+16931693+ // Verify blob store was called
16941694+ if len(blobStore.downloadCalls) != 1 {
16951695+ t.Errorf("Expected GetPresignedDownloadURL to be called for HEAD request")
16961696+ }
16971697+}
16981698+16991699+// TestHandleGetBlob_MissingParameters tests missing required parameters
17001700+// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
17011701+func TestHandleGetBlob_MissingParameters(t *testing.T) {
17021702+ handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
17031703+17041704+ tests := []struct {
17051705+ name string
17061706+ params map[string]string
17071707+ }{
17081708+ {
17091709+ name: "missing all params",
17101710+ params: map[string]string{},
17111711+ },
17121712+ {
17131713+ name: "missing cid",
17141714+ params: map[string]string{
17151715+ "did": "did:web:hold.example.com",
17161716+ },
17171717+ },
17181718+ {
17191719+ name: "missing did",
17201720+ params: map[string]string{
17211721+ "cid": "bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke",
17221722+ },
17231723+ },
17241724+ }
17251725+17261726+ for _, tt := range tests {
17271727+ t.Run(tt.name, func(t *testing.T) {
17281728+ req := makeXRPCGetRequest("/xrpc/com.atproto.sync.getBlob", tt.params)
17291729+ w := httptest.NewRecorder()
17301730+17311731+ handler.HandleGetBlob(w, req)
17321732+17331733+ if w.Code != http.StatusBadRequest {
17341734+ t.Errorf("Expected status 400, got %d", w.Code)
17351735+ }
17361736+ })
17371737+ }
17381738+}
17391739+17401740+// TestHandleGetBlob_InvalidDID tests invalid DID
17411741+// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
17421742+func TestHandleGetBlob_InvalidDID(t *testing.T) {
17431743+ handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
17441744+17451745+ req := makeXRPCGetRequest("/xrpc/com.atproto.sync.getBlob", map[string]string{
17461746+ "did": "did:plc:wrongdid",
17471747+ "cid": "bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke",
17481748+ })
17491749+ w := httptest.NewRecorder()
17501750+17511751+ handler.HandleGetBlob(w, req)
17521752+17531753+ if w.Code != http.StatusBadRequest {
17541754+ t.Errorf("Expected status 400 for invalid DID, got %d", w.Code)
17551755+ }
17561756+}
17571757+17581758+// TestHandleGetBlob_BlobStoreError tests blob store returning error
17591759+// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
17601760+func TestHandleGetBlob_BlobStoreError(t *testing.T) {
17611761+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
17621762+17631763+ // Configure mock to return error
17641764+ blobStore.downloadURLError = fmt.Errorf("blob not found in S3")
17651765+17661766+ req := makeXRPCGetRequest("/xrpc/com.atproto.sync.getBlob", map[string]string{
17671767+ "did": "did:web:hold.example.com",
17681768+ "cid": "bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke",
17691769+ })
17701770+ w := httptest.NewRecorder()
17711771+17721772+ handler.HandleGetBlob(w, req)
17731773+17741774+ if w.Code != http.StatusInternalServerError {
17751775+ t.Errorf("Expected status 500, got %d", w.Code)
17761776+ }
17771777+}
+26-1
pkg/hold/storage.go
···1111 "github.com/aws/aws-sdk-go/service/s3"
1212)
13131414+// atprotoBlobPath creates a per-DID storage path for ATProto blobs
1515+// ATProto spec stores blobs as: /repos/{did}/blobs/{cid}/data
1616+// This provides data sovereignty - each user's blobs are isolated
1717+func atprotoBlobPath(did, cid string) string {
1818+ // Clean DID for filesystem safety (replace : with -)
1919+ safeDID := strings.ReplaceAll(did, ":", "-")
2020+ return fmt.Sprintf("/repos/%s/blobs/%s/data", safeDID, cid)
2121+}
2222+1423// blobPath converts a digest (e.g., "sha256:abc123...") or temp path to a storage path
1524// Distribution stores blobs as: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data
1625// where xx is the first 2 characters of the hash for directory sharding
1726// NOTE: Path must start with / for filesystem driver
2727+// This is used for OCI container layers (content-addressed, globally deduplicated)
1828func blobPath(digest string) string {
1929 // Handle temp paths (start with uploads/temp-)
2030 if strings.HasPrefix(digest, "uploads/temp-") {
···4050}
41514252// getPresignedURL generates a presigned URL for GET, HEAD, or PUT operations
5353+// Distinguishes between ATProto blobs (per-DID) and OCI blobs (content-addressed)
4354func (s *HoldService) getPresignedURL(ctx context.Context, operation PresignedURLOperation, digest string, did string) (string, error) {
4444- path := blobPath(digest)
5555+ var path string
5656+5757+ // Determine blob type and construct appropriate path
5858+ if strings.HasPrefix(digest, "sha256:") || strings.HasPrefix(digest, "uploads/") {
5959+ // OCI container layer (sha256 digest or temp upload path)
6060+ // Use content-addressed storage (globally deduplicated)
6161+ path = blobPath(digest)
6262+ } else {
6363+ // ATProto blob (CID format like bafyreib...)
6464+ // Use per-DID storage for data sovereignty
6565+ if did == "" {
6666+ return "", fmt.Errorf("DID required for ATProto blob storage")
6767+ }
6868+ path = atprotoBlobPath(did, digest)
6969+ }
45704671 // Check blob exists for GET/HEAD operations (not for PUT since blob doesn't exist yet)
4772 if operation == OperationGet || operation == OperationHead {