// Package s3 provides S3 client initialization and presigned URL generation // for hold services. It supports S3, Storj, and Minio storage backends, // with fallback to buffered proxy mode when presigned URLs are unavailable. package s3 import ( "fmt" "log/slog" "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" ) 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) } // NewS3Service 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 { slog.Warn("S3 presigned URLs DISABLED by config", "reason", "DISABLE_PRESIGNED_URLS=true", "uploadMode", "buffered") return &S3Service{}, nil } // Check if storage driver is S3 if storageType != "s3" { slog.Info("Presigned URLs disabled for non-S3 storage", "storageDriver", 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: ®ion, } // 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, "/") } slog.Info("S3 presigned URLs enabled", "bucket", bucket, "region", region, "pathPrefix", s3PathPrefix) // 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) }