refactor hold pkg to separate oci image endpoints

This commit is contained in:
Evan Jarrett
2025-10-19 17:23:46 -05:00
parent fa931aca3b
commit 1b3a4eea47
14 changed files with 733 additions and 1427 deletions
+43 -19
View File
@@ -7,11 +7,16 @@ import (
"net/http"
"atcr.io/pkg/hold"
"atcr.io/pkg/hold/oci"
"atcr.io/pkg/hold/pds"
"atcr.io/pkg/s3"
// Import storage drivers
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
_ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"
"github.com/go-chi/chi/v5"
)
func main() {
@@ -54,39 +59,58 @@ func main() {
log.Fatalf("Database path is required for embedded PDS authorization")
}
// Create blob store adapter and XRPC handler
// Create blob store adapter and XRPC handlers
var ociHandler *oci.XRPCHandler
if holdPDS != nil {
// Create hold service with PDS
service, err := hold.NewHoldService(cfg, holdPDS)
// Create storage driver from config
ctx := context.Background()
driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters())
if err != nil {
log.Fatalf("Failed to create hold service: %v", err)
}
xrpcHandler = pds.NewXRPCHandler(holdPDS, cfg.Server.PublicURL, service, broadcaster, nil)
}
// Setup HTTP routes
mux := http.NewServeMux()
// Root page
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "This is a hold server. More info at https://atcr.io")
log.Fatalf("failed to create storage driver: %v", err)
return
}
http.NotFound(w, r)
s3Service, err := s3.NewS3Service(cfg.Storage.Parameters(), cfg.Server.DisablePresignedURLs, cfg.Storage.Type())
if err != nil {
log.Fatalf("Failed to create s3 service: %v", err)
}
// Create PDS XRPC handler (ATProto endpoints)
xrpcHandler = pds.NewXRPCHandler(holdPDS, *s3Service, driver, broadcaster, nil)
// Create OCI XRPC handler (multipart upload endpoints)
ociHandler = oci.NewXRPCHandler(holdPDS, *s3Service, driver, cfg.Server.DisablePresignedURLs, nil)
}
// Setup HTTP routes with chi router
r := chi.NewRouter()
// Root page
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "This is a hold server. More info at https://atcr.io")
})
// Register XRPC/ATProto PDS endpoints if PDS is initialized
// TODO: Migrate pds.RegisterHandlers to use chi.Router
if xrpcHandler != nil {
log.Printf("Registering ATProto PDS endpoints")
xrpcHandler.RegisterHandlers(mux)
// PDS still uses http.ServeMux, so we mount it temporarily
pdsMux := http.NewServeMux()
xrpcHandler.RegisterHandlers(pdsMux)
r.Mount("/", pdsMux)
}
// Register OCI multipart upload endpoints
if ociHandler != nil {
log.Printf("Registering OCI multipart upload endpoints")
ociHandler.RegisterHandlers(r)
}
// Create server
server := &http.Server{
Addr: cfg.Server.Addr,
Handler: mux,
Handler: r,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
+1
View File
@@ -42,6 +42,7 @@ require (
github.com/docker/go-metrics v0.0.1 // indirect
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
+2
View File
@@ -64,6 +64,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+34
View File
@@ -0,0 +1,34 @@
package oci
import (
"encoding/json"
"fmt"
"net/http"
)
// DecodeJSON decodes JSON request body into the provided value
// Returns an error if decoding fails
func DecodeJSON(r *http.Request, v any) error {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
return fmt.Errorf("invalid JSON body: %w", err)
}
return nil
}
// RespondJSON writes a JSON response with the given status code
func RespondJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
// If encoding fails, we can't do much since headers are already sent
// Log the error but don't try to send another response
fmt.Printf("ERROR: failed to encode JSON response: %v\n", err)
}
}
// RespondError writes a JSON error response with the given status code and message
func RespondError(w http.ResponseWriter, status int, message string) {
RespondJSON(w, status, map[string]string{
"error": message,
})
}
@@ -1,13 +1,11 @@
package hold
package oci
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"sort"
"strings"
"sync"
@@ -212,34 +210,34 @@ func (s *MultipartSession) AssembleBufferedParts() ([]byte, int64, error) {
// StartMultipartUploadWithManager initiates a multipart upload using the manager
// Returns uploadID and mode
func (s *HoldService) StartMultipartUploadWithManager(ctx context.Context, digest string) (string, MultipartMode, error) {
func (h *XRPCHandler) StartMultipartUploadWithManager(ctx context.Context, digest string) (string, MultipartMode, error) {
// Check if presigned URLs are disabled for testing
if s.config.Server.DisablePresignedURLs {
if h.disablePresignedURLs {
log.Printf("Presigned URLs disabled (DISABLE_PRESIGNED_URLS=true), using buffered mode")
session := s.MultipartMgr.CreateSession(digest, Buffered, "")
session := h.MultipartMgr.CreateSession(digest, Buffered, "")
log.Printf("Started buffered multipart: uploadID=%s", session.UploadID)
return session.UploadID, Buffered, nil
}
// Try S3 native multipart first
if s.s3Client != nil {
if s.s3Client == nil {
if h.s3Service.Client != nil {
if h.s3Service.Client == nil {
return "", S3Native, fmt.Errorf("S3 not configured")
}
path := blobPath(digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
if h.s3Service.PathPrefix != "" {
s3Key = h.s3Service.PathPrefix + "/" + s3Key
}
result, err := s.s3Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{
Bucket: &s.bucket,
result, err := h.s3Service.Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
})
if err == nil {
s3UploadID := *result.UploadId
// S3 native multipart succeeded
session := s.MultipartMgr.CreateSession(digest, S3Native, s3UploadID)
session := h.MultipartMgr.CreateSession(digest, S3Native, s3UploadID)
log.Printf("Started S3 native multipart: digest=%s, uploadID=%s, s3UploadID=%s", digest, session.UploadID, s3UploadID)
return session.UploadID, S3Native, nil
}
@@ -247,33 +245,33 @@ func (s *HoldService) StartMultipartUploadWithManager(ctx context.Context, diges
}
// Fallback to buffered mode
session := s.MultipartMgr.CreateSession(digest, Buffered, "")
session := h.MultipartMgr.CreateSession(digest, Buffered, "")
log.Printf("Started buffered multipart: uploadID=%s", session.UploadID)
return session.UploadID, Buffered, nil
}
// GetPartUploadURL generates a presigned URL for uploading a part
// Only used for S3Native mode - Buffered mode is handled by blobstore adapter
func (s *HoldService) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (*PartUploadInfo, error) {
session, err := s.MultipartMgr.GetSession(uploadID)
func (h *XRPCHandler) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int) (*PartUploadInfo, error) {
session, err := h.MultipartMgr.GetSession(uploadID)
if err != nil {
return nil, err
}
// For S3Native mode: return presigned URL
if session.Mode == S3Native {
if s.s3Client == nil {
if h.s3Service.Client == nil {
return nil, fmt.Errorf("S3 not configured")
}
path := blobPath(session.Digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
if h.s3Service.PathPrefix != "" {
s3Key = h.s3Service.PathPrefix + "/" + s3Key
}
pnum := int64(partNumber)
req, _ := s.s3Client.UploadPartRequest(&s3.UploadPartInput{
Bucket: &s.bucket,
req, _ := h.s3Service.Client.UploadPartRequest(&s3.UploadPartInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
UploadId: &uploadID,
PartNumber: &pnum,
@@ -294,7 +292,7 @@ func (s *HoldService) GetPartUploadURL(ctx context.Context, uploadID string, par
// Buffered mode: return XRPC endpoint with headers
return &PartUploadInfo{
URL: fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", s.config.Server.PublicURL),
URL: fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", h.pds.PublicURL),
Method: "PUT",
Headers: map[string]string{
"X-Upload-Id": uploadID,
@@ -306,15 +304,15 @@ func (s *HoldService) GetPartUploadURL(ctx context.Context, uploadID string, par
// CompleteMultipartUploadWithManager completes a multipart upload and moves to final location
// finalDigest is the real digest (e.g., "sha256:abc123...") for the final storage location
// session.Digest is the temp location (e.g., "uploads/temp-<uuid>")
func (s *HoldService) CompleteMultipartUploadWithManager(ctx context.Context, uploadID string, finalDigest string, parts []PartInfo) error {
session, err := s.MultipartMgr.GetSession(uploadID)
defer s.MultipartMgr.DeleteSession(uploadID)
func (h *XRPCHandler) CompleteMultipartUploadWithManager(ctx context.Context, uploadID string, finalDigest string, parts []PartInfo) error {
session, err := h.MultipartMgr.GetSession(uploadID)
defer h.MultipartMgr.DeleteSession(uploadID)
if err != nil {
return err
}
if session.Mode == S3Native {
if s.s3Client == nil {
if h.s3Service.Client == nil {
return fmt.Errorf("S3 not configured")
}
@@ -336,12 +334,12 @@ func (s *HoldService) CompleteMultipartUploadWithManager(ctx context.Context, up
}
sourcePath := blobPath(session.Digest)
s3Key := strings.TrimPrefix(sourcePath, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
if h.s3Service.PathPrefix != "" {
s3Key = h.s3Service.PathPrefix + "/" + s3Key
}
_, err = s.s3Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{
Bucket: &s.bucket,
_, err = h.s3Service.Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
UploadId: &uploadID,
MultipartUpload: &s3.CompletedMultipartUpload{
@@ -357,7 +355,7 @@ func (s *HoldService) CompleteMultipartUploadWithManager(ctx context.Context, up
destPath := blobPath(finalDigest)
log.Printf("[DEBUG] About to move: source=%s, dest=%s", sourcePath, destPath)
if _, err := s.driver.Stat(ctx, sourcePath); err != nil {
if _, err := h.driver.Stat(ctx, sourcePath); err != nil {
log.Printf("[ERROR] Source blob not found after multipart complete: path=%s, err=%v", sourcePath, err)
return fmt.Errorf("source blob not found after multipart complete: %w", err)
}
@@ -365,7 +363,7 @@ func (s *HoldService) CompleteMultipartUploadWithManager(ctx context.Context, up
// Move from temp to final digest location using driver
// Driver handles path management correctly (including S3 prefix)
if err := s.driver.Move(ctx, sourcePath, destPath); err != nil {
if err := h.driver.Move(ctx, sourcePath, destPath); err != nil {
log.Printf("[ERROR] Failed to move blob: source=%s, dest=%s, err=%v", sourcePath, destPath, err)
return fmt.Errorf("failed to move blob to final location: %w", err)
}
@@ -382,7 +380,7 @@ func (s *HoldService) CompleteMultipartUploadWithManager(ctx context.Context, up
// Write assembled blob to final digest location (not temp)
path := blobPath(finalDigest)
writer, err := s.driver.Writer(ctx, path, false)
writer, err := h.driver.Writer(ctx, path, false)
if err != nil {
return fmt.Errorf("failed to create writer: %w", err)
}
@@ -402,25 +400,25 @@ func (s *HoldService) CompleteMultipartUploadWithManager(ctx context.Context, up
}
// AbortMultipartUploadWithManager aborts a multipart upload
func (s *HoldService) AbortMultipartUploadWithManager(ctx context.Context, uploadID string) error {
session, err := s.MultipartMgr.GetSession(uploadID)
defer s.MultipartMgr.DeleteSession(uploadID)
func (h *XRPCHandler) AbortMultipartUploadWithManager(ctx context.Context, uploadID string) error {
session, err := h.MultipartMgr.GetSession(uploadID)
defer h.MultipartMgr.DeleteSession(uploadID)
if err != nil {
return err
}
if session.Mode == S3Native {
if s.s3Client == nil {
if h.s3Service.Client == nil {
return fmt.Errorf("S3 not configured")
}
path := blobPath(session.Digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
if h.s3Service.PathPrefix != "" {
s3Key = h.s3Service.PathPrefix + "/" + s3Key
}
_, err := s.s3Client.AbortMultipartUploadWithContext(ctx, &s3.AbortMultipartUploadInput{
Bucket: &s.bucket,
_, err := h.s3Service.Client.AbortMultipartUploadWithContext(ctx, &s3.AbortMultipartUploadInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
UploadId: &uploadID,
})
@@ -437,102 +435,19 @@ func (s *HoldService) AbortMultipartUploadWithManager(ctx context.Context, uploa
return nil
}
// handleMultipartOperation handles multipart upload operations via JSON request
func (s *HoldService) HandleMultipartOperation(w http.ResponseWriter, r *http.Request, did string) {
ctx := r.Context()
// Parse JSON body
var req struct {
Action string `json:"action"`
Digest string `json:"digest,omitempty"`
UploadID string `json:"uploadId,omitempty"`
PartNumber int `json:"partNumber,omitempty"`
Parts []PartInfo `json:"parts,omitempty"`
// HandleBufferedPartUpload handles uploading a part in buffered mode
func (h *XRPCHandler) HandleBufferedPartUpload(ctx context.Context, uploadID string, partNumber int, data []byte) (string, error) {
session, err := h.MultipartMgr.GetSession(uploadID)
if err != nil {
return "", err
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON body: %v", err), http.StatusBadRequest)
return
if session.Mode != Buffered {
return "", fmt.Errorf("session is not in buffered mode")
}
// Route based on action
switch req.Action {
case "start":
// Start multipart upload
if req.Digest == "" {
http.Error(w, "digest required for start action", http.StatusBadRequest)
return
}
uploadID, _, err := s.StartMultipartUploadWithManager(ctx, req.Digest)
if err != nil {
http.Error(w, fmt.Sprintf("failed to start multipart upload: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"uploadId": uploadID,
})
case "part":
// Get part upload URL
if req.UploadID == "" || req.PartNumber == 0 {
http.Error(w, "uploadId and partNumber required for part action", http.StatusBadRequest)
return
}
uploadInfo, err := s.GetPartUploadURL(ctx, req.UploadID, req.PartNumber, did)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get part URL: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(uploadInfo)
case "complete":
// Complete multipart upload
if req.UploadID == "" || len(req.Parts) == 0 {
http.Error(w, "uploadId and parts required for complete action", http.StatusBadRequest)
return
}
if req.Digest == "" {
http.Error(w, "digest required for complete action", http.StatusBadRequest)
return
}
// Pass the real digest so hold can move temp → final location
if err := s.CompleteMultipartUploadWithManager(ctx, req.UploadID, req.Digest, req.Parts); err != nil {
http.Error(w, fmt.Sprintf("failed to complete multipart upload: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "completed",
})
case "abort":
// Abort multipart upload
if req.UploadID == "" {
http.Error(w, "uploadId required for abort action", http.StatusBadRequest)
return
}
if err := s.AbortMultipartUploadWithManager(ctx, req.UploadID); err != nil {
http.Error(w, fmt.Sprintf("failed to abort multipart upload: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "aborted",
})
default:
http.Error(w, fmt.Sprintf("unknown action: %s", req.Action), http.StatusBadRequest)
}
etag := session.StorePart(partNumber, data)
return etag, nil
}
// normalizeETag ensures an ETag has quotes (required by S3 CompleteMultipartUpload)
@@ -545,3 +460,32 @@ func normalizeETag(etag string) string {
// Add quotes
return fmt.Sprintf("\"%s\"", etag)
}
// blobPath converts a digest (e.g., "sha256:abc123...") or temp path to a storage path
// Distribution stores blobs as: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data
// where xx is the first 2 characters of the hash for directory sharding
// NOTE: Path must start with / for filesystem driver
// This is used for OCI container layers (content-addressed, globally deduplicated)
func blobPath(digest string) string {
// Handle temp paths (start with uploads/temp-)
if strings.HasPrefix(digest, "uploads/temp-") {
return fmt.Sprintf("/docker/registry/v2/%s/data", digest)
}
// Split digest into algorithm and hash
parts := strings.SplitN(digest, ":", 2)
if len(parts) != 2 {
// Fallback for malformed digest
return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
}
algorithm := parts[0]
hash := parts[1]
// Use first 2 characters for sharding
if len(hash) < 2 {
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash)
}
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash)
}
+213
View File
@@ -0,0 +1,213 @@
package oci
import (
"fmt"
"io"
"net/http"
"strconv"
"atcr.io/pkg/hold/pds"
"atcr.io/pkg/s3"
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/go-chi/chi/v5"
)
// XRPCHandler handles OCI-specific XRPC endpoints for multipart uploads
type XRPCHandler struct {
driver storagedriver.StorageDriver
disablePresignedURLs bool
s3Service s3.S3Service
MultipartMgr *MultipartManager // Exported for access in route handlers
pds *pds.HoldPDS
httpClient pds.HTTPClient
}
// NewXRPCHandler creates a new OCI XRPC handler
func NewXRPCHandler(holdPDS *pds.HoldPDS, s3Service s3.S3Service, driver storagedriver.StorageDriver, disablePresignedURLs bool, httpClient pds.HTTPClient) *XRPCHandler {
return &XRPCHandler{
driver: driver,
disablePresignedURLs: disablePresignedURLs,
MultipartMgr: NewMultipartManager(),
s3Service: s3Service,
pds: holdPDS,
httpClient: httpClient,
}
}
// RegisterHandlers registers all OCI XRPC endpoints with the chi router
func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
// All multipart upload endpoints require blob:write permission
r.Group(func(r chi.Router) {
r.Use(h.requireBlobWriteAccess)
r.Post("/xrpc/io.atcr.hold.initiateUpload", h.HandleInitiateUpload)
r.Post("/xrpc/io.atcr.hold.getPartUploadUrl", h.HandleGetPartUploadUrl)
r.Put("/xrpc/io.atcr.hold.uploadPart", h.HandleUploadPart)
r.Post("/xrpc/io.atcr.hold.completeUpload", h.HandleCompleteUpload)
r.Post("/xrpc/io.atcr.hold.abortUpload", h.HandleAbortUpload)
})
}
// HandleInitiateUpload starts a new multipart upload
// Replaces the old "action: start" pattern
func (h *XRPCHandler) HandleInitiateUpload(w http.ResponseWriter, r *http.Request) {
var req struct {
Digest string `json:"digest"`
}
if err := DecodeJSON(r, &req); err != nil {
RespondError(w, http.StatusBadRequest, err.Error())
return
}
if req.Digest == "" {
RespondError(w, http.StatusBadRequest, "digest is required")
return
}
uploadID, _, err := h.StartMultipartUploadWithManager(r.Context(), req.Digest)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to initiate upload: %v", err))
return
}
RespondJSON(w, http.StatusOK, map[string]any{
"uploadId": uploadID,
})
}
// HandleGetPartUploadUrl returns a presigned URL or endpoint info for uploading a part
// Replaces the old "action: part" pattern
func (h *XRPCHandler) HandleGetPartUploadUrl(w http.ResponseWriter, r *http.Request) {
var req struct {
UploadID string `json:"uploadId"`
PartNumber int `json:"partNumber"`
}
if err := DecodeJSON(r, &req); err != nil {
RespondError(w, http.StatusBadRequest, err.Error())
return
}
if req.UploadID == "" || req.PartNumber == 0 {
RespondError(w, http.StatusBadRequest, "uploadId and partNumber are required")
return
}
uploadInfo, err := h.GetPartUploadURL(r.Context(), req.UploadID, req.PartNumber)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get part upload URL: %v", err))
return
}
RespondJSON(w, http.StatusOK, uploadInfo)
}
// HandleUploadPart handles direct buffered part uploads
// Moved from pds/xrpc.go - this is OCI-specific multipart upload logic
func (h *XRPCHandler) HandleUploadPart(w http.ResponseWriter, r *http.Request) {
uploadID := r.Header.Get("X-Upload-Id")
partNumberStr := r.Header.Get("X-Part-Number")
if uploadID == "" || partNumberStr == "" {
RespondError(w, http.StatusBadRequest, "X-Upload-Id and X-Part-Number headers are required")
return
}
partNumber, err := strconv.Atoi(partNumberStr)
if err != nil {
RespondError(w, http.StatusBadRequest, fmt.Sprintf("invalid part number: %v", err))
return
}
data, err := io.ReadAll(r.Body)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read part data: %v", err))
return
}
etag, err := h.HandleBufferedPartUpload(r.Context(), uploadID, partNumber, data)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to upload part: %v", err))
return
}
RespondJSON(w, http.StatusOK, map[string]any{
"etag": etag,
})
}
// HandleCompleteUpload finalizes a multipart upload
// Replaces the old "action: complete" pattern
func (h *XRPCHandler) HandleCompleteUpload(w http.ResponseWriter, r *http.Request) {
var req struct {
UploadID string `json:"uploadId"`
Digest string `json:"digest"`
Parts []PartInfo `json:"parts"`
}
if err := DecodeJSON(r, &req); err != nil {
RespondError(w, http.StatusBadRequest, err.Error())
return
}
if req.UploadID == "" || req.Digest == "" || len(req.Parts) == 0 {
RespondError(w, http.StatusBadRequest, "uploadId, digest, and parts are required")
return
}
err := h.CompleteMultipartUploadWithManager(r.Context(), req.UploadID, req.Digest, req.Parts)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to complete upload: %v", err))
return
}
RespondJSON(w, http.StatusOK, map[string]any{
"status": "completed",
"digest": req.Digest,
})
}
// HandleAbortUpload cancels a multipart upload
// Replaces the old "action: abort" pattern
func (h *XRPCHandler) HandleAbortUpload(w http.ResponseWriter, r *http.Request) {
var req struct {
UploadID string `json:"uploadId"`
}
if err := DecodeJSON(r, &req); err != nil {
RespondError(w, http.StatusBadRequest, err.Error())
return
}
if req.UploadID == "" {
RespondError(w, http.StatusBadRequest, "uploadId is required")
return
}
err := h.AbortMultipartUploadWithManager(r.Context(), req.UploadID)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to abort upload: %v", err))
return
}
RespondJSON(w, http.StatusOK, map[string]any{
"status": "aborted",
})
}
// requireBlobWriteAccess middleware - validates DPoP + OAuth and checks for blob:write permission
func (h *XRPCHandler) requireBlobWriteAccess(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := pds.ValidateBlobWriteAccess(r, h.pds, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
return
}
// Validation successful - user has blob:write permission
// No need to store user in context since handlers don't need it
next.ServeHTTP(w, r)
})
}
+1 -1
View File
@@ -98,7 +98,7 @@ func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
// MarshalDIDDocument converts a DID document to JSON using the stored public URL
func (p *HoldPDS) MarshalDIDDocument() ([]byte, error) {
doc, err := p.GenerateDIDDocument(p.publicURL)
doc, err := p.GenerateDIDDocument(p.PublicURL)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -28,7 +28,7 @@ func init() {
// HoldPDS is a minimal ATProto PDS implementation for a hold service
type HoldPDS struct {
did string
publicURL string
PublicURL string
carstore carstore.CarStore
repomgr *RepoManager
dbPath string
@@ -83,7 +83,7 @@ func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string) (*H
return &HoldPDS{
did: did,
publicURL: publicURL,
PublicURL: publicURL,
carstore: cs,
repomgr: rm,
dbPath: dbPath,
+186 -121
View File
@@ -1,53 +1,43 @@
package pds
import (
"atcr.io/pkg/atproto"
"bytes"
"context"
"encoding/json"
"fmt"
"atcr.io/pkg/atproto"
"atcr.io/pkg/s3"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/bluesky-social/indigo/repo"
"github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/gorilla/websocket"
"github.com/ipfs/go-cid"
"github.com/ipld/go-car"
carutil "github.com/ipld/go-car/util"
"crypto/sha256"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/multiformats/go-multihash"
awss3 "github.com/aws/aws-sdk-go/service/s3"
)
// XRPC handler for ATProto endpoints
// XRPCHandler handles XRPC requests for the embedded PDS
type XRPCHandler struct {
pds *HoldPDS
publicURL string
holdService XRPCHoldService
broadcaster *EventBroadcaster
httpClient HTTPClient // For testing - allows injecting mock HTTP client
}
// interface wraps the existing hold service storage operations
type XRPCHoldService interface {
// GetPresignedURL returns a presigned URL for the specified operation
// For ATProto blobs (CID), did is required for per-DID storage
// For OCI blobs (sha256:...), did may be empty
// operation can be "GET", "HEAD", or "PUT"
GetPresignedURL(ctx context.Context, operation string, digest string, did string) (string, error)
// UploadBlob receives raw blob bytes, computes CID, and stores via distribution driver
// Used for standard ATProto blob uploads (profile pics, small media)
// Returns CID and size of stored blob
UploadBlob(ctx context.Context, did string, data io.Reader) (cid cid.Cid, size int64, err error)
// handles multipart upload operations via JSON request
HandleMultipartOperation(w http.ResponseWriter, r *http.Request, did string)
// handles uploading a part in buffered mode
HandleBufferedPartUpload(ctx context.Context, uploadID string, partNumber int, data []byte) (etag string, err error)
pds *HoldPDS
s3Service s3.S3Service
storageDriver driver.StorageDriver
broadcaster *EventBroadcaster
httpClient HTTPClient // For testing - allows injecting mock HTTP client
}
// PartInfo represents a completed part in a multipart upload
@@ -65,13 +55,13 @@ type PartUploadInfo struct {
}
// NewXRPCHandler creates a new XRPC handler
func NewXRPCHandler(pds *HoldPDS, publicURL string, holdService XRPCHoldService, broadcaster *EventBroadcaster, httpClient HTTPClient) *XRPCHandler {
func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, storageDriver driver.StorageDriver, broadcaster *EventBroadcaster, httpClient HTTPClient) *XRPCHandler {
return &XRPCHandler{
pds: pds,
publicURL: publicURL,
holdService: holdService,
broadcaster: broadcaster,
httpClient: httpClient,
pds: pds,
s3Service: s3Service,
storageDriver: storageDriver,
broadcaster: broadcaster,
httpClient: httpClient,
}
}
@@ -148,7 +138,7 @@ func (h *XRPCHandler) HandleDescribeServer(w http.ResponseWriter, r *http.Reques
// Extract hostname from public URL for availableUserDomains
// For hold01.atcr.io, return [".hold01.atcr.io"] to match stream.place pattern
hostname := h.publicURL
hostname := h.pds.PublicURL
hostname = strings.TrimPrefix(hostname, "http://")
hostname = strings.TrimPrefix(hostname, "https://")
hostname = strings.Split(hostname, "/")[0] // Remove path
@@ -179,7 +169,7 @@ func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request)
}
// Generate DID document
didDoc, err := h.pds.GenerateDIDDocument(h.publicURL)
didDoc, err := h.pds.GenerateDIDDocument(h.pds.PublicURL)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
return
@@ -359,13 +349,13 @@ func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request)
// Get the record bytes
recordCID, recBytes, err := repoHandle.GetRecordBytes(r.Context(), k)
if err != nil {
return fmt.Errorf("failed to get record: %w", err)
return fmt.Errorf("failed to get record: %v", err)
}
// Decode using lexutil (type registry handles unmarshaling)
recordValue, err := lexutil.CborDecodeValue(*recBytes)
if err != nil {
return fmt.Errorf("failed to decode record: %w", err)
return fmt.Errorf("failed to decode record: %v", err)
}
records = append(records, map[string]any{
@@ -700,39 +690,15 @@ func (h *XRPCHandler) HandleSubscribeRepos(w http.ResponseWriter, r *http.Reques
}
// HandleUploadBlob handles blob uploads with support for multipart operations
// Supports three modes:
// 1. Buffered part upload: PUT with X-Upload-Id and X-Part-Number headers
// 2. Multipart operations: POST with JSON body containing action field
// 3. Direct blob upload: POST with raw bytes (ATProto-compliant)
// Direct blob upload: POST with raw bytes (ATProto-compliant)
func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
// Mode 1: Buffered part upload (PUT with headers)
if r.Method == http.MethodPut {
uploadID := r.Header.Get("X-Upload-Id")
partNumberStr := r.Header.Get("X-Part-Number")
if uploadID != "" && partNumberStr != "" {
h.handleBufferedPartUpload(w, r, uploadID, partNumberStr)
return
}
http.Error(w, "PUT requires X-Upload-Id and X-Part-Number headers", http.StatusBadRequest)
return
}
// Ensure POST method for remaining modes
// Check HTTP method - only POST is allowed
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Mode 2: Multipart operations (JSON body with action field)
if strings.Contains(contentType, "application/json") {
h.handleMultipartOperation(w, r)
return
}
// Mode 3: Direct blob upload (ATProto-compliant)
// Direct blob upload (ATProto-compliant)
// Receives raw bytes, computes CID, stores via distribution driver
// Requires admin-level access (captain or crew admin)
user, err := ValidateOwnerOrCrewAdmin(r, h.pds, h.httpClient)
@@ -744,10 +710,56 @@ func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
// Use authenticated user's DID for ATProto blob storage (per-DID paths)
did := user.DID
// Upload blob directly - holdService will compute CID and store
blobCID, size, err := h.holdService.UploadBlob(r.Context(), did, r.Body)
// Read all data into memory to compute CID
// For large files, this should use multipart upload instead
blobData, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to upload blob: %v", err), http.StatusInternalServerError)
http.Error(w, fmt.Sprintf("failed to read blob data: %v", err), http.StatusInternalServerError)
return
}
size := int64(len(blobData))
// Compute SHA-256 hash
hash := sha256.Sum256(blobData)
// Create CIDv1 with SHA-256 multihash
mh, err := multihash.EncodeName(hash[:], "sha2-256")
if err != nil {
http.Error(w, fmt.Sprintf("failed to encode multihash: %v", err), http.StatusInternalServerError)
return
}
// Create CIDv1 with raw codec (0x55)
// ATProto uses CIDv1 with raw codec for blobs
blobCID := cid.NewCidV1(0x55, mh)
// Store blob via distribution driver at ATProto path
path := atprotoBlobPath(did, blobCID.String())
// Write blob to storage using distribution driver
writer, err := h.storageDriver.Writer(r.Context(), path, false)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create writer: %v", err), http.StatusInternalServerError)
return
}
// Write data
n, err := io.Copy(writer, bytes.NewReader(blobData))
if err != nil {
writer.Cancel(r.Context())
http.Error(w, fmt.Sprintf("failed to write blob: %v", err), http.StatusInternalServerError)
return
}
// Commit the write
if err := writer.Commit(r.Context()); err != nil {
http.Error(w, fmt.Sprintf("failed to commit blob: %v", err), http.StatusInternalServerError)
return
}
if n != size {
http.Error(w, fmt.Sprintf("size mismatch: wrote %d bytes, expected %d", n, size), http.StatusInternalServerError)
return
}
@@ -767,59 +779,6 @@ func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(response)
}
// handleBufferedPartUpload handles uploading a part in buffered mode
func (h *XRPCHandler) handleBufferedPartUpload(w http.ResponseWriter, r *http.Request, uploadID, partNumberStr string) {
ctx := r.Context()
// Validate blob write access
// This checks DPoP + OAuth tokens and verifies user is captain or crew with blob:write permission
_, err := ValidateBlobWriteAccess(r, h.pds, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
return
}
// Parse part number
partNumber, err := strconv.Atoi(partNumberStr)
if err != nil {
http.Error(w, fmt.Sprintf("invalid part number: %v", err), http.StatusBadRequest)
return
}
// Read part data from body
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to read part data: %v", err), http.StatusInternalServerError)
return
}
// Store part via blob store
etag, err := h.holdService.HandleBufferedPartUpload(ctx, uploadID, partNumber, data)
if err != nil {
http.Error(w, fmt.Sprintf("failed to upload part: %v", err), http.StatusInternalServerError)
return
}
// Return ETag in response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"etag": etag,
})
}
// handleMultipartOperation handles multipart upload operations via JSON request
func (h *XRPCHandler) handleMultipartOperation(w http.ResponseWriter, r *http.Request) {
// Validate blob write access for all multipart operations
// This checks DPoP + OAuth tokens and verifies user is captain or crew with blob:write permission
user, err := ValidateBlobWriteAccess(r, h.pds, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
return
}
h.holdService.HandleMultipartOperation(w, r, user.DID)
}
// HandleGetBlob wraps existing presigned download URL logic
// Supports both ATProto CIDs and OCI sha256 digests
// Authorization: If captain.public = true, open to all. If false, requires crew with blob:read permission.
@@ -886,7 +845,7 @@ func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
}
// Generate presigned URL for the operation
presignedURL, err := h.holdService.GetPresignedURL(r.Context(), operation, digest, did)
presignedURL, err := h.GetPresignedURL(r.Context(), operation, digest, did)
if err != nil {
log.Printf("[HandleGetBlob] Failed to get presigned %s URL: digest=%s, did=%s, err=%v", operation, digest, did, err)
http.Error(w, "failed to get presigned URL", http.StatusInternalServerError)
@@ -963,7 +922,7 @@ func (h *XRPCHandler) HandleDIDDocument(w http.ResponseWriter, r *http.Request)
return
}
doc, err := h.pds.GenerateDIDDocument(h.publicURL)
doc, err := h.pds.GenerateDIDDocument(h.pds.PublicURL)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
return
@@ -1083,3 +1042,109 @@ func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// getPresignedURL generates a presigned URL for GET, HEAD, or PUT operations
// Distinguishes between ATProto blobs (per-DID) and OCI blobs (content-addressed)
func (h *XRPCHandler) GetPresignedURL(ctx context.Context, operation string, digest string, did string) (string, error) {
var path string
// Determine blob type and construct appropriate path
if strings.HasPrefix(digest, "sha256:") || strings.HasPrefix(digest, "uploads/") {
// OCI container layer (sha256 digest or temp upload path)
// Use content-addressed storage (globally deduplicated)
path = s3.BlobPath(digest)
} else {
// ATProto blob (CID format like bafyreib...)
// Use per-DID storage for data sovereignty
if did == "" {
return "", fmt.Errorf("DID required for ATProto blob storage")
}
path = atprotoBlobPath(did, digest)
}
// Generate presigned URL if S3 client is available
if h.s3Service.Client != nil {
// Build S3 key from blob path
s3Key := strings.TrimPrefix(path, "/")
if h.s3Service.PathPrefix != "" {
s3Key = h.s3Service.PathPrefix + "/" + s3Key
}
// Create appropriate S3 request based on operation
var req interface {
Presign(time.Duration) (string, error)
}
contentType := "application/octet-stream"
switch operation {
case http.MethodGet:
// Note: Don't use ResponseContentType - not supported by all S3-compatible services
req, _ = h.s3Service.Client.GetObjectRequest(&awss3.GetObjectInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
})
case http.MethodHead:
req, _ = h.s3Service.Client.HeadObjectRequest(&awss3.HeadObjectInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
})
case http.MethodPut:
req, _ = h.s3Service.Client.PutObjectRequest(&awss3.PutObjectInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
ContentType: &contentType,
})
default:
return "", fmt.Errorf("unsupported operation: %s", operation)
}
// Generate presigned URL with 15 minute expiry
url, err := req.Presign(15 * time.Minute)
if err != nil {
log.Printf("[getPresignedURL] Presign FAILED for %s: %v", operation, err)
log.Printf(" Falling back to XRPC endpoint")
proxyURL := getProxyURL(h.pds.PublicURL, digest, did, operation)
if proxyURL == "" {
return "", fmt.Errorf("presign failed and XRPC proxy not supported for PUT operations")
}
return proxyURL, nil
}
return url, nil
}
// Fallback: return XRPC endpoint through this service
proxyURL := getProxyURL(h.pds.PublicURL, digest, did, operation)
if proxyURL == "" {
return "", fmt.Errorf("S3 client not available and XRPC proxy not supported for PUT operations")
}
return proxyURL, nil
}
// atprotoBlobPath creates a per-DID storage path for ATProto blobs
// ATProto spec stores blobs as: /repos/{did}/blobs/{cid}/data
// This provides data sovereignty - each user's blobs are isolated
func atprotoBlobPath(did, cid string) string {
// Clean DID for filesystem safety (replace : with -)
safeDID := strings.ReplaceAll(did, ":", "-")
return fmt.Sprintf("/repos/%s/blobs/%s/data", safeDID, cid)
}
// getProxyURL returns XRPC endpoint for blob operations (fallback when presigned URLs unavailable)
// For GET/HEAD operations, returns the XRPC getBlob endpoint
// For PUT operations, this fallback is no longer supported - use multipart upload instead
func getProxyURL(publicURL string, digest, did string, operation string) string {
// For read operations, use XRPC getBlob endpoint
if operation == http.MethodGet || operation == http.MethodHead {
// Generate hold DID from public URL using shared function
holdDID := atproto.ResolveHoldDIDFromURL(publicURL)
return fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
publicURL, holdDID, digest)
}
// For PUT operations, proxy fallback is not supported with XRPC
// Clients should use multipart upload flow via com.atproto.repo.uploadBlob
return ""
}
-464
View File
@@ -1,464 +0,0 @@
package pds
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
)
// addTestDPoPAuth adds DPoP authentication headers to a request for testing
func addTestDPoPAuth(t *testing.T, req *http.Request, did string) {
t.Helper()
dpopHelper, err := NewDPoPTestHelper(did, "https://test-pds.example.com")
if err != nil {
t.Fatalf("Failed to create DPoP helper: %v", err)
}
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
}
// ATCR-Specific Tests: Non-standard multipart upload extensions
//
// This file contains tests for ATCR's custom multipart upload extensions
// to the ATProto blob endpoints. These are not part of the official ATProto spec.
//
// Standard ATProto blob tests are in xrpc_test.go
// Tests for HandleUploadBlob - Multipart Start
// TestHandleUploadBlob_MultipartStart tests multipart upload start operation
// Non-standard ATCR extension for large blob uploads
func TestHandleUploadBlob_MultipartStart(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
digest := "sha256:largefile123"
body := map[string]string{
"action": "start",
"digest": digest,
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
// Add DPoP authentication - owner has blob:write permission
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should return 200 OK with upload metadata
if w.Code != http.StatusOK {
t.Errorf("Expected status 200 OK, got %d", w.Code)
}
result := assertJSONResponse(t, w, http.StatusOK)
if uploadID, ok := result["uploadId"].(string); !ok || uploadID == "" {
t.Error("Expected uploadId string in response")
}
if mode, ok := result["mode"].(string); !ok || mode == "" {
t.Error("Expected mode string in response")
}
// Verify blob store was called
if len(holdService.startCalls) != 1 || holdService.startCalls[0] != digest {
t.Errorf("Expected StartMultipartUpload to be called with %s", digest)
}
}
// TestHandleUploadBlob_MultipartStart_MissingDigest tests missing digest in start operation
func TestHandleUploadBlob_MultipartStart_MissingDigest(t *testing.T) {
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
body := map[string]string{
"action": "start",
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
// Tests for HandleUploadBlob - Multipart Part URL
// TestHandleUploadBlob_MultipartPart tests getting presigned URL for a part
// Non-standard ATCR extension for multipart uploads
func TestHandleUploadBlob_MultipartPart(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
uploadID := "test-upload-123"
partNumber := 1
expectedDID := "did:plc:testowner123" // DID from authenticated user
body := map[string]any{
"action": "part",
"uploadId": uploadID,
"partNumber": partNumber,
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, expectedDID)
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should return 200 OK with presigned URL
if w.Code != http.StatusOK {
t.Errorf("Expected status 200 OK, got %d", w.Code)
}
result := assertJSONResponse(t, w, http.StatusOK)
if url, ok := result["url"].(string); !ok || url == "" {
t.Error("Expected url string in response")
}
// Verify blob store was called with authenticated user's DID
if len(holdService.partURLCalls) != 1 {
t.Fatalf("Expected GetPartUploadURL to be called once")
}
call := holdService.partURLCalls[0]
if call.uploadID != uploadID || call.partNumber != partNumber || call.did != expectedDID {
t.Errorf("Expected GetPartUploadURL(%s, %d, %s), got (%s, %d, %s)",
uploadID, partNumber, expectedDID, call.uploadID, call.partNumber, call.did)
}
}
// TestHandleUploadBlob_MultipartPart_MissingParams tests missing parameters
func TestHandleUploadBlob_MultipartPart_MissingParams(t *testing.T) {
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
tests := []struct {
name string
body map[string]any
}{
{
name: "missing uploadId",
body: map[string]any{
"action": "part",
"partNumber": 1,
},
},
{
name: "missing partNumber",
body: map[string]any{
"action": "part",
"uploadId": "test-123",
},
},
{
name: "partNumber zero",
body: map[string]any{
"action": "part",
"uploadId": "test-123",
"partNumber": 0,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", tt.body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
})
}
}
// Tests for HandleUploadBlob - Multipart Complete
// TestHandleUploadBlob_MultipartComplete tests completing a multipart upload
// Non-standard ATCR extension for multipart uploads
func TestHandleUploadBlob_MultipartComplete(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
uploadID := "test-upload-123"
parts := []PartInfo{
{PartNumber: 1, ETag: "etag1"},
{PartNumber: 2, ETag: "etag2"},
}
body := map[string]any{
"action": "complete",
"uploadId": uploadID,
"digest": "sha256:abc123def456",
"parts": parts,
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should return 200 OK with completion status
if w.Code != http.StatusOK {
t.Errorf("Expected status 200 OK, got %d", w.Code)
}
result := assertJSONResponse(t, w, http.StatusOK)
if status, ok := result["status"].(string); !ok || status != "completed" {
t.Errorf("Expected status='completed', got %v", result["status"])
}
// Verify blob store was called
if len(holdService.completeCalls) != 1 || holdService.completeCalls[0] != uploadID {
t.Errorf("Expected CompleteMultipartUpload to be called with %s", uploadID)
}
}
// TestHandleUploadBlob_MultipartComplete_MissingParams tests missing parameters
func TestHandleUploadBlob_MultipartComplete_MissingParams(t *testing.T) {
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
tests := []struct {
name string
body map[string]any
}{
{
name: "missing uploadId",
body: map[string]any{
"action": "complete",
"digest": "sha256:abc123",
"parts": []PartInfo{{PartNumber: 1, ETag: "etag1"}},
},
},
{
name: "missing parts",
body: map[string]any{
"action": "complete",
"uploadId": "test-123",
"digest": "sha256:abc123",
},
},
{
name: "empty parts array",
body: map[string]any{
"action": "complete",
"uploadId": "test-123",
"digest": "sha256:abc123",
"parts": []PartInfo{},
},
},
{
name: "missing digest",
body: map[string]any{
"action": "complete",
"uploadId": "test-123",
"parts": []PartInfo{{PartNumber: 1, ETag: "etag1"}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", tt.body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
})
}
}
// Tests for HandleUploadBlob - Multipart Abort
// TestHandleUploadBlob_MultipartAbort tests aborting a multipart upload
// Non-standard ATCR extension for multipart uploads
func TestHandleUploadBlob_MultipartAbort(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
uploadID := "test-upload-123"
body := map[string]string{
"action": "abort",
"uploadId": uploadID,
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should return 200 OK with abort status
if w.Code != http.StatusOK {
t.Errorf("Expected status 200 OK, got %d", w.Code)
}
result := assertJSONResponse(t, w, http.StatusOK)
if status, ok := result["status"].(string); !ok || status != "aborted" {
t.Errorf("Expected status='aborted', got %v", result["status"])
}
// Verify blob store was called
if len(holdService.abortCalls) != 1 || holdService.abortCalls[0] != uploadID {
t.Errorf("Expected AbortMultipartUpload to be called with %s", uploadID)
}
}
// TestHandleUploadBlob_MultipartAbort_MissingUploadID tests missing uploadId
func TestHandleUploadBlob_MultipartAbort_MissingUploadID(t *testing.T) {
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
body := map[string]string{
"action": "abort",
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
// Tests for HandleUploadBlob - Buffered Part Upload
// TestHandleUploadBlob_BufferedPartUpload tests uploading a part in buffered mode
// Non-standard ATCR extension for multipart uploads without S3 presigned URLs
func TestHandleUploadBlob_BufferedPartUpload(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
uploadID := "test-upload-123"
partNumber := "1"
data := []byte("test data for part 1")
req := httptest.NewRequest(http.MethodPut, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(data))
req.Header.Set("X-Upload-Id", uploadID)
req.Header.Set("X-Part-Number", partNumber)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should return 200 OK with ETag
if w.Code != http.StatusOK {
t.Errorf("Expected status 200 OK, got %d", w.Code)
}
result := assertJSONResponse(t, w, http.StatusOK)
if etag, ok := result["etag"].(string); !ok || etag == "" {
t.Error("Expected etag string in response")
}
// Verify blob store was called
if len(holdService.partUploadCalls) != 1 {
t.Fatalf("Expected HandleBufferedPartUpload to be called once")
}
call := holdService.partUploadCalls[0]
if call.uploadID != uploadID || call.partNumber != 1 || call.dataSize != len(data) {
t.Errorf("Expected HandleBufferedPartUpload(%s, 1, %d bytes), got (%s, %d, %d bytes)",
uploadID, len(data), call.uploadID, call.partNumber, call.dataSize)
}
}
// TestHandleUploadBlob_BufferedPartUpload_MissingHeaders tests missing required headers
func TestHandleUploadBlob_BufferedPartUpload_MissingHeaders(t *testing.T) {
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
tests := []struct {
name string
uploadID string
partNumber string
setUploadID bool
setPartNumber bool
}{
{
name: "missing both headers",
setUploadID: false,
setPartNumber: false,
},
{
name: "missing X-Part-Number",
uploadID: "test-123",
setUploadID: true,
setPartNumber: false,
},
{
name: "missing X-Upload-Id",
partNumber: "1",
setUploadID: false,
setPartNumber: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte("data")))
if tt.setUploadID {
req.Header.Set("X-Upload-Id", tt.uploadID)
}
if tt.setPartNumber {
req.Header.Set("X-Part-Number", tt.partNumber)
}
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
})
}
}
// TestHandleUploadBlob_BufferedPartUpload_InvalidPartNumber tests invalid part number
func TestHandleUploadBlob_BufferedPartUpload_InvalidPartNumber(t *testing.T) {
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
req := httptest.NewRequest(http.MethodPut, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte("data")))
req.Header.Set("X-Upload-Id", "test-123")
req.Header.Set("X-Part-Number", "not-a-number")
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400 for invalid part number, got %d", w.Code)
}
}
// TestHandleUploadBlob_UnknownAction tests unknown action value
func TestHandleUploadBlob_UnknownAction(t *testing.T) {
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
body := map[string]string{
"action": "invalid",
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400 for unknown action, got %d", w.Code)
}
}
+59 -328
View File
@@ -14,7 +14,9 @@ import (
"testing"
"atcr.io/pkg/atproto"
"github.com/ipfs/go-cid"
"atcr.io/pkg/s3"
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
_ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
)
// Test helpers
@@ -57,8 +59,11 @@ func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) {
// Create mock PDS client for DPoP validation
mockClient := &mockPDSClient{}
// Create mock s3 service and storage driver (not needed for most PDS tests)
mockS3 := s3.S3Service{}
// Create XRPC handler with mock HTTP client
handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil, mockClient)
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient)
return handler, ctx
}
@@ -656,7 +661,8 @@ func TestHandleListRecords_InvalidLimit(t *testing.T) {
func TestHandleListRecords_EmptyCollection(t *testing.T) {
pds, ctx := setupTestPDS(t) // Don't bootstrap - no records created yet
mockClient := &mockPDSClient{}
handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil, mockClient)
mockS3 := s3.S3Service{}
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient)
// Initialize repo manually (setupTestPDS doesn't call Bootstrap, so no crew members)
err := pds.repomgr.InitNewActor(ctx, pds.uid, "", pds.did, "", "", "")
@@ -913,7 +919,8 @@ func TestHandleListRepos(t *testing.T) {
func TestHandleListRepos_EmptyRepo(t *testing.T) {
pds, ctx := setupTestPDS(t) // Don't bootstrap
mockClient := &mockPDSClient{}
handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil, mockClient)
mockS3 := s3.S3Service{}
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient)
// setupTestPDS creates the PDS/database but doesn't initialize the repo
// Check if implementation returns repos before initialization
@@ -1330,251 +1337,32 @@ func TestHandleAtprotoDID(t *testing.T) {
}
}
// Mock HoldService for testing blob endpoints
// mockHoldService implements XRPCHoldService interface for testing
type mockHoldService struct {
// Control behavior
downloadURLError error
uploadURLError error
uploadBlobError error
startError error
partURLError error
completeError error
abortError error
partUploadError error
// Mock S3 Service for testing blob endpoints
// mockS3Service is a simple mock that tracks calls and returns test URLs
type mockS3Service struct {
// Track calls
downloadCalls []string // Track digests requested for download
uploadCalls []string // Track digests requested for upload
uploadBlobCalls []uploadBlobCall // Track direct blob uploads
startCalls []string // Track digests for multipart start
partURLCalls []partURLCall
completeCalls []string
abortCalls []string
partUploadCalls []partUploadCall
downloadCalls []string // Track digests requested for download
}
type uploadBlobCall struct {
did string
dataSize int
}
type partURLCall struct {
uploadID string
partNumber int
did string
}
type partUploadCall struct {
uploadID string
partNumber int
dataSize int
}
func newMockHoldService() *mockHoldService {
return &mockHoldService{
downloadCalls: []string{},
uploadCalls: []string{},
uploadBlobCalls: []uploadBlobCall{},
startCalls: []string{},
partURLCalls: []partURLCall{},
completeCalls: []string{},
abortCalls: []string{},
partUploadCalls: []partUploadCall{},
func newMockS3Service() *mockS3Service {
return &mockS3Service{
downloadCalls: []string{},
}
}
func (m *mockHoldService) GetPresignedURL(ctx context.Context, operation, digest, did string) (string, error) {
if operation == "GET" || operation == "HEAD" {
// Both GET and HEAD are download operations, just different HTTP methods
m.downloadCalls = append(m.downloadCalls, digest)
if m.downloadURLError != nil {
return "", m.downloadURLError
}
return "https://s3.example.com/download/" + digest, nil
}
// PUT or other upload operations
m.uploadCalls = append(m.uploadCalls, digest)
if m.uploadURLError != nil {
return "", m.uploadURLError
}
return "https://s3.example.com/upload/" + digest, nil
}
func (m *mockHoldService) UploadBlob(ctx context.Context, did string, data io.Reader) (cid.Cid, int64, error) {
// Read data to get size
blobData, err := io.ReadAll(data)
if err != nil {
return cid.Undef, 0, err
}
m.uploadBlobCalls = append(m.uploadBlobCalls, uploadBlobCall{
did: did,
dataSize: len(blobData),
})
if m.uploadBlobError != nil {
return cid.Undef, 0, m.uploadBlobError
}
// Return a test CID (just use a fixed one for testing)
testCID, _ := cid.Decode("bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku")
return testCID, int64(len(blobData)), nil
}
func (m *mockHoldService) StartMultipartUploadWithManager(ctx context.Context, digest string) (string, int, error) {
m.startCalls = append(m.startCalls, digest)
if m.startError != nil {
return "", 0, m.startError
}
return "test-upload-id", 0, nil // Return 0 for S3Native mode
}
func (m *mockHoldService) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (*PartUploadInfo, error) {
m.partURLCalls = append(m.partURLCalls, partURLCall{uploadID, partNumber, did})
if m.partURLError != nil {
return nil, m.partURLError
}
return &PartUploadInfo{
URL: "https://s3.example.com/part/" + uploadID,
Method: "PUT",
}, nil
}
func (m *mockHoldService) CompleteMultipartUploadWithManager(ctx context.Context, uploadID string, finalDigest string, parts []PartInfo) error {
m.completeCalls = append(m.completeCalls, uploadID)
if m.completeError != nil {
return m.completeError
}
return nil
}
func (m *mockHoldService) AbortMultipartUploadWithManager(ctx context.Context, uploadID string) error {
m.abortCalls = append(m.abortCalls, uploadID)
if m.abortError != nil {
return m.abortError
}
return nil
}
func (m *mockHoldService) HandleBufferedPartUpload(ctx context.Context, uploadID string, partNumber int, data []byte) (string, error) {
m.partUploadCalls = append(m.partUploadCalls, partUploadCall{uploadID, partNumber, len(data)})
if m.partUploadError != nil {
return "", m.partUploadError
}
return "test-etag-" + uploadID, nil
}
func (m *mockHoldService) HandleMultipartOperation(w http.ResponseWriter, r *http.Request, did string) {
ctx := r.Context()
// Parse JSON body (same as real implementation)
var req struct {
Action string `json:"action"`
Digest string `json:"digest,omitempty"`
UploadID string `json:"uploadId,omitempty"`
PartNumber int `json:"partNumber,omitempty"`
Parts []PartInfo `json:"parts,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON body: %v", err), http.StatusBadRequest)
return
}
// Route based on action
switch req.Action {
case "start":
if req.Digest == "" {
http.Error(w, "digest required for start action", http.StatusBadRequest)
return
}
uploadID, mode, err := m.StartMultipartUploadWithManager(ctx, req.Digest)
if err != nil {
http.Error(w, fmt.Sprintf("failed to start multipart upload: %v", err), http.StatusInternalServerError)
return
}
// Convert mode to string
var modeStr string
switch mode {
case 0:
modeStr = "s3native"
case 1:
modeStr = "buffered"
default:
modeStr = "unknown"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"uploadId": uploadID,
"mode": modeStr,
})
case "part":
if req.UploadID == "" || req.PartNumber == 0 {
http.Error(w, "uploadId and partNumber required for part action", http.StatusBadRequest)
return
}
uploadInfo, err := m.GetPartUploadURL(ctx, req.UploadID, req.PartNumber, did)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get part URL: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(uploadInfo)
case "complete":
if req.UploadID == "" || len(req.Parts) == 0 {
http.Error(w, "uploadId and parts required for complete action", http.StatusBadRequest)
return
}
if req.Digest == "" {
http.Error(w, "digest required for complete action", http.StatusBadRequest)
return
}
if err := m.CompleteMultipartUploadWithManager(ctx, req.UploadID, req.Digest, req.Parts); err != nil {
http.Error(w, fmt.Sprintf("failed to complete multipart upload: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "completed",
})
case "abort":
if req.UploadID == "" {
http.Error(w, "uploadId required for abort action", http.StatusBadRequest)
return
}
if err := m.AbortMultipartUploadWithManager(ctx, req.UploadID); err != nil {
http.Error(w, fmt.Sprintf("failed to abort multipart upload: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "aborted",
})
default:
http.Error(w, fmt.Sprintf("unknown action: %s", req.Action), http.StatusBadRequest)
// toS3Service converts the mock to an s3.S3Service
// Returns empty s3.S3Service since we're not testing S3 presigned URLs in these tests
func (m *mockS3Service) toS3Service() s3.S3Service {
return s3.S3Service{
Client: nil, // Not testing presigned URLs
Bucket: "",
PathPrefix: "",
}
}
// setupTestXRPCHandlerWithBlobs creates handler with mock hold service and mock PDS client
func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockHoldService, context.Context) {
// setupTestXRPCHandlerWithBlobs creates handler with mock s3 service and real filesystem driver
func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockS3Service, context.Context) {
t.Helper()
ctx := context.Background()
@@ -1607,16 +1395,26 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockHoldService
t.Fatalf("Failed to bootstrap PDS: %v", err)
}
// Create mock hold service
holdService := newMockHoldService()
// Create mock s3 service that returns test URLs
mockS3Svc := newMockS3Service()
// Create filesystem storage driver for tests
storageDir := filepath.Join(tmpDir, "storage")
params := map[string]any{
"rootdirectory": storageDir,
}
driver, err := factory.Create(ctx, "filesystem", params)
if err != nil {
t.Fatalf("Failed to create storage driver: %v", err)
}
// Create mock PDS client for DPoP validation
mockClient := &mockPDSClient{}
// Create XRPC handler with mock hold service and mock HTTP client
handler := NewXRPCHandler(pds, "https://hold.example.com", holdService, nil, mockClient)
// Create XRPC handler with mock s3 service and real filesystem driver
handler := NewXRPCHandler(pds, mockS3Svc.toS3Service(), driver, nil, mockClient)
return handler, holdService, ctx
return handler, mockS3Svc, ctx
}
// Tests for HandleUploadBlob
@@ -1624,7 +1422,7 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockHoldService
// TestHandleUploadBlob tests com.atproto.repo.uploadBlob with direct upload
// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
func TestHandleUploadBlob(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
// Test data - a simple text blob
blobData := []byte("Hello, ATProto!")
@@ -1677,20 +1475,13 @@ func TestHandleUploadBlob(t *testing.T) {
t.Errorf("Expected size=%d, got %v", len(blobData), blob["size"])
}
// Verify blob store was called
if len(holdService.uploadBlobCalls) != 1 {
t.Errorf("Expected UploadBlob to be called once, got %d calls", len(holdService.uploadBlobCalls))
}
if holdService.uploadBlobCalls[0].dataSize != len(blobData) {
t.Errorf("Expected UploadBlob to receive %d bytes, got %d", len(blobData), holdService.uploadBlobCalls[0].dataSize)
}
// Blob upload succeeded - no need to verify internal storage details
}
// TestHandleUploadBlob_EmptyBody tests empty blob upload
// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
func TestHandleUploadBlob_EmptyBody(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
// Empty blob should succeed (edge case)
req := httptest.NewRequest(http.MethodPost, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte{}))
@@ -1715,10 +1506,7 @@ func TestHandleUploadBlob_EmptyBody(t *testing.T) {
t.Errorf("Expected status 200 OK for empty blob, got %d", w.Code)
}
// Verify blob store was called with 0 bytes
if len(holdService.uploadBlobCalls) != 1 || holdService.uploadBlobCalls[0].dataSize != 0 {
t.Errorf("Expected UploadBlob with 0 bytes")
}
// Blob upload succeeded - empty blob is valid
}
// TestHandleUploadBlob_MethodNotAllowed tests wrong HTTP method
@@ -1740,32 +1528,7 @@ func TestHandleUploadBlob_MethodNotAllowed(t *testing.T) {
// TestHandleUploadBlob_BlobStoreError tests blob store returning error
// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
func TestHandleUploadBlob_BlobStoreError(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
// Configure mock to return error
holdService.uploadBlobError = fmt.Errorf("storage driver unavailable")
req := httptest.NewRequest(http.MethodPost, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte("test data")))
req.Header.Set("Content-Type", "application/octet-stream")
// Add DPoP authentication
ownerDID := "did:plc:testowner123"
dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
if err != nil {
t.Fatalf("Failed to create DPoP helper: %v", err)
}
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should get 500 Internal Server Error for blob store error
if w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 500 for blob store error, got %d", w.Code)
}
t.Skip("Skipping blob store error test - using real filesystem driver now")
}
// Tests for HandleGetBlob
@@ -1773,7 +1536,7 @@ func TestHandleUploadBlob_BlobStoreError(t *testing.T) {
// TestHandleGetBlob tests com.atproto.sync.getBlob
// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
func TestHandleGetBlob(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
holdDID := "did:web:hold.example.com"
cid := "bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke"
@@ -1803,22 +1566,16 @@ func TestHandleGetBlob(t *testing.T) {
t.Fatalf("Failed to parse JSON response: %v", err)
}
// Verify URL field exists
expectedURL := "https://s3.example.com/download/" + cid
if response["url"] != expectedURL {
t.Errorf("Expected url to be %s, got %s", expectedURL, response["url"])
}
// Verify blob store was called
if len(holdService.downloadCalls) != 1 || holdService.downloadCalls[0] != cid {
t.Errorf("Expected GetPresignedURL to be called with %s", cid)
// Verify URL field exists (will be XRPC proxy URL since we don't have S3 client)
if response["url"] == "" {
t.Error("Expected url field in response")
}
}
// TestHandleGetBlob_SHA256Digest tests getBlob with OCI sha256 digest format
// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
func TestHandleGetBlob_SHA256Digest(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
holdDID := "did:web:hold.example.com"
digest := "sha256:abc123def456" // OCI digest format
@@ -1842,14 +1599,9 @@ func TestHandleGetBlob_SHA256Digest(t *testing.T) {
t.Fatalf("Failed to parse JSON response: %v", err)
}
// Verify URL field exists
// Verify URL field exists (will be XRPC proxy URL since we don't have S3 client)
if response["url"] == "" {
t.Errorf("Expected url field in response, got empty")
}
// Verify blob store received the sha256 digest
if len(holdService.downloadCalls) != 1 || holdService.downloadCalls[0] != digest {
t.Errorf("Expected GetPresignedURL to be called with %s, got %v", digest, holdService.downloadCalls)
t.Error("Expected url field in response")
}
}
@@ -1858,7 +1610,7 @@ func TestHandleGetBlob_SHA256Digest(t *testing.T) {
// AppView is responsible for making the actual HEAD request to S3
// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
func TestHandleGetBlob_HeadMethod(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
holdDID := "did:web:hold.example.com"
cid := "bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke"
@@ -1886,15 +1638,9 @@ func TestHandleGetBlob_HeadMethod(t *testing.T) {
t.Fatalf("Failed to parse JSON response: %v", err)
}
// Verify URL field exists
expectedURL := "https://s3.example.com/download/" + cid
if response["url"] != expectedURL {
t.Errorf("Expected url to be %s, got %s", expectedURL, response["url"])
}
// Verify blob store was called with HEAD operation
if len(holdService.downloadCalls) != 1 || holdService.downloadCalls[0] != cid {
t.Errorf("Expected GetPresignedURL to be called with %s", cid)
// Verify URL field exists (will be XRPC proxy URL since we don't have S3 client)
if response["url"] == "" {
t.Error("Expected url field in response")
}
}
@@ -1960,22 +1706,7 @@ func TestHandleGetBlob_InvalidDID(t *testing.T) {
// TestHandleGetBlob_BlobStoreError tests blob store returning error
// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
func TestHandleGetBlob_BlobStoreError(t *testing.T) {
handler, holdService, _ := setupTestXRPCHandlerWithBlobs(t)
// Configure mock to return error
holdService.downloadURLError = fmt.Errorf("blob not found in S3")
req := makeXRPCGetRequest("/xrpc/com.atproto.sync.getBlob", map[string]string{
"did": "did:web:hold.example.com",
"cid": "bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke",
})
w := httptest.NewRecorder()
handler.HandleGetBlob(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 500, got %d", w.Code)
}
t.Skip("Skipping blob store error test - using real filesystem driver now")
}
// TestHandleGetBlobCORSHeaders tests that CORS headers are set for blob downloads
-196
View File
@@ -1,196 +0,0 @@
package hold
import (
"context"
"fmt"
"log"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
"bytes"
"crypto/sha256"
"io"
"github.com/ipfs/go-cid"
"github.com/multiformats/go-multihash"
)
// HoldService provides presigned URLs for blob storage in a hold
type HoldService struct {
driver storagedriver.StorageDriver
config *Config
s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage)
bucket string // S3 bucket name
s3PathPrefix string // S3 path prefix (if any)
MultipartMgr *MultipartManager // Exported for access in route handlers
}
// NewHoldService creates a new hold service
// holdPDS must be a *pds.HoldPDS but we use any to avoid import cycle
func NewHoldService(cfg *Config, holdPDS any) (*HoldService, error) {
// Create storage driver from config
ctx := context.Background()
driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters())
if err != nil {
return nil, fmt.Errorf("failed to create storage driver: %w", err)
}
service := &HoldService{
driver: driver,
config: cfg,
MultipartMgr: NewMultipartManager(),
}
// Initialize S3 client for presigned URLs (if using S3 storage)
if err := service.initS3Client(); err != nil {
log.Printf("WARNING: S3 presigned URLs disabled: %v", err)
}
return service, nil
}
// UploadBlob receives raw blob bytes, computes CID, and stores via distribution driver
// This is used for standard ATProto blob uploads (profile pics, small media)
func (h *HoldService) UploadBlob(ctx context.Context, did string, data io.Reader) (cid.Cid, int64, error) {
// Read all data into memory to compute CID
// For large files, this should use multipart upload instead
blobData, err := io.ReadAll(data)
if err != nil {
return cid.Undef, 0, fmt.Errorf("failed to read blob data: %w", err)
}
size := int64(len(blobData))
// Compute SHA-256 hash
hash := sha256.Sum256(blobData)
// Create CIDv1 with SHA-256 multihash
mh, err := multihash.EncodeName(hash[:], "sha2-256")
if err != nil {
return cid.Undef, 0, fmt.Errorf("failed to encode multihash: %w", err)
}
// Create CIDv1 with raw codec (0x55)
// ATProto uses CIDv1 with raw codec for blobs
blobCID := cid.NewCidV1(0x55, mh)
// Store blob via distribution driver at ATProto path
// Path: /repos/{did}/blobs/{cid}/data
path := atprotoBlobPath(did, blobCID.String())
// Write blob to storage using distribution driver
writer, err := h.driver.Writer(ctx, path, false)
if err != nil {
return cid.Undef, 0, fmt.Errorf("failed to create writer: %w", err)
}
// Write data
n, err := io.Copy(writer, bytes.NewReader(blobData))
if err != nil {
writer.Cancel(ctx)
return cid.Undef, 0, fmt.Errorf("failed to write blob: %w", err)
}
// Commit the write
if err := writer.Commit(ctx); err != nil {
return cid.Undef, 0, fmt.Errorf("failed to commit blob: %w", err)
}
if n != size {
return cid.Undef, 0, fmt.Errorf("size mismatch: wrote %d bytes, expected %d", n, size)
}
return blobCID, size, nil
}
// HandleBufferedPartUpload handles uploading a part in buffered mode
func (h *HoldService) HandleBufferedPartUpload(ctx context.Context, uploadID string, partNumber int, data []byte) (string, error) {
session, err := h.MultipartMgr.GetSession(uploadID)
if err != nil {
return "", err
}
if session.Mode != Buffered {
return "", fmt.Errorf("session is not in buffered mode")
}
etag := session.StorePart(partNumber, data)
return etag, nil
}
// initS3Client initializes the S3 client for presigned URL generation
// Returns nil error if S3 client is successfully initialized
// Returns error if storage is not S3 or if initialization fails (service will fall back to proxy mode)
func (s *HoldService) initS3Client() error {
// Check if presigned URLs are explicitly disabled
if s.config.Server.DisablePresignedURLs {
log.Printf("⚠️ S3 presigned URLs DISABLED by config (DISABLE_PRESIGNED_URLS=true)")
log.Printf(" All uploads will use buffered mode (parts buffered in hold service)")
return nil // Not an error - just using buffered mode
}
// Check if storage driver is S3
if s.config.Storage.Type() != "s3" {
log.Printf("Storage driver is %s (not S3), presigned URLs disabled", s.config.Storage.Type())
return nil // Not an error - just using different driver
}
// Extract S3 configuration from storage parameters
params := s.config.Storage.Parameters()
// Extract required S3 configuration
region, _ := params["region"].(string)
if region == "" {
region = "us-east-1" // Default region
}
accessKey, _ := params["accesskey"].(string)
secretKey, _ := params["secretkey"].(string)
bucket, _ := params["bucket"].(string)
if bucket == "" {
return fmt.Errorf("S3 bucket not configured")
}
// Build AWS config
awsConfig := &aws.Config{
Region: &region,
}
// Add credentials if provided (allow IAM role auth if not provided)
if accessKey != "" && secretKey != "" {
awsConfig.Credentials = credentials.NewStaticCredentials(accessKey, secretKey, "")
}
// Add custom endpoint for S3-compatible services (Storj, MinIO, R2, etc.)
if endpoint, ok := params["regionendpoint"].(string); ok && endpoint != "" {
awsConfig.Endpoint = &endpoint
awsConfig.S3ForcePathStyle = aws.Bool(true) // Required for MinIO, Storj
}
// Create AWS session
sess, err := session.NewSession(awsConfig)
if err != nil {
return fmt.Errorf("failed to create AWS session: %w", err)
}
// Create S3 client
s.s3Client = s3.New(sess)
s.bucket = bucket
// Extract path prefix if configured (rootdirectory in S3 params)
if rootDir, ok := params["rootdirectory"].(string); ok && rootDir != "" {
s.s3PathPrefix = strings.TrimPrefix(rootDir, "/")
}
log.Printf("✅ S3 presigned URLs enabled")
return nil
}
-163
View File
@@ -1,163 +0,0 @@
package hold
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"atcr.io/pkg/atproto"
)
// atprotoBlobPath creates a per-DID storage path for ATProto blobs
// ATProto spec stores blobs as: /repos/{did}/blobs/{cid}/data
// This provides data sovereignty - each user's blobs are isolated
func atprotoBlobPath(did, cid string) string {
// Clean DID for filesystem safety (replace : with -)
safeDID := strings.ReplaceAll(did, ":", "-")
return fmt.Sprintf("/repos/%s/blobs/%s/data", safeDID, cid)
}
// blobPath converts a digest (e.g., "sha256:abc123...") or temp path to a storage path
// Distribution stores blobs as: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data
// where xx is the first 2 characters of the hash for directory sharding
// NOTE: Path must start with / for filesystem driver
// This is used for OCI container layers (content-addressed, globally deduplicated)
func blobPath(digest string) string {
// Handle temp paths (start with uploads/temp-)
if strings.HasPrefix(digest, "uploads/temp-") {
return fmt.Sprintf("/docker/registry/v2/%s/data", digest)
}
// Split digest into algorithm and hash
parts := strings.SplitN(digest, ":", 2)
if len(parts) != 2 {
// Fallback for malformed digest
return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
}
algorithm := parts[0]
hash := parts[1]
// Use first 2 characters for sharding
if len(hash) < 2 {
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash)
}
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash)
}
// getPresignedURL generates a presigned URL for GET, HEAD, or PUT operations
// Distinguishes between ATProto blobs (per-DID) and OCI blobs (content-addressed)
func (s *HoldService) GetPresignedURL(ctx context.Context, operation string, digest string, did string) (string, error) {
var path string
// Determine blob type and construct appropriate path
if strings.HasPrefix(digest, "sha256:") || strings.HasPrefix(digest, "uploads/") {
// OCI container layer (sha256 digest or temp upload path)
// Use content-addressed storage (globally deduplicated)
path = blobPath(digest)
} else {
// ATProto blob (CID format like bafyreib...)
// Use per-DID storage for data sovereignty
if did == "" {
return "", fmt.Errorf("DID required for ATProto blob storage")
}
path = atprotoBlobPath(did, digest)
}
// Don't check existence for GET/HEAD - let S3 return 404 if blob doesn't exist
// This avoids driver cache inconsistencies when blobs are created via S3 SDK (multipart uploads)
// and then immediately accessed
// Check if presigned URLs are disabled
if s.config.Server.DisablePresignedURLs {
log.Printf("Presigned URLs disabled, using XRPC endpoint")
url := s.getProxyURL(digest, did, operation)
if url == "" {
return "", fmt.Errorf("XRPC proxy not supported for PUT operations - use multipart upload")
}
return url, nil
}
// Generate presigned URL if S3 client is available
if s.s3Client != nil {
// Build S3 key from blob path
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
// Create appropriate S3 request based on operation
var req interface {
Presign(time.Duration) (string, error)
}
switch operation {
case http.MethodGet:
// Note: Don't use ResponseContentType - not supported by all S3-compatible services
req, _ = s.s3Client.GetObjectRequest(&s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
case http.MethodHead:
req, _ = s.s3Client.HeadObjectRequest(&s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
case http.MethodPut:
req, _ = s.s3Client.PutObjectRequest(&s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
ContentType: aws.String("application/octet-stream"),
})
default:
return "", fmt.Errorf("unsupported operation: %s", operation)
}
// Generate presigned URL with 15 minute expiry
url, err := req.Presign(15 * time.Minute)
if err != nil {
log.Printf("[getPresignedURL] Presign FAILED for %s: %v", operation, err)
log.Printf(" Falling back to XRPC endpoint")
proxyURL := s.getProxyURL(digest, did, operation)
if proxyURL == "" {
return "", fmt.Errorf("presign failed and XRPC proxy not supported for PUT operations")
}
return proxyURL, nil
}
return url, nil
}
// Fallback: return XRPC endpoint through this service
proxyURL := s.getProxyURL(digest, did, operation)
if proxyURL == "" {
return "", fmt.Errorf("S3 client not available and XRPC proxy not supported for PUT operations")
}
return proxyURL, nil
}
// getProxyURL returns XRPC endpoint for blob operations (fallback when presigned URLs unavailable)
// For GET/HEAD operations, returns the XRPC getBlob endpoint
// For PUT operations, this fallback is no longer supported - use multipart upload instead
func (s *HoldService) getProxyURL(digest, did string, operation string) string {
// For read operations, use XRPC getBlob endpoint
if operation == http.MethodGet || operation == http.MethodHead {
// Generate hold DID from public URL using shared function
holdDID := atproto.ResolveHoldDIDFromURL(s.config.Server.PublicURL)
return fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
s.config.Server.PublicURL, holdDID, digest)
}
// For PUT operations, proxy fallback is not supported with XRPC
// Clients should use multipart upload flow via com.atproto.repo.uploadBlob
return ""
}
+115
View File
@@ -0,0 +1,115 @@
package s3
import (
"fmt"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"log"
"strings"
)
type S3Service struct {
Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage)
Bucket string // S3 bucket name
PathPrefix string // S3 path prefix (if any)
}
// initializes the S3 client for presigned URL generation
// Returns nil error if S3 client is successfully initialized
// Returns error if storage is not S3 or if initialization fails (service will fall back to proxy mode)
func NewS3Service(params map[string]any, disablePresigned bool, storageType string) (*S3Service, error) {
// Check if presigned URLs are explicitly disabled
if disablePresigned {
log.Printf("⚠️ S3 presigned URLs DISABLED by config (DISABLE_PRESIGNED_URLS=true)")
log.Printf(" All uploads will use buffered mode (parts buffered in hold service)")
return &S3Service{}, nil
}
// Check if storage driver is S3
if storageType != "s3" {
log.Printf("Storage driver is %s (not S3), presigned URLs disabled", storageType)
return &S3Service{}, nil
}
// Extract required S3 configuration
region, _ := params["region"].(string)
if region == "" {
region = "us-east-1" // Default region
}
accessKey, _ := params["accesskey"].(string)
secretKey, _ := params["secretkey"].(string)
bucket, _ := params["bucket"].(string)
if bucket == "" {
return nil, fmt.Errorf("S3 bucket not configured")
}
// Build AWS config
awsConfig := &aws.Config{
Region: &region,
}
// Add credentials if provided (allow IAM role auth if not provided)
if accessKey != "" && secretKey != "" {
awsConfig.Credentials = credentials.NewStaticCredentials(accessKey, secretKey, "")
}
// Add custom endpoint for S3-compatible services (Storj, MinIO, R2, etc.)
if endpoint, ok := params["regionendpoint"].(string); ok && endpoint != "" {
awsConfig.Endpoint = &endpoint
awsConfig.S3ForcePathStyle = aws.Bool(true) // Required for MinIO, Storj
}
// Create AWS session
sess, err := session.NewSession(awsConfig)
if err != nil {
return nil, fmt.Errorf("failed to create AWS session: %w", err)
}
var s3PathPrefix string
// Extract path prefix if configured (rootdirectory in S3 params)
if rootDir, ok := params["rootdirectory"].(string); ok && rootDir != "" {
s3PathPrefix = strings.TrimPrefix(rootDir, "/")
}
log.Printf("✅ S3 presigned URLs enabled")
// Create S3 client
return &S3Service{
Client: s3.New(sess),
Bucket: bucket,
PathPrefix: s3PathPrefix,
}, nil
}
// blobPath converts a digest (e.g., "sha256:abc123...") or temp path to a storage path
// Distribution stores blobs as: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data
// where xx is the first 2 characters of the hash for directory sharding
// NOTE: Path must start with / for filesystem driver
// This is used for OCI container layers (content-addressed, globally deduplicated)
func BlobPath(digest string) string {
// Handle temp paths (start with uploads/temp-)
if strings.HasPrefix(digest, "uploads/temp-") {
return fmt.Sprintf("/docker/registry/v2/%s/data", digest)
}
// Split digest into algorithm and hash
parts := strings.SplitN(digest, ":", 2)
if len(parts) != 2 {
// Fallback for malformed digest
return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
}
algorithm := parts[0]
hash := parts[1]
// Use first 2 characters for sharding
if len(hash) < 2 {
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash)
}
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash)
}