···222222 return distribution.Descriptor{}, err
223223 }
224224225225- // Get presigned HEAD URL
226226- url, err := p.getHeadURL(ctx, dgst)
225225+ method := "HEAD"
226226+227227+ url, err := p.getPresignedURL(ctx, method, dgst)
227228 if err != nil {
228229 return distribution.Descriptor{}, distribution.ErrBlobUnknown
229230 }
230231231231- // Make HEAD request with service token authentication
232232- req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil)
232232+ // Make HEAD request to presigned URL
233233+ req, err := http.NewRequestWithContext(ctx, method, url, nil)
233234 if err != nil {
234235 return distribution.Descriptor{}, distribution.ErrBlobUnknown
235236 }
236237237237- resp, err := p.doAuthenticatedRequest(ctx, req)
238238+ // Go directly to the presigned URL, no need to authenticate
239239+ resp, err := p.httpClient.Do(req)
238240 if err != nil {
239241 return distribution.Descriptor{}, distribution.ErrBlobUnknown
240242 }
···264266 return nil, err
265267 }
266268267267- url, err := p.getDownloadURL(ctx, dgst)
269269+ method := "GET"
270270+271271+ url, err := p.getPresignedURL(ctx, method, dgst)
268272 if err != nil {
269273 return nil, err
270274 }
271275272272- // Download the blob
273273- resp, err := http.Get(url)
276276+ // Download the blob with service token authentication
277277+ req, err := http.NewRequestWithContext(ctx, method, url, nil)
274278 if err != nil {
275279 return nil, err
276280 }
281281+282282+ resp, err := p.doAuthenticatedRequest(ctx, req)
283283+ if err != nil {
284284+ return nil, err
285285+ }
286286+277287 defer resp.Body.Close()
278288279289 if resp.StatusCode != http.StatusOK {
···290300 return nil, err
291301 }
292302293293- url, err := p.getDownloadURL(ctx, dgst)
303303+ method := "GET"
304304+305305+ url, err := p.getPresignedURL(ctx, method, dgst)
294306 if err != nil {
295307 return nil, err
296308 }
297309298310 // Download the blob with service token authentication
299299- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
311311+ req, err := http.NewRequestWithContext(ctx, method, url, nil)
300312 if err != nil {
301313 return nil, err
302314 }
···370382 return err
371383 }
372384373373- // For HEAD requests, proxy the response instead of redirecting
374374- // This avoids authentication issues when client follows redirects
375375- if r.Method == http.MethodHead {
376376- url, err := p.getHeadURL(ctx, dgst)
377377- if err != nil {
378378- return err
379379- }
380380-381381- // Make authenticated HEAD request to hold service
382382- req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil)
383383- if err != nil {
384384- return err
385385- }
386386-387387- resp, err := p.doAuthenticatedRequest(ctx, req)
388388- if err != nil {
389389- return err
390390- }
391391- defer resp.Body.Close()
392392-393393- if resp.StatusCode != http.StatusOK {
394394- return fmt.Errorf("blob not found")
395395- }
396396-397397- // Copy response headers
398398- if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
399399- w.Header().Set("Content-Length", contentLength)
400400- }
401401- if contentType := resp.Header.Get("Content-Type"); contentType != "" {
402402- w.Header().Set("Content-Type", contentType)
403403- }
404404- if etag := resp.Header.Get("ETag"); etag != "" {
405405- w.Header().Set("ETag", etag)
406406- }
407407-408408- w.WriteHeader(http.StatusOK)
409409- return nil
410410- }
411411-412412- // For GET requests, redirect to presigned URL for direct download
413413- url, err := p.getDownloadURL(ctx, dgst)
385385+ url, err := p.getPresignedURL(ctx, r.Method, dgst)
414386 if err != nil {
415387 return err
416388 }
···483455 return writer, nil
484456}
485457486486-// getDownloadURL returns the XRPC getBlob URL for downloading a blob
487487-// The hold service will redirect to a presigned S3 URL
488488-func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) (string, error) {
489489- // Use XRPC endpoint: GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid={digest}
458458+// getPresignedURL returns the XRPC endpoint URL for blob operations
459459+func (p *ProxyBlobStore) getPresignedURL(ctx context.Context, operation string, dgst digest.Digest) (string, error) {
460460+ // Use XRPC endpoint: /xrpc/com.atproto.sync.getBlob?did={userDID}&cid={digest}
490461 // The 'did' parameter is the USER's DID (whose blob we're fetching), not the hold service DID
491462 // Per migration doc: hold accepts OCI digest directly as cid parameter (checks for sha256: prefix)
492492- url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
493493- p.holdURL, p.ctx.DID, dgst.String())
494494- return url, nil
495495-}
463463+ xrpcURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s&method=%s",
464464+ p.holdURL, p.ctx.DID, dgst.String(), operation)
465465+466466+ req, err := http.NewRequestWithContext(ctx, "GET", xrpcURL, nil)
467467+ if err != nil {
468468+ return "", fmt.Errorf("failed to create request: %w", err)
469469+ }
470470+471471+ resp, err := p.doAuthenticatedRequest(ctx, req)
472472+ if err != nil {
473473+ return "", fmt.Errorf("failed to call hold service: %w", err)
474474+ }
475475+ defer resp.Body.Close()
476476+477477+ if resp.StatusCode != http.StatusOK {
478478+ bodyBytes, _ := io.ReadAll(resp.Body)
479479+ return "", fmt.Errorf("hold service returned error: status %d, body: %s", resp.StatusCode, string(bodyBytes))
480480+ }
481481+482482+ // Parse JSON response to get presigned HEAD URL
483483+ var result struct {
484484+ URL string `json:"url"`
485485+ }
486486+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
487487+ return "", fmt.Errorf("failed to parse hold service response: %w", err)
488488+ }
489489+490490+ if result.URL == "" {
491491+ return "", fmt.Errorf("hold service returned empty URL")
492492+ }
496493497497-// getHeadURL returns the XRPC getBlob URL for HEAD requests
498498-// The hold service will redirect to a presigned S3 URL
499499-func (p *ProxyBlobStore) getHeadURL(ctx context.Context, dgst digest.Digest) (string, error) {
500500- // Same as GET - hold service handles HEAD method on getBlob endpoint
501501- // The 'did' parameter is the USER's DID (whose blob we're checking), not the hold service DID
502502- url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
503503- p.holdURL, p.ctx.DID, dgst.String())
504504- return url, nil
494494+ fmt.Printf("DEBUG [proxy_blob_store]: Got presigned HEAD URL from hold service: %s\n", result.URL)
495495+ return result.URL, nil
505496}
506497507498// startMultipartUpload initiates a multipart upload via XRPC uploadBlob endpoint
+23-49
pkg/hold/pds/xrpc.go
···978978 digest = cidOrDigest
979979 }
980980981981- // Handle HEAD vs GET differently - they need different presigned URLs
982982- if r.Method == http.MethodHead {
983983- // For HEAD requests: generate HEAD presigned URL and proxy to S3
984984- // AppView expects 200 OK with Content-Length, not a redirect
985985- // Note: HEAD presigned URLs have different signatures than GET URLs
986986-987987- headURL, err := h.blobStore.GetPresignedURL("HEAD", digest, did) // TODO: Add GetPresignedHeadURL method
988988- if err != nil {
989989- log.Printf("[HandleGetBlob] Failed to get presigned HEAD URL: digest=%s, did=%s, err=%v", digest, did, err)
990990- http.Error(w, "blob not found", http.StatusNotFound)
991991- return
992992- }
993993-994994- log.Printf("[HandleGetBlob] Proxying HEAD request to: %s", headURL)
995995-996996- headResp, err := http.Head(headURL)
997997- if err != nil {
998998- log.Printf("[HandleGetBlob] HEAD request failed: %v", err)
999999- http.Error(w, "blob not found", http.StatusNotFound)
10001000- return
10011001- }
10021002- defer headResp.Body.Close()
10031003-10041004- if headResp.StatusCode != http.StatusOK {
10051005- log.Printf("[HandleGetBlob] HEAD request returned non-200: %d", headResp.StatusCode)
10061006- http.Error(w, "blob not found", http.StatusNotFound)
10071007- return
981981+ // Determine presigned URL operation
982982+ // Check for ?method=HEAD query parameter first (from AppView), then fall back to request method
983983+ // HEAD and GET need different presigned URL signatures
984984+ operation := r.URL.Query().Get("method")
985985+ if operation == "" {
986986+ operation = "GET"
987987+ if r.Method == http.MethodHead {
988988+ operation = "HEAD"
1008989 }
990990+ }
100999110101010- // Copy relevant headers from S3 response
10111011- if contentLength := headResp.Header.Get("Content-Length"); contentLength != "" {
10121012- w.Header().Set("Content-Length", contentLength)
10131013- }
10141014- if contentType := headResp.Header.Get("Content-Type"); contentType != "" {
10151015- w.Header().Set("Content-Type", contentType)
10161016- }
10171017- if etag := headResp.Header.Get("ETag"); etag != "" {
10181018- w.Header().Set("ETag", etag)
10191019- }
992992+ // Generate presigned URL for the operation
993993+ presignedURL, err := h.blobStore.GetPresignedURL(operation, digest, did)
994994+ if err != nil {
995995+ log.Printf("[HandleGetBlob] Failed to get presigned %s URL: digest=%s, did=%s, err=%v", operation, digest, did, err)
996996+ http.Error(w, "failed to get presigned URL", http.StatusInternalServerError)
997997+ return
998998+ }
102099910211021- log.Printf("[HandleGetBlob] HEAD request successful, Content-Length: %s", headResp.Header.Get("Content-Length"))
10221022- w.WriteHeader(http.StatusOK)
10231023- } else {
10241024- // For GET requests: generate GET presigned URL and redirect for direct download from S3
10251025- downloadURL, err := h.blobStore.GetPresignedURL("GET", digest, did)
10261026- if err != nil {
10271027- log.Printf("[HandleGetBlob] Failed to get presigned GET URL: digest=%s, did=%s, err=%v", digest, did, err)
10281028- http.Error(w, "failed to get download URL", http.StatusInternalServerError)
10291029- return
10301030- }
10001000+ log.Printf("[HandleGetBlob] Returning presigned %s URL: %s", operation, presignedURL)
1031100110321032- log.Printf("[HandleGetBlob] Redirecting GET request to presigned URL: %s", downloadURL)
10331033- http.Redirect(w, r, downloadURL, http.StatusTemporaryRedirect)
10021002+ // Return JSON response with the presigned URL
10031003+ // AppView will either redirect (GET) or proxy (HEAD) using this URL
10041004+ response := map[string]string{
10051005+ "url": presignedURL,
10341006 }
10071007+ w.Header().Set("Content-Type", "application/json")
10081008+ json.NewEncoder(w).Encode(response)
10351009}
1036101010371011// HandleListRepos lists all repositories in this PDS
+65-21
pkg/hold/pds/xrpc_test.go
···13861386}
1387138713881388func (m *mockBlobStore) GetPresignedURL(operation, digest, did string) (string, error) {
13891389- if operation == "GET" {
13891389+ if operation == "GET" || operation == "HEAD" {
13901390+ // Both GET and HEAD are download operations, just different HTTP methods
13901391 m.downloadCalls = append(m.downloadCalls, digest)
13911392 if m.downloadURLError != nil {
13921393 return "", m.downloadURLError
···13951396 return "https://s3.example.com/download/" + digest, nil
13961397 }
1397139813991399+ // PUT or other upload operations
13981400 m.uploadCalls = append(m.uploadCalls, digest)
13991401 if m.uploadURLError != nil {
14001402 return "", m.uploadURLError
···1680168216811683 handler.HandleGetBlob(w, req)
1682168416831683- // Should redirect to presigned download URL (307 Temporary Redirect)
16841684- if w.Code != http.StatusTemporaryRedirect {
16851685- t.Errorf("Expected status 307 (Temporary Redirect), got %d", w.Code)
16851685+ // Should return 200 OK with JSON response containing presigned URL
16861686+ if w.Code != http.StatusOK {
16871687+ t.Errorf("Expected status 200 OK, got %d", w.Code)
16881688+ }
16891689+16901690+ // Verify Content-Type is JSON
16911691+ contentType := w.Header().Get("Content-Type")
16921692+ if contentType != "application/json" {
16931693+ t.Errorf("Expected Content-Type application/json, got %s", contentType)
16941694+ }
16951695+16961696+ // Parse JSON response
16971697+ var response map[string]string
16981698+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
16991699+ t.Fatalf("Failed to parse JSON response: %v", err)
16861700 }
1687170116881688- location := w.Header().Get("Location")
17021702+ // Verify URL field exists
16891703 expectedURL := "https://s3.example.com/download/" + cid
16901690- if location != expectedURL {
16911691- t.Errorf("Expected redirect to %s, got %s", expectedURL, location)
17041704+ if response["url"] != expectedURL {
17051705+ t.Errorf("Expected url to be %s, got %s", expectedURL, response["url"])
16921706 }
1693170716941708 // Verify blob store was called
16951709 if len(blobStore.downloadCalls) != 1 || blobStore.downloadCalls[0] != cid {
16961696- t.Errorf("Expected GetPresignedDownloadURL to be called with %s", cid)
17101710+ t.Errorf("Expected GetPresignedURL to be called with %s", cid)
16971711 }
16981712}
16991713···1713172717141728 handler.HandleGetBlob(w, req)
1715172917161716- // Should redirect to presigned download URL
17171717- if w.Code != http.StatusTemporaryRedirect {
17181718- t.Errorf("Expected status 307, got %d", w.Code)
17301730+ // Should return 200 OK with JSON response
17311731+ if w.Code != http.StatusOK {
17321732+ t.Errorf("Expected status 200 OK, got %d", w.Code)
17331733+ }
17341734+17351735+ // Parse JSON response
17361736+ var response map[string]string
17371737+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
17381738+ t.Fatalf("Failed to parse JSON response: %v", err)
17391739+ }
17401740+17411741+ // Verify URL field exists
17421742+ if response["url"] == "" {
17431743+ t.Errorf("Expected url field in response, got empty")
17191744 }
1720174517211746 // Verify blob store received the sha256 digest
17221747 if len(blobStore.downloadCalls) != 1 || blobStore.downloadCalls[0] != digest {
17231723- t.Errorf("Expected GetPresignedDownloadURL to be called with %s, got %v", digest, blobStore.downloadCalls)
17481748+ t.Errorf("Expected GetPresignedURL to be called with %s, got %v", digest, blobStore.downloadCalls)
17241749 }
17251750}
1726175117271752// TestHandleGetBlob_HeadMethod tests HEAD request support
17281728-// HEAD requests are proxied (not redirected) to avoid S3 presigned URL signature issues
17291729-// The hold service makes the HEAD request itself and returns 200 OK with headers
17531753+// HEAD requests now return JSON with presigned HEAD URL (same as GET)
17541754+// AppView is responsible for making the actual HEAD request to S3
17301755// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
17311756func TestHandleGetBlob_HeadMethod(t *testing.T) {
17321732- handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
17571757+ handler, blobStore, _ := setupTestXRPCHandlerWithBlobs(t)
1733175817341759 holdDID := "did:web:hold.example.com"
17351760 cid := "bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke"
···1740176517411766 handler.HandleGetBlob(w, req)
1742176717431743- // HEAD requests are now proxied - the handler makes an HTTP HEAD to the presigned URL
17441744- // In the test environment, this will fail because the mock returns a fake URL
17451745- // Expect 404 because the handler couldn't reach the mock S3 URL
17461746- if w.Code != http.StatusNotFound {
17471747- t.Errorf("Expected status 404 (mock S3 unreachable), got %d", w.Code)
17681768+ // Should return 200 OK with JSON response containing presigned HEAD URL
17691769+ if w.Code != http.StatusOK {
17701770+ t.Errorf("Expected status 200 OK, got %d", w.Code)
17711771+ }
17721772+17731773+ // Verify Content-Type is JSON
17741774+ contentType := w.Header().Get("Content-Type")
17751775+ if contentType != "application/json" {
17761776+ t.Errorf("Expected Content-Type application/json, got %s", contentType)
17771777+ }
17781778+17791779+ // Parse JSON response
17801780+ var response map[string]string
17811781+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
17821782+ t.Fatalf("Failed to parse JSON response: %v", err)
17481783 }
1749178417501750- // Note: In production with real S3, HEAD would return 200 OK with Content-Length header
17851785+ // Verify URL field exists
17861786+ expectedURL := "https://s3.example.com/download/" + cid
17871787+ if response["url"] != expectedURL {
17881788+ t.Errorf("Expected url to be %s, got %s", expectedURL, response["url"])
17891789+ }
17901790+17911791+ // Verify blob store was called with HEAD operation
17921792+ if len(blobStore.downloadCalls) != 1 || blobStore.downloadCalls[0] != cid {
17931793+ t.Errorf("Expected GetPresignedURL to be called with %s", cid)
17941794+ }
17511795}
1752179617531797// TestHandleGetBlob_MissingParameters tests missing required parameters