Files
at-container-registry/pkg/hold/storage.go
T

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)
}