mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 13:17:09 +00:00
176 lines
6.0 KiB
Go
176 lines
6.0 KiB
Go
package hold
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/s3"
|
|
)
|
|
|
|
// 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 PresignedURLOperation, 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)
|
|
}
|
|
|
|
// Check blob exists for GET/HEAD operations (not for PUT since blob doesn't exist yet)
|
|
if operation == OperationGet || operation == OperationHead {
|
|
if _, err := s.driver.Stat(ctx, path); err != nil {
|
|
return "", fmt.Errorf("blob not found: %w", err)
|
|
}
|
|
}
|
|
|
|
// 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 OperationGet:
|
|
// 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 OperationHead:
|
|
req, _ = s.s3Client.HeadObjectRequest(&s3.HeadObjectInput{
|
|
Bucket: aws.String(s.bucket),
|
|
Key: aws.String(s3Key),
|
|
})
|
|
|
|
case OperationPut:
|
|
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 PresignedURLOperation) string {
|
|
// For read operations, use XRPC getBlob endpoint
|
|
if operation == OperationGet || operation == OperationHead {
|
|
// Generate hold DID from public URL
|
|
holdDID := s.getHoldDID()
|
|
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 ""
|
|
}
|
|
|
|
// getHoldDID generates a did:web from the hold's public URL
|
|
func (s *HoldService) getHoldDID() string {
|
|
// Convert URL to did:web format
|
|
// https://hold01.atcr.io → did:web:hold01.atcr.io
|
|
url := s.config.Server.PublicURL
|
|
url = strings.TrimPrefix(url, "https://")
|
|
url = strings.TrimPrefix(url, "http://")
|
|
url = strings.Split(url, "/")[0] // Remove path
|
|
url = strings.Split(url, ":")[0] // Remove port
|
|
return fmt.Sprintf("did:web:%s", url)
|
|
}
|