A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'presigned-urls'

+4159 -1671
+1 -17
cmd/appview/main.go
··· 1 1 package main 2 2 3 3 import ( 4 - "fmt" 5 4 "os" 6 - "time" 7 5 8 6 "github.com/distribution/distribution/v3/registry" 9 7 _ "github.com/distribution/distribution/v3/registry/auth/token" 10 - _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem" 11 8 _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" 12 - _ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws" 13 9 14 10 // Register our custom middleware 15 - _ "atcr.io/pkg/middleware" 16 - 17 - "atcr.io/pkg/auth/oauth" 18 - "atcr.io/pkg/auth/token" 19 - "atcr.io/pkg/middleware" 11 + _ "atcr.io/pkg/appview/middleware" 20 12 ) 21 13 22 14 func main() { ··· 26 18 os.Exit(1) 27 19 } 28 20 } 29 - 30 - // Suppress unused import warnings 31 - var _ = fmt.Sprint 32 - var _ = os.Stdout 33 - var _ = time.Now 34 - var _ = oauth.NewRefresher 35 - var _ = token.NewIssuer 36 - var _ = middleware.SetGlobalRefresher
+12 -13
cmd/appview/serve.go
··· 19 19 sqlite3 "github.com/mattn/go-sqlite3" 20 20 "github.com/spf13/cobra" 21 21 22 + "atcr.io/pkg/appview/middleware" 22 23 "atcr.io/pkg/auth/oauth" 23 24 "atcr.io/pkg/auth/token" 24 - "atcr.io/pkg/middleware" 25 25 26 26 // UI components 27 27 "atcr.io/pkg/appview" 28 28 "atcr.io/pkg/appview/db" 29 29 uihandlers "atcr.io/pkg/appview/handlers" 30 30 "atcr.io/pkg/appview/jetstream" 31 - appmiddleware "atcr.io/pkg/appview/middleware" 32 31 "github.com/gorilla/mux" 33 32 ) 34 33 ··· 473 472 474 473 // Public routes (with optional auth for navbar) 475 474 // SECURITY: Public pages use read-only DB 476 - router.Handle("/", appmiddleware.OptionalAuth(sessionStore, database)( 475 + router.Handle("/", middleware.OptionalAuth(sessionStore, database)( 477 476 &uihandlers.HomeHandler{ 478 477 DB: readOnlyDB, 479 478 Templates: templates, ··· 481 480 }, 482 481 )).Methods("GET") 483 482 484 - router.Handle("/api/recent-pushes", appmiddleware.OptionalAuth(sessionStore, database)( 483 + router.Handle("/api/recent-pushes", middleware.OptionalAuth(sessionStore, database)( 485 484 &uihandlers.RecentPushesHandler{ 486 485 DB: readOnlyDB, 487 486 Templates: templates, ··· 490 489 )).Methods("GET") 491 490 492 491 // SECURITY: Search uses read-only DB to prevent writes and limit access to sensitive tables 493 - router.Handle("/search", appmiddleware.OptionalAuth(sessionStore, database)( 492 + router.Handle("/search", middleware.OptionalAuth(sessionStore, database)( 494 493 &uihandlers.SearchHandler{ 495 494 DB: readOnlyDB, 496 495 Templates: templates, ··· 498 497 }, 499 498 )).Methods("GET") 500 499 501 - router.Handle("/api/search-results", appmiddleware.OptionalAuth(sessionStore, database)( 500 + router.Handle("/api/search-results", middleware.OptionalAuth(sessionStore, database)( 502 501 &uihandlers.SearchResultsHandler{ 503 502 DB: readOnlyDB, 504 503 Templates: templates, ··· 507 506 )).Methods("GET") 508 507 509 508 // API route for repository stats (public, read-only) 510 - router.Handle("/api/stats/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( 509 + router.Handle("/api/stats/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)( 511 510 &uihandlers.GetStatsHandler{ 512 511 DB: readOnlyDB, 513 512 Directory: oauthApp.Directory(), ··· 515 514 )).Methods("GET") 516 515 517 516 // API routes for stars (require authentication) 518 - router.Handle("/api/stars/{handle}/{repository}", appmiddleware.RequireAuth(sessionStore, database)( 517 + router.Handle("/api/stars/{handle}/{repository}", middleware.RequireAuth(sessionStore, database)( 519 518 &uihandlers.StarRepositoryHandler{ 520 519 DB: database, // Needs write access 521 520 Directory: oauthApp.Directory(), ··· 523 522 }, 524 523 )).Methods("POST") 525 524 526 - router.Handle("/api/stars/{handle}/{repository}", appmiddleware.RequireAuth(sessionStore, database)( 525 + router.Handle("/api/stars/{handle}/{repository}", middleware.RequireAuth(sessionStore, database)( 527 526 &uihandlers.UnstarRepositoryHandler{ 528 527 DB: database, // Needs write access 529 528 Directory: oauthApp.Directory(), ··· 531 530 }, 532 531 )).Methods("DELETE") 533 532 534 - router.Handle("/api/stars/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( 533 + router.Handle("/api/stars/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)( 535 534 &uihandlers.CheckStarHandler{ 536 535 DB: readOnlyDB, // Read-only check 537 536 Directory: oauthApp.Directory(), ··· 539 538 }, 540 539 )).Methods("GET") 541 540 542 - router.Handle("/u/{handle}", appmiddleware.OptionalAuth(sessionStore, database)( 541 + router.Handle("/u/{handle}", middleware.OptionalAuth(sessionStore, database)( 543 542 &uihandlers.UserPageHandler{ 544 543 DB: readOnlyDB, 545 544 Templates: templates, ··· 547 546 }, 548 547 )).Methods("GET") 549 548 550 - router.Handle("/r/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( 549 + router.Handle("/r/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)( 551 550 &uihandlers.RepositoryPageHandler{ 552 551 DB: readOnlyDB, 553 552 Templates: templates, ··· 559 558 560 559 // Authenticated routes 561 560 authRouter := router.NewRoute().Subrouter() 562 - authRouter.Use(appmiddleware.RequireAuth(sessionStore, database)) 561 + authRouter.Use(middleware.RequireAuth(sessionStore, database)) 563 562 564 563 authRouter.Handle("/settings", &uihandlers.SettingsHandler{ 565 564 Templates: templates,
+33 -1386
cmd/hold/main.go
··· 1 1 package main 2 2 3 3 import ( 4 - "context" 5 4 "encoding/json" 6 5 "fmt" 7 - "io" 8 6 "log" 9 7 "net/http" 10 - "net/url" 11 - "os" 8 + "strconv" 12 9 "strings" 13 10 "time" 14 11 15 - "github.com/aws/aws-sdk-go/aws" 16 - "github.com/aws/aws-sdk-go/aws/credentials" 17 - "github.com/aws/aws-sdk-go/aws/session" 18 - "github.com/aws/aws-sdk-go/service/s3" 19 - "github.com/distribution/distribution/v3/configuration" 20 - storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 21 - "github.com/distribution/distribution/v3/registry/storage/driver/factory" 22 - 23 12 "atcr.io/pkg/atproto" 24 - "atcr.io/pkg/auth/oauth" 13 + "atcr.io/pkg/hold" 25 14 indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 26 - "github.com/bluesky-social/indigo/atproto/identity" 27 - "github.com/bluesky-social/indigo/atproto/syntax" 28 15 29 16 // Import storage drivers 30 17 _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem" 31 18 _ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws" 32 19 ) 33 20 34 - // Config represents the hold service configuration 35 - type Config struct { 36 - Version string `yaml:"version"` 37 - Storage StorageConfig `yaml:"storage"` 38 - Server ServerConfig `yaml:"server"` 39 - Registration RegistrationConfig `yaml:"registration"` 40 - } 41 - 42 - // RegistrationConfig defines auto-registration settings 43 - type RegistrationConfig struct { 44 - // OwnerDID is the owner's ATProto DID (from env: HOLD_OWNER) 45 - // If set, auto-registration is enabled 46 - OwnerDID string `yaml:"owner_did"` 47 - } 48 - 49 - // StorageConfig wraps distribution's storage configuration 50 - type StorageConfig struct { 51 - configuration.Storage `yaml:",inline"` 52 - } 53 - 54 - // ServerConfig defines server settings 55 - type ServerConfig struct { 56 - // Addr is the address to listen on (e.g., ":8080") 57 - Addr string `yaml:"addr"` 58 - 59 - // PublicURL is the public URL of this hold service (e.g., "https://hold.example.com") 60 - PublicURL string `yaml:"public_url"` 61 - 62 - // Public controls whether this hold allows public blob reads without auth (from env: HOLD_PUBLIC) 63 - Public bool `yaml:"public"` 64 - 65 - // TestMode uses localhost for OAuth redirects while storing real URL in hold record (from env: TEST_MODE) 66 - TestMode bool `yaml:"test_mode"` 67 - 68 - // ReadTimeout for HTTP requests 69 - ReadTimeout time.Duration `yaml:"read_timeout"` 70 - 71 - // WriteTimeout for HTTP requests 72 - WriteTimeout time.Duration `yaml:"write_timeout"` 73 - } 74 - 75 - // HoldService provides presigned URLs for blob storage in a hold 76 - type HoldService struct { 77 - driver storagedriver.StorageDriver 78 - config *Config 79 - s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage) 80 - bucket string // S3 bucket name 81 - s3PathPrefix string // S3 path prefix (if any) 82 - } 83 - 84 - // NewHoldService creates a new hold service 85 - func NewHoldService(cfg *Config) (*HoldService, error) { 86 - // Create storage driver from config 87 - ctx := context.Background() 88 - driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters()) 89 - if err != nil { 90 - return nil, fmt.Errorf("failed to create storage driver: %w", err) 91 - } 92 - 93 - service := &HoldService{ 94 - driver: driver, 95 - config: cfg, 96 - } 97 - 98 - // Initialize S3 client for presigned URLs (if using S3 storage) 99 - if err := service.initS3Client(); err != nil { 100 - log.Printf("WARNING: S3 presigned URLs disabled: %v", err) 101 - } 102 - 103 - return service, nil 104 - } 105 - 106 - // initS3Client initializes the S3 client for presigned URL generation 107 - // Returns nil error if S3 client is successfully initialized 108 - // Returns error if storage is not S3 or if initialization fails (service will fall back to proxy mode) 109 - func (s *HoldService) initS3Client() error { 110 - // Check if storage driver is S3 111 - if s.config.Storage.Type() != "s3" { 112 - log.Printf("Storage driver is %s (not S3), presigned URLs disabled", s.config.Storage.Type()) 113 - return nil // Not an error - just using different driver 114 - } 115 - 116 - // Extract S3 configuration from storage parameters 117 - params := s.config.Storage.Parameters() 118 - 119 - // Extract required S3 configuration 120 - region, _ := params["region"].(string) 121 - if region == "" { 122 - region = "us-east-1" // Default region 123 - } 124 - 125 - accessKey, _ := params["accesskey"].(string) 126 - secretKey, _ := params["secretkey"].(string) 127 - bucket, _ := params["bucket"].(string) 128 - 129 - if bucket == "" { 130 - return fmt.Errorf("S3 bucket not configured") 131 - } 132 - 133 - // Build AWS config 134 - awsConfig := &aws.Config{ 135 - Region: aws.String(region), 136 - } 137 - 138 - // Add credentials if provided (allow IAM role auth if not provided) 139 - if accessKey != "" && secretKey != "" { 140 - awsConfig.Credentials = credentials.NewStaticCredentials(accessKey, secretKey, "") 141 - } 142 - 143 - // Add custom endpoint for S3-compatible services (Storj, MinIO, R2, etc.) 144 - if endpoint, ok := params["regionendpoint"].(string); ok && endpoint != "" { 145 - awsConfig.Endpoint = aws.String(endpoint) 146 - awsConfig.S3ForcePathStyle = aws.Bool(true) // Required for MinIO, Storj 147 - } 148 - 149 - // Create AWS session 150 - sess, err := session.NewSession(awsConfig) 151 - if err != nil { 152 - return fmt.Errorf("failed to create AWS session: %w", err) 153 - } 154 - 155 - // Create S3 client 156 - s.s3Client = s3.New(sess) 157 - s.bucket = bucket 158 - 159 - // Extract path prefix if configured (rootdirectory in S3 params) 160 - if rootDir, ok := params["rootdirectory"].(string); ok && rootDir != "" { 161 - s.s3PathPrefix = strings.TrimPrefix(rootDir, "/") 162 - } 163 - 164 - log.Printf("S3 presigned URLs enabled for bucket: %s", s.bucket) 165 - if s.s3PathPrefix != "" { 166 - log.Printf("S3 path prefix: %s", s.s3PathPrefix) 167 - } 168 - 169 - return nil 170 - } 171 - 172 - // GetPresignedURLRequest represents a request for a presigned download URL 173 - type GetPresignedURLRequest struct { 174 - DID string `json:"did"` 175 - Digest string `json:"digest"` 176 - } 177 - 178 - // GetPresignedURLResponse contains the presigned URL 179 - type GetPresignedURLResponse struct { 180 - URL string `json:"url"` 181 - ExpiresAt time.Time `json:"expires_at"` 182 - } 183 - 184 - // PutPresignedURLRequest represents a request for a presigned upload URL 185 - type PutPresignedURLRequest struct { 186 - DID string `json:"did"` 187 - Digest string `json:"digest"` 188 - Size int64 `json:"size"` 189 - } 190 - 191 - // PutPresignedURLResponse contains the presigned upload URL 192 - type PutPresignedURLResponse struct { 193 - URL string `json:"url"` 194 - ExpiresAt time.Time `json:"expires_at"` 195 - } 196 - 197 - // StartMultipartUploadRequest initiates a multipart upload 198 - type StartMultipartUploadRequest struct { 199 - DID string `json:"did"` 200 - Digest string `json:"digest"` 201 - } 202 - 203 - // StartMultipartUploadResponse contains the upload ID 204 - type StartMultipartUploadResponse struct { 205 - UploadID string `json:"upload_id"` 206 - ExpiresAt time.Time `json:"expires_at"` 207 - } 208 - 209 - // GetPartURLRequest requests a presigned URL for a specific part 210 - type GetPartURLRequest struct { 211 - DID string `json:"did"` 212 - Digest string `json:"digest"` 213 - UploadID string `json:"upload_id"` 214 - PartNumber int `json:"part_number"` 215 - } 216 - 217 - // GetPartURLResponse contains the presigned URL for the part 218 - type GetPartURLResponse struct { 219 - URL string `json:"url"` 220 - ExpiresAt time.Time `json:"expires_at"` 221 - } 222 - 223 - // CompletedPart represents a completed multipart upload part 224 - type CompletedPart struct { 225 - PartNumber int `json:"part_number"` 226 - ETag string `json:"etag"` 227 - } 228 - 229 - // CompleteMultipartRequest completes a multipart upload 230 - type CompleteMultipartRequest struct { 231 - DID string `json:"did"` 232 - Digest string `json:"digest"` 233 - UploadID string `json:"upload_id"` 234 - Parts []CompletedPart `json:"parts"` 235 - } 236 - 237 - // AbortMultipartRequest aborts an in-progress upload 238 - type AbortMultipartRequest struct { 239 - DID string `json:"did"` 240 - Digest string `json:"digest"` 241 - UploadID string `json:"upload_id"` 242 - } 243 - 244 - // HandleGetPresignedURL handles requests for download URLs 245 - func (s *HoldService) HandleGetPresignedURL(w http.ResponseWriter, r *http.Request) { 246 - if r.Method != http.MethodPost { 247 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 248 - return 249 - } 250 - 251 - var req GetPresignedURLRequest 252 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 253 - http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 254 - return 255 - } 256 - 257 - // Validate DID authorization for READ 258 - if !s.isAuthorizedRead(req.DID) { 259 - if req.DID == "" { 260 - // Anonymous request to private hold 261 - http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 262 - } else { 263 - // Authenticated but not authorized 264 - http.Error(w, "forbidden: access denied", http.StatusForbidden) 265 - } 266 - return 267 - } 268 - 269 - // Generate presigned URL (15 minute expiry) 270 - ctx := context.Background() 271 - expiry := time.Now().Add(15 * time.Minute) 272 - 273 - // For now, construct direct URL to blob 274 - // In production, this would use driver-specific presigned URLs 275 - url, err := s.getDownloadURL(ctx, req.Digest, req.DID) 276 - if err != nil { 277 - http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError) 278 - return 279 - } 280 - 281 - resp := GetPresignedURLResponse{ 282 - URL: url, 283 - ExpiresAt: expiry, 284 - } 285 - 286 - w.Header().Set("Content-Type", "application/json") 287 - json.NewEncoder(w).Encode(resp) 288 - } 289 - 290 - // HandlePutPresignedURL handles requests for upload URLs 291 - func (s *HoldService) HandlePutPresignedURL(w http.ResponseWriter, r *http.Request) { 292 - if r.Method != http.MethodPost { 293 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 294 - return 295 - } 296 - 297 - var req PutPresignedURLRequest 298 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 299 - http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 300 - return 301 - } 302 - 303 - // Validate DID authorization for WRITE 304 - if !s.isAuthorizedWrite(req.DID) { 305 - if req.DID == "" { 306 - // Anonymous write attempt 307 - http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 308 - } else { 309 - // Authenticated but not crew/owner 310 - http.Error(w, "forbidden: write access denied", http.StatusForbidden) 311 - } 312 - return 313 - } 314 - 315 - // Generate presigned upload URL (15 minute expiry) 316 - ctx := context.Background() 317 - expiry := time.Now().Add(15 * time.Minute) 318 - 319 - url, err := s.getUploadURL(ctx, req.Digest, req.Size, req.DID) 320 - if err != nil { 321 - http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError) 322 - return 323 - } 324 - 325 - resp := PutPresignedURLResponse{ 326 - URL: url, 327 - ExpiresAt: expiry, 328 - } 329 - 330 - w.Header().Set("Content-Type", "application/json") 331 - json.NewEncoder(w).Encode(resp) 332 - } 333 - 334 - // HandleProxyGet proxies a blob download through the service 335 - func (s *HoldService) HandleProxyGet(w http.ResponseWriter, r *http.Request) { 336 - if r.Method != http.MethodGet && r.Method != http.MethodHead { 337 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 338 - return 339 - } 340 - 341 - // Extract digest from path (e.g., /blobs/sha256:abc123) 342 - digest := r.URL.Path[len("/blobs/"):] 343 - if digest == "" { 344 - http.Error(w, "missing digest", http.StatusBadRequest) 345 - return 346 - } 347 - 348 - // Get DID from query param or header 349 - did := r.URL.Query().Get("did") 350 - if did == "" { 351 - did = r.Header.Get("X-ATCR-DID") 352 - } 353 - 354 - // Authorize READ access 355 - if !s.isAuthorizedRead(did) { 356 - if did == "" { 357 - http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 358 - } else { 359 - http.Error(w, "forbidden: access denied", http.StatusForbidden) 360 - } 361 - return 362 - } 363 - 364 - ctx := r.Context() 365 - path := blobPath(digest) 366 - 367 - // For HEAD requests, just check if blob exists 368 - if r.Method == http.MethodHead { 369 - stat, err := s.driver.Stat(ctx, path) 370 - if err != nil { 371 - http.Error(w, "blob not found", http.StatusNotFound) 372 - return 373 - } 374 - w.Header().Set("Content-Type", "application/octet-stream") 375 - w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) 376 - w.WriteHeader(http.StatusOK) 377 - return 378 - } 379 - 380 - // For GET requests, read and return the blob 381 - content, err := s.driver.GetContent(ctx, path) 382 - if err != nil { 383 - http.Error(w, "blob not found", http.StatusNotFound) 384 - return 385 - } 386 - 387 - w.Header().Set("Content-Type", "application/octet-stream") 388 - w.Write(content) 389 - } 390 - 391 - // HandleMove moves a blob from one path to another 392 - // POST /move?from={path}&to={digest}&did={did} 393 - func (s *HoldService) HandleMove(w http.ResponseWriter, r *http.Request) { 394 - if r.Method != http.MethodPost { 395 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 396 - return 397 - } 398 - 399 - fromPath := r.URL.Query().Get("from") 400 - toDigest := r.URL.Query().Get("to") 401 - did := r.URL.Query().Get("did") 402 - 403 - if fromPath == "" || toDigest == "" { 404 - http.Error(w, "missing from or to parameter", http.StatusBadRequest) 405 - return 406 - } 407 - 408 - // Authorize WRITE access 409 - if !s.isAuthorizedWrite(did) { 410 - if did == "" { 411 - http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 412 - } else { 413 - http.Error(w, "forbidden: write access denied", http.StatusForbidden) 414 - } 415 - return 416 - } 417 - 418 - ctx := r.Context() 419 - sourcePath := blobPath(fromPath) 420 - destPath := blobPath(toDigest) 421 - 422 - // Try to move using driver's Move operation 423 - if err := s.driver.Move(ctx, sourcePath, destPath); err != nil { 424 - log.Printf("HandleMove: failed to move blob: %v", err) 425 - http.Error(w, fmt.Sprintf("failed to move blob: %v", err), http.StatusInternalServerError) 426 - return 427 - } 428 - 429 - log.Printf("HandleMove: successfully moved blob from=%s to=%s", fromPath, toDigest) 430 - w.WriteHeader(http.StatusOK) 431 - } 432 - 433 - // HandleProxyPut proxies a blob upload through the service 434 - func (s *HoldService) HandleProxyPut(w http.ResponseWriter, r *http.Request) { 435 - if r.Method != http.MethodPut { 436 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 437 - return 438 - } 439 - 440 - digest := r.URL.Path[len("/blobs/"):] 441 - if digest == "" { 442 - http.Error(w, "missing digest", http.StatusBadRequest) 443 - return 444 - } 445 - 446 - did := r.URL.Query().Get("did") 447 - if did == "" { 448 - did = r.Header.Get("X-ATCR-DID") 449 - } 450 - 451 - // Authorize WRITE access 452 - if !s.isAuthorizedWrite(did) { 453 - if did == "" { 454 - http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 455 - } else { 456 - http.Error(w, "forbidden: write access denied", http.StatusForbidden) 457 - } 458 - return 459 - } 460 - 461 - // Stream blob to storage (no buffering) 462 - ctx := r.Context() 463 - path := blobPath(digest) 464 - 465 - // Create writer for streaming 466 - writer, err := s.driver.Writer(ctx, path, false) 467 - if err != nil { 468 - log.Printf("HandleProxyPut: failed to create writer: %v", err) 469 - http.Error(w, "failed to create writer", http.StatusInternalServerError) 470 - return 471 - } 472 - 473 - // Stream directly from request body to storage 474 - written, err := io.Copy(writer, r.Body) 475 - if err != nil { 476 - writer.Cancel(ctx) 477 - log.Printf("HandleProxyPut: failed to write blob: %v", err) 478 - http.Error(w, "failed to write blob", http.StatusInternalServerError) 479 - return 480 - } 481 - 482 - // Commit the write 483 - if err := writer.Commit(ctx); err != nil { 484 - log.Printf("HandleProxyPut: failed to commit blob: %v", err) 485 - http.Error(w, "failed to commit blob", http.StatusInternalServerError) 486 - return 487 - } 488 - 489 - log.Printf("HandleProxyPut: successfully stored blob path=%s, size=%d", digest, written) 490 - w.WriteHeader(http.StatusCreated) 491 - } 492 - 493 - // isAuthorizedRead checks if a DID can read from this hold 494 - // Authorization: 495 - // - Public hold: allow anonymous (empty DID) or any authenticated user 496 - // - Private hold: require authentication (any user with sailor.profile) 497 - func (s *HoldService) isAuthorizedRead(did string) bool { 498 - // Check hold public flag 499 - isPublic, err := s.isHoldPublic() 500 - if err != nil { 501 - log.Printf("ERROR: Failed to check hold public flag: %v", err) 502 - // Fail secure - deny access on error 503 - return false 504 - } 505 - 506 - if isPublic { 507 - // Public hold - allow anyone (even anonymous) 508 - return true 509 - } 510 - 511 - // Private hold - require authentication 512 - // Any authenticated user with sailor.profile can read 513 - if did == "" { 514 - // Anonymous user trying to access private hold 515 - return false 516 - } 517 - 518 - // For MVP: assume DID presence means they have sailor.profile 519 - // Future: could query PDS to verify sailor.profile exists 520 - return true 521 - } 522 - 523 - // isAuthorizedWrite checks if a DID can write to this hold 524 - // Authorization: must be hold owner OR crew member 525 - func (s *HoldService) isAuthorizedWrite(did string) bool { 526 - if did == "" { 527 - // Anonymous writes not allowed 528 - return false 529 - } 530 - 531 - // Check if DID is the hold owner 532 - ownerDID := s.config.Registration.OwnerDID 533 - if ownerDID == "" { 534 - log.Printf("ERROR: Hold owner DID not configured") 535 - return false 536 - } 537 - 538 - if did == ownerDID { 539 - // Owner always has write access 540 - return true 541 - } 542 - 543 - // Check if DID is a crew member 544 - isCrew, err := s.isCrewMember(did) 545 - if err != nil { 546 - log.Printf("ERROR: Failed to check crew membership: %v", err) 547 - return false 548 - } 549 - 550 - return isCrew 551 - } 552 - 553 - // isHoldPublic checks if this hold allows public (anonymous) reads 554 - func (s *HoldService) isHoldPublic() (bool, error) { 555 - // Use cached config value for now 556 - // Future: could query PDS for hold record to get live value 557 - return s.config.Server.Public, nil 558 - } 559 - 560 - // isCrewMember checks if a DID is a crew member of this hold 561 - func (s *HoldService) isCrewMember(did string) (bool, error) { 562 - ownerDID := s.config.Registration.OwnerDID 563 - if ownerDID == "" { 564 - return false, fmt.Errorf("hold owner DID not configured") 565 - } 566 - 567 - ctx := context.Background() 568 - 569 - // Resolve owner's PDS endpoint using indigo 570 - directory := identity.DefaultDirectory() 571 - ownerDIDParsed, err := syntax.ParseDID(ownerDID) 572 - if err != nil { 573 - return false, fmt.Errorf("invalid owner DID: %w", err) 574 - } 575 - 576 - ident, err := directory.LookupDID(ctx, ownerDIDParsed) 577 - if err != nil { 578 - return false, fmt.Errorf("failed to resolve owner PDS: %w", err) 579 - } 580 - 581 - pdsEndpoint := ident.PDSEndpoint() 582 - if pdsEndpoint == "" { 583 - return false, fmt.Errorf("no PDS endpoint found for owner") 584 - } 585 - 586 - // Create unauthenticated client to read public records 587 - client := atproto.NewClient(pdsEndpoint, ownerDID, "") 588 - 589 - // List crew records for this hold 590 - // Crew records are public, so we can read them without auth 591 - records, err := client.ListRecords(ctx, atproto.HoldCrewCollection, 100) 592 - if err != nil { 593 - return false, fmt.Errorf("failed to list crew records: %w", err) 594 - } 595 - 596 - // Check if DID is in crew list 597 - for _, record := range records { 598 - var crewRecord atproto.HoldCrewRecord 599 - if err := json.Unmarshal(record.Value, &crewRecord); err != nil { 600 - continue 601 - } 602 - 603 - if crewRecord.Member == did { 604 - // Found crew membership 605 - return true, nil 606 - } 607 - } 608 - 609 - return false, nil 610 - } 611 - 612 - // getDownloadURL generates a download URL for a blob 613 - func (s *HoldService) getDownloadURL(ctx context.Context, digest string, did string) (string, error) { 614 - // Check if blob exists 615 - path := blobPath(digest) 616 - _, err := s.driver.Stat(ctx, path) 617 - if err != nil { 618 - return "", fmt.Errorf("blob not found: %w", err) 619 - } 620 - 621 - // If S3 client available, generate presigned URL 622 - if s.s3Client != nil { 623 - // Build S3 key from blob path 624 - // blobPath returns paths like: /docker/registry/v2/blobs/sha256/ab/abc123.../data 625 - s3Key := strings.TrimPrefix(path, "/") 626 - if s.s3PathPrefix != "" { 627 - s3Key = s.s3PathPrefix + "/" + s3Key 628 - } 629 - 630 - // Generate presigned GET URL 631 - req, _ := s.s3Client.GetObjectRequest(&s3.GetObjectInput{ 632 - Bucket: aws.String(s.bucket), 633 - Key: aws.String(s3Key), 634 - }) 635 - 636 - url, err := req.Presign(15 * time.Minute) 637 - if err != nil { 638 - log.Printf("WARN: Presigned URL generation failed for %s, falling back to proxy: %v", digest, err) 639 - return s.getProxyDownloadURL(digest, did), nil 640 - } 641 - 642 - log.Printf("Generated presigned download URL for %s (expires in 15min)", digest) 643 - return url, nil 644 - } 645 - 646 - // Fallback: return proxy URL through this service 647 - return s.getProxyDownloadURL(digest, did), nil 648 - } 649 - 650 - // getProxyDownloadURL returns a proxy URL for blob download (fallback when presigned URLs unavailable) 651 - func (s *HoldService) getProxyDownloadURL(digest, did string) string { 652 - return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did) 653 - } 654 - 655 - // getUploadURL generates an upload URL for a blob 656 - // Note: This is called from HandlePutPresignedURL which has the DID in the request 657 - func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64, did string) (string, error) { 658 - // If S3 client available, generate presigned URL 659 - if s.s3Client != nil { 660 - // Build S3 key from blob path 661 - path := blobPath(digest) 662 - s3Key := strings.TrimPrefix(path, "/") 663 - if s.s3PathPrefix != "" { 664 - s3Key = s.s3PathPrefix + "/" + s3Key 665 - } 666 - 667 - // Generate presigned PUT URL 668 - req, _ := s.s3Client.PutObjectRequest(&s3.PutObjectInput{ 669 - Bucket: aws.String(s.bucket), 670 - Key: aws.String(s3Key), 671 - }) 672 - 673 - url, err := req.Presign(15 * time.Minute) 674 - if err != nil { 675 - log.Printf("WARN: Presigned URL generation failed for %s, falling back to proxy: %v", digest, err) 676 - return s.getProxyUploadURL(digest, did), nil 677 - } 678 - 679 - log.Printf("Generated presigned upload URL for %s (expires in 15min)", digest) 680 - return url, nil 681 - } 682 - 683 - // Fallback: return proxy URL through this service 684 - return s.getProxyUploadURL(digest, did), nil 685 - } 686 - 687 - // getProxyUploadURL returns a proxy URL for blob upload (fallback when presigned URLs unavailable) 688 - func (s *HoldService) getProxyUploadURL(digest, did string) string { 689 - return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did) 690 - } 691 - 692 - // startMultipartUpload initiates a multipart upload and returns upload ID 693 - func (s *HoldService) startMultipartUpload(ctx context.Context, digest string) (string, error) { 694 - if s.s3Client == nil { 695 - return "", fmt.Errorf("S3 not configured for multipart uploads") 696 - } 697 - 698 - path := blobPath(digest) 699 - s3Key := strings.TrimPrefix(path, "/") 700 - if s.s3PathPrefix != "" { 701 - s3Key = s.s3PathPrefix + "/" + s3Key 702 - } 703 - 704 - result, err := s.s3Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{ 705 - Bucket: aws.String(s.bucket), 706 - Key: aws.String(s3Key), 707 - }) 708 - if err != nil { 709 - return "", fmt.Errorf("failed to create multipart upload: %w", err) 710 - } 711 - 712 - log.Printf("Started multipart upload: key=%s, uploadId=%s", s3Key, *result.UploadId) 713 - return *result.UploadId, nil 714 - } 715 - 716 - // getPartPresignedURL generates presigned URL for a specific part 717 - func (s *HoldService) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) { 718 - if s.s3Client == nil { 719 - return "", fmt.Errorf("S3 not configured for multipart uploads") 720 - } 721 - 722 - path := blobPath(digest) 723 - s3Key := strings.TrimPrefix(path, "/") 724 - if s.s3PathPrefix != "" { 725 - s3Key = s.s3PathPrefix + "/" + s3Key 726 - } 727 - 728 - req, _ := s.s3Client.UploadPartRequest(&s3.UploadPartInput{ 729 - Bucket: aws.String(s.bucket), 730 - Key: aws.String(s3Key), 731 - UploadId: aws.String(uploadID), 732 - PartNumber: aws.Int64(int64(partNumber)), 733 - }) 734 - 735 - url, err := req.Presign(15 * time.Minute) 736 - if err != nil { 737 - return "", fmt.Errorf("failed to presign part URL: %w", err) 738 - } 739 - 740 - log.Printf("Generated presigned URL for part %d: key=%s, uploadId=%s", partNumber, s3Key, uploadID) 741 - return url, nil 742 - } 743 - 744 - // completeMultipartUpload finalizes the multipart upload 745 - func (s *HoldService) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error { 746 - if s.s3Client == nil { 747 - return fmt.Errorf("S3 not configured for multipart uploads") 748 - } 749 - 750 - path := blobPath(digest) 751 - s3Key := strings.TrimPrefix(path, "/") 752 - if s.s3PathPrefix != "" { 753 - s3Key = s.s3PathPrefix + "/" + s3Key 754 - } 755 - 756 - // Convert to S3 CompletedPart format 757 - s3Parts := make([]*s3.CompletedPart, len(parts)) 758 - for i, p := range parts { 759 - s3Parts[i] = &s3.CompletedPart{ 760 - PartNumber: aws.Int64(int64(p.PartNumber)), 761 - ETag: aws.String(p.ETag), 762 - } 763 - } 764 - 765 - _, err := s.s3Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{ 766 - Bucket: aws.String(s.bucket), 767 - Key: aws.String(s3Key), 768 - UploadId: aws.String(uploadID), 769 - MultipartUpload: &s3.CompletedMultipartUpload{ 770 - Parts: s3Parts, 771 - }, 772 - }) 773 - 774 - if err != nil { 775 - return fmt.Errorf("failed to complete multipart upload: %w", err) 776 - } 777 - 778 - log.Printf("Completed multipart upload: key=%s, uploadId=%s, parts=%d", s3Key, uploadID, len(parts)) 779 - return nil 780 - } 781 - 782 - // abortMultipartUpload cancels an in-progress multipart upload 783 - func (s *HoldService) abortMultipartUpload(ctx context.Context, digest, uploadID string) error { 784 - if s.s3Client == nil { 785 - return fmt.Errorf("S3 not configured for multipart uploads") 786 - } 787 - 788 - path := blobPath(digest) 789 - s3Key := strings.TrimPrefix(path, "/") 790 - if s.s3PathPrefix != "" { 791 - s3Key = s.s3PathPrefix + "/" + s3Key 792 - } 793 - 794 - _, err := s.s3Client.AbortMultipartUploadWithContext(ctx, &s3.AbortMultipartUploadInput{ 795 - Bucket: aws.String(s.bucket), 796 - Key: aws.String(s3Key), 797 - UploadId: aws.String(uploadID), 798 - }) 799 - 800 - if err != nil { 801 - return fmt.Errorf("failed to abort multipart upload: %w", err) 802 - } 803 - 804 - log.Printf("Aborted multipart upload: key=%s, uploadId=%s", s3Key, uploadID) 805 - return nil 806 - } 807 - 808 - // HandleStartMultipart initiates a multipart upload 809 - func (s *HoldService) HandleStartMultipart(w http.ResponseWriter, r *http.Request) { 810 - if r.Method != http.MethodPost { 811 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 812 - return 813 - } 814 - 815 - var req StartMultipartUploadRequest 816 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 817 - http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 818 - return 819 - } 820 - 821 - // Validate DID authorization for WRITE 822 - if !s.isAuthorizedWrite(req.DID) { 823 - if req.DID == "" { 824 - http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 825 - } else { 826 - http.Error(w, "forbidden: write access denied", http.StatusForbidden) 827 - } 828 - return 829 - } 830 - 831 - ctx := r.Context() 832 - uploadID, err := s.startMultipartUpload(ctx, req.Digest) 833 - if err != nil { 834 - http.Error(w, fmt.Sprintf("failed to start multipart upload: %v", err), http.StatusInternalServerError) 835 - return 836 - } 837 - 838 - resp := StartMultipartUploadResponse{ 839 - UploadID: uploadID, 840 - ExpiresAt: time.Now().Add(24 * time.Hour), // Multipart uploads expire in 24h 841 - } 842 - 843 - w.Header().Set("Content-Type", "application/json") 844 - json.NewEncoder(w).Encode(resp) 845 - } 846 - 847 - // HandleGetPartURL generates a presigned URL for uploading a specific part 848 - func (s *HoldService) HandleGetPartURL(w http.ResponseWriter, r *http.Request) { 849 - if r.Method != http.MethodPost { 850 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 851 - return 852 - } 853 - 854 - var req GetPartURLRequest 855 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 856 - http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 857 - return 858 - } 859 - 860 - // Validate DID authorization for WRITE 861 - if !s.isAuthorizedWrite(req.DID) { 862 - if req.DID == "" { 863 - http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 864 - } else { 865 - http.Error(w, "forbidden: write access denied", http.StatusForbidden) 866 - } 867 - return 868 - } 869 - 870 - ctx := r.Context() 871 - url, err := s.getPartPresignedURL(ctx, req.Digest, req.UploadID, req.PartNumber) 872 - if err != nil { 873 - http.Error(w, fmt.Sprintf("failed to generate part URL: %v", err), http.StatusInternalServerError) 874 - return 875 - } 876 - 877 - resp := GetPartURLResponse{ 878 - URL: url, 879 - ExpiresAt: time.Now().Add(15 * time.Minute), 880 - } 881 - 882 - w.Header().Set("Content-Type", "application/json") 883 - json.NewEncoder(w).Encode(resp) 884 - } 885 - 886 - // HandleCompleteMultipart completes a multipart upload 887 - func (s *HoldService) HandleCompleteMultipart(w http.ResponseWriter, r *http.Request) { 888 - if r.Method != http.MethodPost { 889 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 890 - return 891 - } 892 - 893 - var req CompleteMultipartRequest 894 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 895 - http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 896 - return 897 - } 898 - 899 - // Validate DID authorization for WRITE 900 - if !s.isAuthorizedWrite(req.DID) { 901 - if req.DID == "" { 902 - http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 903 - } else { 904 - http.Error(w, "forbidden: write access denied", http.StatusForbidden) 905 - } 906 - return 907 - } 908 - 909 - ctx := r.Context() 910 - if err := s.completeMultipartUpload(ctx, req.Digest, req.UploadID, req.Parts); err != nil { 911 - http.Error(w, fmt.Sprintf("failed to complete multipart upload: %v", err), http.StatusInternalServerError) 912 - return 913 - } 914 - 915 - w.WriteHeader(http.StatusOK) 916 - json.NewEncoder(w).Encode(map[string]string{"status": "completed"}) 917 - } 918 - 919 - // HandleAbortMultipart aborts a multipart upload 920 - func (s *HoldService) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) { 921 - if r.Method != http.MethodPost { 922 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 923 - return 924 - } 925 - 926 - var req AbortMultipartRequest 927 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 928 - http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 929 - return 930 - } 931 - 932 - // Validate DID authorization for WRITE 933 - if !s.isAuthorizedWrite(req.DID) { 934 - if req.DID == "" { 935 - http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 936 - } else { 937 - http.Error(w, "forbidden: write access denied", http.StatusForbidden) 938 - } 939 - return 940 - } 941 - 942 - ctx := r.Context() 943 - if err := s.abortMultipartUpload(ctx, req.Digest, req.UploadID); err != nil { 944 - http.Error(w, fmt.Sprintf("failed to abort multipart upload: %v", err), http.StatusInternalServerError) 945 - return 946 - } 947 - 948 - w.WriteHeader(http.StatusOK) 949 - json.NewEncoder(w).Encode(map[string]string{"status": "aborted"}) 950 - } 951 - 952 - // RegisterRequest represents a request to register this hold in a user's PDS 953 - type RegisterRequest struct { 954 - DID string `json:"did"` 955 - AccessToken string `json:"access_token"` 956 - PDSEndpoint string `json:"pds_endpoint"` 957 - } 958 - 959 - // RegisterResponse contains the registration result 960 - type RegisterResponse struct { 961 - HoldURI string `json:"hold_uri"` 962 - CrewURI string `json:"crew_uri"` 963 - Message string `json:"message"` 964 - } 965 - 966 - // HandleRegister registers this hold service in a user's PDS (manual endpoint) 967 - func (s *HoldService) HandleRegister(w http.ResponseWriter, r *http.Request) { 968 - if r.Method != http.MethodPost { 969 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 970 - return 971 - } 972 - 973 - var req RegisterRequest 974 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 975 - http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 976 - return 977 - } 978 - 979 - // Validate required fields 980 - if req.DID == "" || req.AccessToken == "" || req.PDSEndpoint == "" { 981 - http.Error(w, "missing required fields: did, access_token, pds_endpoint", http.StatusBadRequest) 982 - return 983 - } 984 - 985 - // Get public URL from config 986 - publicURL := s.config.Server.PublicURL 987 - if publicURL == "" { 988 - // Fallback to constructing URL from request 989 - scheme := "http" 990 - if r.TLS != nil { 991 - scheme = "https" 992 - } 993 - publicURL = fmt.Sprintf("%s://%s", scheme, r.Host) 994 - } 995 - 996 - // Derive hold name from URL 997 - holdName, err := extractHostname(publicURL) 998 - if err != nil { 999 - http.Error(w, fmt.Sprintf("failed to extract hostname: %v", err), http.StatusBadRequest) 1000 - return 1001 - } 1002 - 1003 - ctx := r.Context() 1004 - 1005 - // Create ATProto client with user's credentials 1006 - client := atproto.NewClient(req.PDSEndpoint, req.DID, req.AccessToken) 1007 - 1008 - // Create HoldRecord 1009 - holdRecord := atproto.NewHoldRecord(publicURL, req.DID, s.config.Server.Public) 1010 - 1011 - holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord) 1012 - if err != nil { 1013 - http.Error(w, fmt.Sprintf("failed to create hold record: %v", err), http.StatusInternalServerError) 1014 - return 1015 - } 1016 - 1017 - log.Printf("Created hold record: %s", holdResult.URI) 1018 - 1019 - // Create HoldCrewRecord for the owner 1020 - crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, req.DID, "owner") 1021 - 1022 - crewRKey := fmt.Sprintf("%s-%s", holdName, req.DID) 1023 - crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord) 1024 - if err != nil { 1025 - http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError) 1026 - return 1027 - } 1028 - 1029 - log.Printf("Created crew record: %s", crewResult.URI) 1030 - 1031 - resp := RegisterResponse{ 1032 - HoldURI: holdResult.URI, 1033 - CrewURI: crewResult.URI, 1034 - Message: fmt.Sprintf("Successfully registered hold service. Storage endpoint: %s", publicURL), 1035 - } 1036 - 1037 - w.Header().Set("Content-Type", "application/json") 1038 - json.NewEncoder(w).Encode(resp) 1039 - } 1040 - 1041 - // HealthHandler handles health check requests 1042 - func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) { 1043 - w.Header().Set("Content-Type", "application/json") 1044 - json.NewEncoder(w).Encode(map[string]string{ 1045 - "status": "ok", 1046 - }) 1047 - } 1048 - 1049 21 func main() { 1050 22 // Load configuration from environment variables 1051 - cfg, err := loadConfigFromEnv() 23 + cfg, err := hold.LoadConfigFromEnv() 1052 24 if err != nil { 1053 25 log.Fatalf("Failed to load config: %v", err) 1054 26 } 1055 27 1056 28 // Create hold service 1057 - service, err := NewHoldService(cfg) 29 + service, err := hold.NewHoldService(cfg) 1058 30 if err != nil { 1059 31 log.Fatalf("Failed to create hold service: %v", err) 1060 32 } ··· 1063 35 mux := http.NewServeMux() 1064 36 mux.HandleFunc("/health", service.HealthHandler) 1065 37 mux.HandleFunc("/register", service.HandleRegister) 1066 - mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL) 1067 - mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL) 38 + mux.HandleFunc("/presigned-url", service.HandlePresignedURL) 1068 39 mux.HandleFunc("/move", service.HandleMove) 1069 40 1070 41 // Multipart upload endpoints ··· 1073 44 mux.HandleFunc("/complete-multipart", service.HandleCompleteMultipart) 1074 45 mux.HandleFunc("/abort-multipart", service.HandleAbortMultipart) 1075 46 47 + // Buffered multipart part upload endpoint (for when presigned URLs are disabled/unavailable) 48 + mux.HandleFunc("/multipart-parts/", func(w http.ResponseWriter, r *http.Request) { 49 + if r.Method != http.MethodPut { 50 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 51 + return 52 + } 53 + 54 + // Parse URL: /multipart-parts/{uploadID}/{partNumber} 55 + path := r.URL.Path[len("/multipart-parts/"):] 56 + parts := strings.Split(path, "/") 57 + if len(parts) != 2 { 58 + http.Error(w, "invalid path format, expected /multipart-parts/{uploadID}/{partNumber}", http.StatusBadRequest) 59 + return 60 + } 61 + 62 + uploadID := parts[0] 63 + partNumber, err := strconv.Atoi(parts[1]) 64 + if err != nil { 65 + http.Error(w, fmt.Sprintf("invalid part number: %v", err), http.StatusBadRequest) 66 + return 67 + } 68 + 69 + // Get DID from query param 70 + did := r.URL.Query().Get("did") 71 + 72 + service.HandleMultipartPartUpload(w, r, uploadID, partNumber, did, service.MultipartMgr) 73 + }) 74 + 1076 75 // Pre-register OAuth callback route (will be populated by auto-registration) 1077 76 var oauthCallbackHandler http.HandlerFunc 1078 77 mux.HandleFunc("/auth/oauth/callback", func(w http.ResponseWriter, r *http.Request) { ··· 1156 155 log.Fatalf("Server failed: %v", err) 1157 156 } 1158 157 } 1159 - 1160 - // loadConfigFromEnv loads all configuration from environment variables 1161 - func loadConfigFromEnv() (*Config, error) { 1162 - cfg := &Config{ 1163 - Version: "0.1", 1164 - } 1165 - 1166 - // Server configuration 1167 - cfg.Server.Addr = getEnvOrDefault("HOLD_SERVER_ADDR", ":8080") 1168 - cfg.Server.PublicURL = os.Getenv("HOLD_PUBLIC_URL") 1169 - if cfg.Server.PublicURL == "" { 1170 - return nil, fmt.Errorf("HOLD_PUBLIC_URL is required") 1171 - } 1172 - cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true" 1173 - cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" 1174 - cfg.Server.ReadTimeout = 5 * time.Minute // Increased for large blob uploads 1175 - cfg.Server.WriteTimeout = 5 * time.Minute // Increased for large blob uploads 1176 - 1177 - // Registration configuration (optional) 1178 - cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER") 1179 - 1180 - // Storage configuration - build from env vars based on storage type 1181 - storageType := getEnvOrDefault("STORAGE_DRIVER", "s3") 1182 - var err error 1183 - cfg.Storage, err = buildStorageConfig(storageType) 1184 - if err != nil { 1185 - return nil, fmt.Errorf("failed to build storage config: %w", err) 1186 - } 1187 - 1188 - return cfg, nil 1189 - } 1190 - 1191 - // buildStorageConfig creates storage configuration based on driver type 1192 - func buildStorageConfig(driver string) (StorageConfig, error) { 1193 - params := make(map[string]any) 1194 - 1195 - switch driver { 1196 - case "s3": 1197 - // S3/Storj/Minio configuration from standard AWS env vars 1198 - accessKey := os.Getenv("AWS_ACCESS_KEY_ID") 1199 - secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") 1200 - region := getEnvOrDefault("AWS_REGION", "us-east-1") 1201 - bucket := os.Getenv("S3_BUCKET") 1202 - endpoint := os.Getenv("S3_ENDPOINT") // For Storj/Minio 1203 - 1204 - if bucket == "" { 1205 - return StorageConfig{}, fmt.Errorf("S3_BUCKET is required for S3 storage") 1206 - } 1207 - 1208 - params["accesskey"] = accessKey 1209 - params["secretkey"] = secretKey 1210 - params["region"] = region 1211 - params["bucket"] = bucket 1212 - if endpoint != "" { 1213 - params["regionendpoint"] = endpoint 1214 - } 1215 - 1216 - case "filesystem": 1217 - // Filesystem configuration 1218 - rootDir := getEnvOrDefault("STORAGE_ROOT_DIR", "/var/lib/atcr/hold") 1219 - params["rootdirectory"] = rootDir 1220 - 1221 - default: 1222 - return StorageConfig{}, fmt.Errorf("unsupported storage driver: %s", driver) 1223 - } 1224 - 1225 - // Build distribution Storage config 1226 - storageCfg := configuration.Storage{} 1227 - storageCfg[driver] = configuration.Parameters(params) 1228 - 1229 - return StorageConfig{Storage: storageCfg}, nil 1230 - } 1231 - 1232 - // getEnvOrDefault gets an environment variable or returns a default value 1233 - func getEnvOrDefault(key, defaultValue string) string { 1234 - if val := os.Getenv(key); val != "" { 1235 - return val 1236 - } 1237 - return defaultValue 1238 - } 1239 - 1240 - // blobPath converts a digest (e.g., "sha256:abc123...") or temp path to a storage path 1241 - // Distribution stores blobs as: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data 1242 - // where xx is the first 2 characters of the hash for directory sharding 1243 - // NOTE: Path must start with / for filesystem driver 1244 - func blobPath(digest string) string { 1245 - // Handle temp paths (start with uploads/temp-) 1246 - if strings.HasPrefix(digest, "uploads/temp-") { 1247 - return fmt.Sprintf("/docker/registry/v2/%s/data", digest) 1248 - } 1249 - 1250 - // Split digest into algorithm and hash 1251 - parts := strings.SplitN(digest, ":", 2) 1252 - if len(parts) != 2 { 1253 - // Fallback for malformed digest 1254 - return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) 1255 - } 1256 - 1257 - algorithm := parts[0] 1258 - hash := parts[1] 1259 - 1260 - // Use first 2 characters for sharding 1261 - if len(hash) < 2 { 1262 - return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash) 1263 - } 1264 - 1265 - return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash) 1266 - } 1267 - 1268 - // isHoldRegistered checks if a hold with the given public URL is already registered in the PDS 1269 - func (s *HoldService) isHoldRegistered(ctx context.Context, did, pdsEndpoint, publicURL string) (bool, error) { 1270 - // We need to query the PDS without authentication to check public records 1271 - // ATProto records are publicly readable, so we can use an unauthenticated client 1272 - client := atproto.NewClient(pdsEndpoint, did, "") 1273 - 1274 - // List all hold records for this DID 1275 - records, err := client.ListRecords(ctx, atproto.HoldCollection, 100) 1276 - if err != nil { 1277 - return false, fmt.Errorf("failed to list hold records: %w", err) 1278 - } 1279 - 1280 - // Check if any hold record matches our public URL 1281 - for _, record := range records { 1282 - var holdRecord atproto.HoldRecord 1283 - if err := json.Unmarshal(record.Value, &holdRecord); err != nil { 1284 - continue 1285 - } 1286 - 1287 - if holdRecord.Endpoint == publicURL { 1288 - return true, nil 1289 - } 1290 - } 1291 - 1292 - return false, nil 1293 - } 1294 - 1295 - // AutoRegister registers this hold service in the owner's PDS 1296 - // Checks if already registered first, then does OAuth if needed 1297 - func (s *HoldService) AutoRegister(callbackHandler *http.HandlerFunc) error { 1298 - reg := &s.config.Registration 1299 - publicURL := s.config.Server.PublicURL 1300 - 1301 - if publicURL == "" { 1302 - return fmt.Errorf("HOLD_PUBLIC_URL not set") 1303 - } 1304 - 1305 - if reg.OwnerDID == "" { 1306 - return fmt.Errorf("HOLD_OWNER not set - required for registration") 1307 - } 1308 - 1309 - ctx := context.Background() 1310 - 1311 - log.Printf("Checking registration status for DID: %s", reg.OwnerDID) 1312 - 1313 - // Resolve DID to PDS endpoint using indigo 1314 - directory := identity.DefaultDirectory() 1315 - didParsed, err := syntax.ParseDID(reg.OwnerDID) 1316 - if err != nil { 1317 - return fmt.Errorf("invalid owner DID: %w", err) 1318 - } 1319 - 1320 - ident, err := directory.LookupDID(ctx, didParsed) 1321 - if err != nil { 1322 - return fmt.Errorf("failed to resolve PDS for DID: %w", err) 1323 - } 1324 - 1325 - pdsEndpoint := ident.PDSEndpoint() 1326 - if pdsEndpoint == "" { 1327 - return fmt.Errorf("no PDS endpoint found for DID") 1328 - } 1329 - 1330 - log.Printf("PDS endpoint: %s", pdsEndpoint) 1331 - 1332 - // Check if hold is already registered 1333 - isRegistered, err := s.isHoldRegistered(ctx, reg.OwnerDID, pdsEndpoint, publicURL) 1334 - if err != nil { 1335 - log.Printf("Warning: failed to check registration status: %v", err) 1336 - log.Printf("Proceeding with OAuth registration...") 1337 - } else if isRegistered { 1338 - log.Printf("✓ Hold service already registered in PDS") 1339 - log.Printf("Public URL: %s", publicURL) 1340 - return nil 1341 - } 1342 - 1343 - // Not registered, need to do OAuth 1344 - log.Printf("Hold not registered, starting OAuth flow...") 1345 - 1346 - // Get handle from DID document (already resolved above) 1347 - handle := ident.Handle.String() 1348 - if handle == "" || handle == "handle.invalid" { 1349 - return fmt.Errorf("no valid handle found for DID") 1350 - } 1351 - 1352 - log.Printf("Resolved handle: %s", handle) 1353 - log.Printf("Starting OAuth registration for hold service") 1354 - log.Printf("Public URL: %s", publicURL) 1355 - 1356 - return s.registerWithOAuth(publicURL, handle, reg.OwnerDID, pdsEndpoint, callbackHandler) 1357 - } 1358 - 1359 - // registerWithOAuth performs OAuth flow and registers the hold 1360 - func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string, callbackHandler *http.HandlerFunc) error { 1361 - // Define the scopes we need for hold registration 1362 - holdScopes := []string{ 1363 - "atproto", 1364 - fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection), 1365 - fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection), 1366 - fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection), 1367 - fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), 1368 - fmt.Sprintf("repo:%s?action=create", atproto.SailorProfileCollection), 1369 - fmt.Sprintf("repo:%s?action=update", atproto.SailorProfileCollection), 1370 - } 1371 - 1372 - // Determine base URL based on mode 1373 - // Callback path standardized to /auth/oauth/callback across ATCR 1374 - var baseURL string 1375 - 1376 - if s.config.Server.TestMode { 1377 - // Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record 1378 - // Extract port from publicURL (e.g., "http://172.28.0.3:8080" -> ":8080") 1379 - parsedURL, err := url.Parse(publicURL) 1380 - if err != nil { 1381 - return fmt.Errorf("failed to parse public URL: %w", err) 1382 - } 1383 - port := parsedURL.Port() 1384 - if port == "" { 1385 - port = "8080" // default 1386 - } 1387 - baseURL = fmt.Sprintf("http://127.0.0.1:%s", port) 1388 - } else { 1389 - baseURL = publicURL 1390 - } 1391 - 1392 - // Run interactive OAuth flow with persistent server 1393 - ctx := context.Background() 1394 - 1395 - result, err := oauth.InteractiveFlowWithCallback( 1396 - ctx, 1397 - baseURL, 1398 - handle, 1399 - holdScopes, // Pass hold-specific scopes 1400 - func(handler http.HandlerFunc) error { 1401 - // Populate the pre-registered callback handler 1402 - *callbackHandler = handler 1403 - return nil 1404 - }, 1405 - func(authURL string) error { 1406 - // Display OAuth URL for user to visit 1407 - log.Print("\n" + strings.Repeat("=", 80)) 1408 - log.Printf("OAUTH AUTHORIZATION REQUIRED") 1409 - log.Print(strings.Repeat("=", 80)) 1410 - log.Printf("\nPlease visit this URL to authorize the hold service:\n") 1411 - log.Printf(" %s\n", authURL) 1412 - log.Printf("Waiting for authorization...") 1413 - log.Print(strings.Repeat("=", 80) + "\n") 1414 - return nil 1415 - }, 1416 - ) 1417 - if err != nil { 1418 - return err 1419 - } 1420 - 1421 - log.Printf("Authorization received!") 1422 - log.Printf("OAuth session obtained successfully") 1423 - log.Printf("DID: %s", did) 1424 - log.Printf("PDS: %s", pdsEndpoint) 1425 - 1426 - // Create ATProto client with indigo's API client (handles DPoP automatically) 1427 - apiClient := result.Session.APIClient() 1428 - client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient) 1429 - 1430 - return s.registerWithClient(publicURL, did, client) 1431 - } 1432 - 1433 - // registerWithClient registers the hold using an authenticated ATProto client 1434 - func (s *HoldService) registerWithClient(publicURL, did string, client *atproto.Client) error { 1435 - // Derive hold name from URL (hostname) 1436 - holdName, err := extractHostname(publicURL) 1437 - if err != nil { 1438 - return fmt.Errorf("failed to extract hostname from URL: %w", err) 1439 - } 1440 - 1441 - log.Printf("Registering hold service: url=%s, name=%s, owner=%s", publicURL, holdName, did) 1442 - 1443 - ctx := context.Background() 1444 - 1445 - // Create HoldRecord 1446 - holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public) 1447 - 1448 - // Use hostname as record key 1449 - holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord) 1450 - if err != nil { 1451 - return fmt.Errorf("failed to create hold record: %w", err) 1452 - } 1453 - 1454 - log.Printf("✓ Created hold record: %s", holdResult.URI) 1455 - 1456 - // Create HoldCrewRecord for the owner 1457 - crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, did, "owner") 1458 - 1459 - crewRKey := fmt.Sprintf("%s-%s", holdName, did) 1460 - crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord) 1461 - if err != nil { 1462 - return fmt.Errorf("failed to create crew record: %w", err) 1463 - } 1464 - 1465 - log.Printf("✓ Created crew record: %s", crewResult.URI) 1466 - 1467 - // Update sailor profile to set this as the default hold 1468 - profile, err := atproto.GetProfile(ctx, client) 1469 - if err != nil { 1470 - log.Printf("Warning: failed to get sailor profile: %v", err) 1471 - } else { 1472 - if profile == nil { 1473 - // Create new profile with this hold as default 1474 - profile = atproto.NewSailorProfileRecord(publicURL) 1475 - } else { 1476 - // Update existing profile with new defaultHold 1477 - profile.DefaultHold = publicURL 1478 - profile.UpdatedAt = time.Now() 1479 - } 1480 - 1481 - err = atproto.UpdateProfile(ctx, client, profile) 1482 - if err != nil { 1483 - log.Printf("Warning: failed to update sailor profile: %v", err) 1484 - } else { 1485 - log.Printf("✓ Updated sailor profile defaultHold: %s", publicURL) 1486 - } 1487 - } 1488 - 1489 - log.Print("\n" + strings.Repeat("=", 80)) 1490 - log.Printf("REGISTRATION COMPLETE") 1491 - log.Print(strings.Repeat("=", 80)) 1492 - log.Printf("Hold service is now registered and ready to use!") 1493 - log.Print(strings.Repeat("=", 80) + "\n") 1494 - 1495 - return nil 1496 - } 1497 - 1498 - // extractHostname extracts the hostname from a URL to use as the hold name 1499 - func extractHostname(urlStr string) (string, error) { 1500 - u, err := url.Parse(urlStr) 1501 - if err != nil { 1502 - return "", err 1503 - } 1504 - // Remove port if present 1505 - hostname := u.Hostname() 1506 - if hostname == "" { 1507 - return "", fmt.Errorf("no hostname in URL") 1508 - } 1509 - return hostname, nil 1510 - }
+1
docker-compose.yml
··· 47 47 # STORAGE_DRIVER: filesystem 48 48 # STORAGE_ROOT_DIR: /var/lib/atcr/hold 49 49 TEST_MODE: true 50 + # DISABLE_PRESIGNED_URLS: true 50 51 # Storage config comes from env_file (STORAGE_DRIVER, AWS_*, S3_*) 51 52 build: 52 53 context: .
+460
docs/HOLD_MULTIPART.md
··· 1 + # Hold Service Multipart Upload Architecture 2 + 3 + ## Overview 4 + 5 + The hold service supports multipart uploads through two modes: 6 + 1. **S3Native** - Uses S3's native multipart API with presigned URLs (optimal) 7 + 2. **Buffered** - Buffers parts in hold service memory, assembles on completion (fallback) 8 + 9 + This dual-mode approach enables the hold service to work with: 10 + - S3-compatible storage with presigned URL support (S3, Storj, MinIO, etc.) 11 + - S3-compatible storage WITHOUT presigned URL support 12 + - Filesystem storage 13 + - Any storage driver supported by distribution 14 + 15 + ## Current State 16 + 17 + ### What Works ✅ 18 + - **S3 Native Mode with presigned URLs**: Fully working! Direct uploads to S3 via presigned URLs 19 + - **Buffered mode with S3**: Tested and working with `DISABLE_PRESIGNED_URLS=true` 20 + - **Filesystem storage**: Tested and working! Buffered mode with filesystem driver 21 + - **AppView multipart client**: Implements chunked uploads via multipart API 22 + - **MultipartManager**: Session tracking, automatic cleanup, thread-safe operations 23 + - **Automatic fallback**: Falls back to buffered mode when S3 unavailable or disabled 24 + - **ETag normalization**: Handles quoted/unquoted ETags from S3 25 + - **Route handler**: `/multipart-parts/{uploadID}/{partNumber}` endpoint added and tested 26 + 27 + ### All Implementation Complete! 🎉 28 + All three multipart upload modes are fully implemented, tested, and working in production. 29 + 30 + ### Bugs Fixed 🔧 31 + - **Missing S3 parts in complete**: For S3Native mode, parts uploaded directly to S3 weren't being recorded. Fixed by storing parts from request in `HandleCompleteMultipart` before calling `CompleteMultipartUploadWithManager`. 32 + - **Malformed XML error from S3**: S3 requires ETags to be quoted in CompleteMultipartUpload XML. Added `normalizeETag()` function to ensure quotes are present. 33 + - **Route missing**: `/multipart-parts/{uploadID}/{partNumber}` not registered in cmd/hold/main.go. Fixed by adding route handler with path parsing. 34 + - **MultipartMgr access**: Field was private, preventing route handler access. Fixed by exporting as `MultipartMgr`. 35 + - **DISABLE_PRESIGNED_URLS not logged**: `initS3Client()` didn't check the flag before initializing. Fixed with early return check and proper logging. 36 + 37 + ## Architecture 38 + 39 + ### Three Modes of Operation 40 + 41 + #### Mode 1: S3 Native Multipart ✅ WORKING 42 + ``` 43 + Docker → AppView → Hold → S3 (presigned URLs) 44 + 45 + Returns presigned URL 46 + 47 + Docker ──────────→ S3 (direct upload) 48 + ``` 49 + 50 + **Flow:** 51 + 1. AppView: `POST /start-multipart` → Hold starts S3 multipart, returns uploadID 52 + 2. AppView: `POST /part-presigned-url` → Hold returns S3 presigned URL 53 + 3. Docker → S3: Direct upload via presigned URL 54 + 4. AppView: `POST /complete-multipart` → Hold calls S3 CompleteMultipartUpload 55 + 56 + **Advantages:** 57 + - No data flows through hold service 58 + - Minimal bandwidth usage 59 + - Fast uploads 60 + 61 + #### Mode 2: S3 Proxy Mode (Buffered) ✅ WORKING 62 + ``` 63 + Docker → AppView → Hold → S3 (via driver) 64 + 65 + Buffers & proxies 66 + 67 + S3 68 + ``` 69 + 70 + **Flow:** 71 + 1. AppView: `POST /start-multipart` → Hold creates buffered session 72 + 2. AppView: `POST /part-presigned-url` → Hold returns proxy URL 73 + 3. Docker → Hold: `PUT /multipart-parts/{uploadID}/{part}` → Hold buffers 74 + 4. AppView: `POST /complete-multipart` → Hold uploads to S3 via driver 75 + 76 + **Use Cases:** 77 + - S3 provider doesn't support presigned URLs 78 + - S3 API fails to generate presigned URL 79 + - Fallback from Mode 1 80 + 81 + #### Mode 3: Filesystem Mode ✅ WORKING 82 + ``` 83 + Docker → AppView → Hold (filesystem driver) 84 + 85 + Buffers & writes 86 + 87 + Local filesystem 88 + ``` 89 + 90 + **Flow:** 91 + Same as Mode 2, but writes to filesystem driver instead of S3 driver. 92 + 93 + **Use Cases:** 94 + - Development/testing with local filesystem 95 + - Small deployments without S3 96 + - Air-gapped environments 97 + 98 + ## Implementation: pkg/hold/multipart.go 99 + 100 + ### Core Components 101 + 102 + #### MultipartManager 103 + ```go 104 + type MultipartManager struct { 105 + sessions map[string]*MultipartSession 106 + mu sync.RWMutex 107 + } 108 + ``` 109 + 110 + **Responsibilities:** 111 + - Track active multipart sessions 112 + - Clean up abandoned uploads (>24h inactive) 113 + - Thread-safe session access 114 + 115 + #### MultipartSession 116 + ```go 117 + type MultipartSession struct { 118 + UploadID string // Unique ID for this upload 119 + Digest string // Target blob digest 120 + Mode MultipartMode // S3Native or Buffered 121 + S3UploadID string // S3 upload ID (S3Native only) 122 + Parts map[int]*MultipartPart // Buffered parts (Buffered only) 123 + CreatedAt time.Time 124 + LastActivity time.Time 125 + } 126 + ``` 127 + 128 + **State Tracking:** 129 + - S3Native: Tracks S3 upload ID and part ETags 130 + - Buffered: Stores part data in memory 131 + 132 + #### MultipartPart 133 + ```go 134 + type MultipartPart struct { 135 + PartNumber int // Part number (1-indexed) 136 + Data []byte // Part data (Buffered mode only) 137 + ETag string // S3 ETag or computed hash 138 + Size int64 139 + } 140 + ``` 141 + 142 + ### Key Methods 143 + 144 + #### StartMultipartUploadWithManager 145 + ```go 146 + func (s *HoldService) StartMultipartUploadWithManager( 147 + ctx context.Context, 148 + digest string, 149 + manager *MultipartManager, 150 + ) (string, MultipartMode, error) 151 + ``` 152 + 153 + **Logic:** 154 + 1. Try S3 native multipart via `s.startMultipartUpload()` 155 + 2. If successful → Create S3Native session 156 + 3. If fails or no S3 client → Create Buffered session 157 + 4. Return uploadID and mode 158 + 159 + #### GetPartUploadURL 160 + ```go 161 + func (s *HoldService) GetPartUploadURL( 162 + ctx context.Context, 163 + session *MultipartSession, 164 + partNumber int, 165 + did string, 166 + ) (string, error) 167 + ``` 168 + 169 + **Logic:** 170 + - S3Native mode: Generate S3 presigned URL via `s.getPartPresignedURL()` 171 + - Buffered mode: Return proxy endpoint `/multipart-parts/{uploadID}/{part}` 172 + 173 + #### CompleteMultipartUploadWithManager 174 + ```go 175 + func (s *HoldService) CompleteMultipartUploadWithManager( 176 + ctx context.Context, 177 + session *MultipartSession, 178 + manager *MultipartManager, 179 + ) error 180 + ``` 181 + 182 + **Logic:** 183 + - S3Native: Call `s.completeMultipartUpload()` with S3 API 184 + - Buffered: Assemble parts in order, write via storage driver 185 + 186 + #### HandleMultipartPartUpload (New Endpoint) 187 + ```go 188 + func (s *HoldService) HandleMultipartPartUpload( 189 + w http.ResponseWriter, 190 + r *http.Request, 191 + uploadID string, 192 + partNumber int, 193 + did string, 194 + manager *MultipartManager, 195 + ) 196 + ``` 197 + 198 + **New HTTP endpoint:** `PUT /multipart-parts/{uploadID}/{partNumber}` 199 + 200 + **Purpose:** Receive part uploads in Buffered mode 201 + 202 + **Logic:** 203 + 1. Validate session exists and is in Buffered mode 204 + 2. Authorize write access 205 + 3. Read part data from request body 206 + 4. Store in session with computed ETag (SHA256) 207 + 5. Return ETag in response header 208 + 209 + ## Integration Plan 210 + 211 + ### Phase 1: Migrate to pkg/hold (COMPLETE) 212 + - [x] Extract code from cmd/hold/main.go to pkg/hold/ 213 + - [x] Create isolated multipart.go implementation 214 + - [x] Update cmd/hold/main.go to import pkg/hold 215 + - [x] Test existing functionality works 216 + 217 + ### Phase 2: Add Buffered Mode Support (COMPLETE ✅) 218 + - [x] Add MultipartManager to HoldService 219 + - [x] Update handlers to use `*WithManager` methods 220 + - [x] Add DISABLE_PRESIGNED_URLS environment variable for testing 221 + - [x] Implement presigned URL disable checks in all methods 222 + - [x] **Fixed: Record S3 parts from request in HandleCompleteMultipart** 223 + - [x] **Fixed: ETag normalization (add quotes for S3 XML)** 224 + - [x] **Test S3 native mode with presigned URLs** ✅ WORKING 225 + - [x] **Add route in cmd/hold/main.go** ✅ COMPLETE 226 + - [x] **Export MultipartMgr field for route handler access** ✅ COMPLETE 227 + - [x] **Test DISABLE_PRESIGNED_URLS=true with S3 storage** ✅ WORKING 228 + - [x] **Test filesystem storage with buffered multipart** ✅ WORKING 229 + 230 + ### Phase 3: Update AppView 231 + - [ ] Detect hold capabilities (presigned vs proxy) 232 + - [ ] Fallback to buffered mode when presigned fails 233 + - [ ] Handle `/multipart-parts/` proxy URLs 234 + 235 + ### Phase 4: Capability Discovery 236 + - [ ] Add capability endpoint: `GET /capabilities` 237 + - [ ] Return: `{"multipart": "native|buffered|both", "storage": "s3|filesystem"}` 238 + - [ ] AppView uses capabilities to choose upload strategy 239 + 240 + ## Testing Strategy 241 + 242 + ### Unit Tests 243 + - [ ] MultipartManager session lifecycle 244 + - [ ] Part buffering and assembly 245 + - [ ] Concurrent part uploads (thread safety) 246 + - [ ] Session cleanup (expired uploads) 247 + 248 + ### Integration Tests 249 + 250 + **S3 Native Mode:** 251 + - [x] Start multipart → get presigned URLs → upload parts → complete ✅ WORKING 252 + - [x] Verify no data flows through hold service (only ~1KB API calls) 253 + - [ ] Test abort cleanup 254 + 255 + **Buffered Mode (S3 with DISABLE_PRESIGNED_URLS):** 256 + - [x] Start multipart → get proxy URLs → upload parts → complete ✅ WORKING 257 + - [x] Verify parts assembled correctly 258 + - [ ] Test missing part detection 259 + - [ ] Test abort cleanup 260 + 261 + **Buffered Mode (Filesystem):** 262 + - [x] Start multipart → get proxy URLs → upload parts → complete ✅ WORKING 263 + - [x] Verify parts assembled correctly ✅ WORKING 264 + - [x] Verify blobs written to filesystem ✅ WORKING 265 + - [ ] Test missing part detection 266 + - [ ] Test abort cleanup 267 + 268 + ### Load Tests 269 + - [ ] Concurrent multipart uploads (multiple sessions) 270 + - [ ] Large blobs (100MB+, many parts) 271 + - [ ] Memory usage with many buffered parts 272 + 273 + ## Performance Considerations 274 + 275 + ### Memory Usage (Buffered Mode) 276 + - Parts stored in memory until completion 277 + - Docker typically uses 5MB chunks (S3 minimum) 278 + - 100MB image = ~20 parts = ~100MB RAM during upload 279 + - Multiple concurrent uploads multiply memory usage 280 + 281 + **Mitigation:** 282 + - Session cleanup (24h timeout) 283 + - Consider disk-backed buffering for large parts (future optimization) 284 + - Monitor memory usage and set limits 285 + 286 + ### Network Bandwidth 287 + - S3Native: Minimal (only API calls) 288 + - Buffered: Full blob data flows through hold service 289 + - Filesystem: Always buffered (no presigned URL option) 290 + 291 + ## Configuration 292 + 293 + ### Environment Variables 294 + 295 + **Current (S3 only):** 296 + ```bash 297 + STORAGE_DRIVER=s3 298 + S3_BUCKET=my-bucket 299 + S3_ENDPOINT=https://s3.amazonaws.com 300 + AWS_ACCESS_KEY_ID=... 301 + AWS_SECRET_ACCESS_KEY=... 302 + ``` 303 + 304 + **Filesystem:** 305 + ```bash 306 + STORAGE_DRIVER=filesystem 307 + STORAGE_ROOT_DIR=/var/lib/atcr/hold 308 + ``` 309 + 310 + ### Automatic Mode Selection 311 + No configuration needed - hold service automatically: 312 + 1. Tries S3 native multipart if S3 client exists 313 + 2. Falls back to buffered mode if S3 unavailable or fails 314 + 3. Always uses buffered mode for filesystem driver 315 + 316 + ## Security Considerations 317 + 318 + ### Authorization 319 + - All multipart operations require write authorization 320 + - Buffered mode: Check auth on every part upload 321 + - S3Native: Auth only on start/complete (presigned URLs have embedded auth) 322 + 323 + ### Resource Limits 324 + - Max upload size: Controlled by storage backend 325 + - Max concurrent uploads: Limited by memory 326 + - Session timeout: 24 hours (configurable) 327 + 328 + ### Attack Vectors 329 + - **Memory exhaustion**: Attacker uploads many large parts 330 + - Mitigation: Session limits, cleanup, auth 331 + - **Incomplete uploads**: Attacker starts but never completes 332 + - Mitigation: 24h timeout, cleanup goroutine 333 + - **Part flooding**: Upload many tiny parts 334 + - Mitigation: S3 has 10,000 part limit, could add to buffered mode 335 + 336 + ## Future Enhancements 337 + 338 + ### Disk-Backed Buffering 339 + Instead of memory, buffer parts to temporary disk location: 340 + - Reduces memory pressure 341 + - Supports larger uploads 342 + - Requires cleanup on completion/abort 343 + 344 + ### Parallel Part Assembly 345 + For large uploads, assemble parts in parallel: 346 + - Stream parts to writer as they arrive 347 + - Reduce memory footprint 348 + - Faster completion 349 + 350 + ### Chunked Completion 351 + For very large assembled blobs: 352 + - Stream to storage driver in chunks 353 + - Avoid loading entire blob in memory 354 + - Use `io.Copy()` with buffer 355 + 356 + ### Multi-Backend Support 357 + - Azure Blob Storage multipart 358 + - Google Cloud Storage resumable uploads 359 + - Backblaze B2 large file API 360 + 361 + ## Implementation Complete ✅ 362 + 363 + The buffered multipart mode is fully implemented with the following components: 364 + 365 + **Route Handler** (`cmd/hold/main.go:47-73`): 366 + - Endpoint: `PUT /multipart-parts/{uploadID}/{partNumber}` 367 + - Parses URL path to extract uploadID and partNumber 368 + - Delegates to `service.HandleMultipartPartUpload()` 369 + 370 + **Exported Manager** (`pkg/hold/service.go:20`): 371 + - Field `MultipartMgr` is now exported for route handler access 372 + - All handlers updated to use `s.MultipartMgr` 373 + 374 + **Configuration Check** (`pkg/hold/s3.go:20-25`): 375 + - `initS3Client()` checks `DISABLE_PRESIGNED_URLS` flag before initializing 376 + - Logs clear message when presigned URLs are disabled 377 + - Prevents misleading "S3 presigned URLs enabled" message 378 + 379 + ## Testing Multipart Modes 380 + 381 + ### Test 1: S3 Native Mode (presigned URLs) ✅ TESTED 382 + ```bash 383 + export STORAGE_DRIVER=s3 384 + export S3_BUCKET=your-bucket 385 + export AWS_ACCESS_KEY_ID=... 386 + export AWS_SECRET_ACCESS_KEY=... 387 + # Do NOT set DISABLE_PRESIGNED_URLS 388 + 389 + # Start hold service 390 + ./bin/atcr-hold 391 + 392 + # Push an image 393 + docker push atcr.io/yourdid/test:latest 394 + 395 + # Expected logs: 396 + # "✅ S3 presigned URLs enabled" 397 + # "Started S3 native multipart: uploadID=... s3UploadID=..." 398 + # "Completed multipart upload: digest=... uploadID=... parts=..." 399 + ``` 400 + 401 + **Status**: ✅ Working - Direct uploads to S3, minimal bandwidth through hold service 402 + 403 + ### Test 2: Buffered Mode with S3 (forced proxy) ✅ TESTED 404 + ```bash 405 + export STORAGE_DRIVER=s3 406 + export S3_BUCKET=your-bucket 407 + export AWS_ACCESS_KEY_ID=... 408 + export AWS_SECRET_ACCESS_KEY=... 409 + export DISABLE_PRESIGNED_URLS=true # Force buffered mode 410 + 411 + # Start hold service 412 + ./bin/atcr-hold 413 + 414 + # Push an image 415 + docker push atcr.io/yourdid/test:latest 416 + 417 + # Expected logs: 418 + # "⚠️ S3 presigned URLs DISABLED by config (DISABLE_PRESIGNED_URLS=true)" 419 + # "Presigned URLs disabled (DISABLE_PRESIGNED_URLS=true), using buffered mode" 420 + # "Stored part: uploadID=... part=1 size=..." 421 + # "Assembled buffered parts: uploadID=... parts=... totalSize=..." 422 + # "Completed buffered multipart: uploadID=... size=... written=..." 423 + ``` 424 + 425 + **Status**: ✅ Working - Parts buffered in hold service memory, assembled and written to S3 via driver 426 + 427 + ### Test 3: Filesystem Mode (always buffered) ✅ TESTED 428 + ```bash 429 + export STORAGE_DRIVER=filesystem 430 + export STORAGE_ROOT_DIR=/tmp/atcr-hold-test 431 + # DISABLE_PRESIGNED_URLS not needed (filesystem never has presigned URLs) 432 + 433 + # Start hold service 434 + ./bin/atcr-hold 435 + 436 + # Push an image 437 + docker push atcr.io/yourdid/test:latest 438 + 439 + # Expected logs: 440 + # "Storage driver is filesystem (not S3), presigned URLs disabled" 441 + # "Started buffered multipart: uploadID=..." 442 + # "Stored part: uploadID=... part=1 size=..." 443 + # "Assembled buffered parts: uploadID=... parts=... totalSize=..." 444 + # "Completed buffered multipart: uploadID=... size=... written=..." 445 + 446 + # Verify blobs written to: 447 + ls -lh /var/lib/atcr/hold/docker/registry/v2/blobs/sha256/ 448 + # Or from outside container: 449 + docker exec atcr-hold ls -lh /var/lib/atcr/hold/docker/registry/v2/blobs/sha256/ 450 + ``` 451 + 452 + **Status**: ✅ Working - Parts buffered in memory, assembled, and written to filesystem via driver 453 + 454 + **Note**: Initial HEAD requests will show "Path not found" errors - this is normal! Docker checks if blobs exist before uploading. The errors occur for blobs that haven't been uploaded yet. After upload, subsequent HEAD checks succeed. 455 + 456 + ## References 457 + 458 + - S3 Multipart Upload API: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html 459 + - Distribution Storage Driver Interface: https://github.com/distribution/distribution/blob/main/registry/storage/driver/storagedriver.go 460 + - OCI Distribution Spec (Blob Upload): https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
+448
docs/MULTIPART_OLD.md
··· 1 + S3 Multipart Upload Implementation Plan 2 + Problem Summary 3 + Current implementation uses a single presigned URL with a pipe for chunked uploads (PATCH). This causes: 4 + - Docker PATCH requests block waiting for pipe writes 5 + - S3 upload happens in background via single presigned URL 6 + - Docker times out → "client disconnected during blob PATCH" 7 + - Root cause: Single presigned URLs don't support OCI's chunked upload protocol 8 + Solution: S3 Multipart Upload API 9 + Implement proper S3 multipart upload to support Docker's chunked PATCH operations: 10 + - Each PATCH → separate S3 part upload with its own presigned URL 11 + - On Commit → complete multipart upload 12 + - No buffering, no pipes, no blocking 13 + --- 14 + Architecture Changes 15 + Current (Broken) Flow 16 + POST /blobs/uploads/ → Create() → Single presigned URL to temp location 17 + PATCH → Write to pipe → [blocks] → Background goroutine uploads via single URL 18 + PATCH → [blocks on pipe] → Docker timeout → disconnect ❌ 19 + New (Multipart) Flow 20 + POST /blobs/uploads/ → Create() → Initiate multipart upload, get upload ID 21 + PATCH #1 → Get presigned URL for part 1 → Upload part 1 to S3 → Store ETag 22 + PATCH #2 → Get presigned URL for part 2 → Upload part 2 to S3 → Store ETag 23 + PUT (commit) → Complete multipart upload with ETags → Done ✅ 24 + --- 25 + Implementation Details 26 + 1. Hold Service: Add Multipart Upload Endpoints 27 + File: cmd/hold/main.go 28 + New Request/Response Types 29 + // StartMultipartUploadRequest initiates a multipart upload 30 + type StartMultipartUploadRequest struct { 31 + DID string `json:"did"` 32 + Digest string `json:"digest"` 33 + } 34 + type StartMultipartUploadResponse struct { 35 + UploadID string `json:"upload_id"` 36 + ExpiresAt time.Time `json:"expires_at"` 37 + } 38 + // GetPartURLRequest requests a presigned URL for a specific part 39 + type GetPartURLRequest struct { 40 + DID string `json:"did"` 41 + Digest string `json:"digest"` 42 + UploadID string `json:"upload_id"` 43 + PartNumber int `json:"part_number"` 44 + } 45 + type GetPartURLResponse struct { 46 + URL string `json:"url"` 47 + ExpiresAt time.Time `json:"expires_at"` 48 + } 49 + // CompleteMultipartRequest completes a multipart upload 50 + type CompleteMultipartRequest struct { 51 + DID string `json:"did"` 52 + Digest string `json:"digest"` 53 + UploadID string `json:"upload_id"` 54 + Parts []CompletedPart `json:"parts"` 55 + } 56 + type CompletedPart struct { 57 + PartNumber int `json:"part_number"` 58 + ETag string `json:"etag"` 59 + } 60 + // AbortMultipartRequest aborts an in-progress upload 61 + type AbortMultipartRequest struct { 62 + DID string `json:"did"` 63 + Digest string `json:"digest"` 64 + UploadID string `json:"upload_id"` 65 + } 66 + New Endpoints 67 + POST /start-multipart 68 + func (s *HoldService) HandleStartMultipart(w http.ResponseWriter, r *http.Request) { 69 + // Validate DID authorization for WRITE 70 + // Build S3 key from digest 71 + // Call s3.CreateMultipartUploadRequest() 72 + // Generate presigned URL if needed, or return upload ID 73 + // Return upload ID to client 74 + } 75 + POST /part-presigned-url 76 + func (s *HoldService) HandleGetPartURL(w http.ResponseWriter, r *http.Request) { 77 + // Validate DID authorization for WRITE 78 + // Build S3 key from digest 79 + // Call s3.UploadPartRequest() with part number and upload ID 80 + // Generate presigned URL 81 + // Return presigned URL for this specific part 82 + } 83 + POST /complete-multipart 84 + func (s *HoldService) HandleCompleteMultipart(w http.ResponseWriter, r *http.Request) { 85 + // Validate DID authorization for WRITE 86 + // Build S3 key from digest 87 + // Prepare CompletedPart array with part numbers and ETags 88 + // Call s3.CompleteMultipartUpload() 89 + // Return success 90 + } 91 + POST /abort-multipart (for cleanup) 92 + func (s *HoldService) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) { 93 + // Validate DID authorization for WRITE 94 + // Call s3.AbortMultipartUpload() 95 + // Return success 96 + } 97 + S3 Implementation 98 + // startMultipartUpload initiates a multipart upload and returns upload ID 99 + func (s *HoldService) startMultipartUpload(ctx context.Context, digest string) (string, error) { 100 + if s.s3Client == nil { 101 + return "", fmt.Errorf("S3 not configured") 102 + } 103 + path := blobPath(digest) 104 + s3Key := strings.TrimPrefix(path, "/") 105 + if s.s3PathPrefix != "" { 106 + s3Key = s.s3PathPrefix + "/" + s3Key 107 + } 108 + result, err := s.s3Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{ 109 + Bucket: aws.String(s.bucket), 110 + Key: aws.String(s3Key), 111 + }) 112 + if err != nil { 113 + return "", err 114 + } 115 + return *result.UploadId, nil 116 + } 117 + // getPartPresignedURL generates presigned URL for a specific part 118 + func (s *HoldService) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) { 119 + if s.s3Client == nil { 120 + return "", fmt.Errorf("S3 not configured") 121 + } 122 + path := blobPath(digest) 123 + s3Key := strings.TrimPrefix(path, "/") 124 + if s.s3PathPrefix != "" { 125 + s3Key = s.s3PathPrefix + "/" + s3Key 126 + } 127 + req, _ := s.s3Client.UploadPartRequest(&s3.UploadPartInput{ 128 + Bucket: aws.String(s.bucket), 129 + Key: aws.String(s3Key), 130 + UploadId: aws.String(uploadID), 131 + PartNumber: aws.Int64(int64(partNumber)), 132 + }) 133 + return req.Presign(15 * time.Minute) 134 + } 135 + // completeMultipartUpload finalizes the multipart upload 136 + func (s *HoldService) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error { 137 + if s.s3Client == nil { 138 + return fmt.Errorf("S3 not configured") 139 + } 140 + path := blobPath(digest) 141 + s3Key := strings.TrimPrefix(path, "/") 142 + if s.s3PathPrefix != "" { 143 + s3Key = s.s3PathPrefix + "/" + s3Key 144 + } 145 + // Convert to S3 CompletedPart format 146 + s3Parts := make([]*s3.CompletedPart, len(parts)) 147 + for i, p := range parts { 148 + s3Parts[i] = &s3.CompletedPart{ 149 + PartNumber: aws.Int64(int64(p.PartNumber)), 150 + ETag: aws.String(p.ETag), 151 + } 152 + } 153 + _, err := s.s3Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{ 154 + Bucket: aws.String(s.bucket), 155 + Key: aws.String(s3Key), 156 + UploadId: aws.String(uploadID), 157 + MultipartUpload: &s3.CompletedMultipartUpload{ 158 + Parts: s3Parts, 159 + }, 160 + }) 161 + return err 162 + } 163 + --- 164 + 2. AppView: Rewrite ProxyBlobStore for Multipart 165 + File: pkg/storage/proxy_blob_store.go 166 + Remove Current Implementation 167 + - Remove pipe-based streaming 168 + - Remove background goroutine with single presigned URL 169 + - Remove global upload tracking map 170 + New ProxyBlobWriter Structure 171 + type ProxyBlobWriter struct { 172 + store *ProxyBlobStore 173 + options distribution.CreateOptions 174 + uploadID string // S3 multipart upload ID 175 + parts []CompletedPart // Track uploaded parts with ETags 176 + partNumber int // Current part number (starts at 1) 177 + buffer *bytes.Buffer // Buffer for current part 178 + size int64 // Total bytes written 179 + closed bool 180 + id string // Distribution's upload ID (for state) 181 + startedAt time.Time 182 + finalDigest string // Set on Commit 183 + } 184 + type CompletedPart struct { 185 + PartNumber int 186 + ETag string 187 + } 188 + New Create() - Initiate Multipart Upload 189 + func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) { 190 + var opts distribution.CreateOptions 191 + for _, option := range options { 192 + if err := option.Apply(&opts); err != nil { 193 + return nil, err 194 + } 195 + } 196 + // Use temp digest for upload location 197 + writerID := fmt.Sprintf("upload-%d", time.Now().UnixNano()) 198 + tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", writerID)) 199 + // Start multipart upload via hold service 200 + uploadID, err := p.startMultipartUpload(ctx, tempDigest) 201 + if err != nil { 202 + return nil, fmt.Errorf("failed to start multipart upload: %w", err) 203 + } 204 + writer := &ProxyBlobWriter{ 205 + store: p, 206 + options: opts, 207 + uploadID: uploadID, 208 + parts: make([]CompletedPart, 0), 209 + partNumber: 1, 210 + buffer: bytes.NewBuffer(make([]byte, 0, 5*1024*1024)), // 5MB buffer 211 + id: writerID, 212 + startedAt: time.Now(), 213 + } 214 + // Store in global map for Resume() 215 + globalUploadsMu.Lock() 216 + globalUploads[writer.id] = writer 217 + globalUploadsMu.Unlock() 218 + return writer, nil 219 + } 220 + New Write() - Buffer and Flush Parts 221 + func (w *ProxyBlobWriter) Write(p []byte) (int, error) { 222 + if w.closed { 223 + return 0, fmt.Errorf("writer closed") 224 + } 225 + n, err := w.buffer.Write(p) 226 + w.size += int64(n) 227 + // Flush if buffer reaches 5MB (S3 minimum part size) 228 + if w.buffer.Len() >= 5*1024*1024 { 229 + if err := w.flushPart(); err != nil { 230 + return n, err 231 + } 232 + } 233 + return n, err 234 + } 235 + func (w *ProxyBlobWriter) flushPart() error { 236 + if w.buffer.Len() == 0 { 237 + return nil 238 + } 239 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 240 + defer cancel() 241 + // Get presigned URL for this part 242 + tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", w.id)) 243 + url, err := w.store.getPartPresignedURL(ctx, tempDigest, w.uploadID, w.partNumber) 244 + if err != nil { 245 + return fmt.Errorf("failed to get part presigned URL: %w", err) 246 + } 247 + // Upload part to S3 248 + req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(w.buffer.Bytes())) 249 + if err != nil { 250 + return err 251 + } 252 + resp, err := w.store.httpClient.Do(req) 253 + if err != nil { 254 + return err 255 + } 256 + defer resp.Body.Close() 257 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 258 + return fmt.Errorf("part upload failed: status %d", resp.StatusCode) 259 + } 260 + // Store ETag for completion 261 + etag := resp.Header.Get("ETag") 262 + if etag == "" { 263 + return fmt.Errorf("no ETag in response") 264 + } 265 + w.parts = append(w.parts, CompletedPart{ 266 + PartNumber: w.partNumber, 267 + ETag: etag, 268 + }) 269 + // Reset buffer and increment part number 270 + w.buffer.Reset() 271 + w.partNumber++ 272 + return nil 273 + } 274 + New Commit() - Complete Multipart and Move 275 + func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) { 276 + if w.closed { 277 + return distribution.Descriptor{}, fmt.Errorf("writer closed") 278 + } 279 + w.closed = true 280 + // Flush any remaining buffered data 281 + if w.buffer.Len() > 0 { 282 + if err := w.flushPart(); err != nil { 283 + // Try to abort multipart on error 284 + w.store.abortMultipartUpload(ctx, w.uploadID) 285 + return distribution.Descriptor{}, err 286 + } 287 + } 288 + // Complete multipart upload at temp location 289 + tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", w.id)) 290 + if err := w.store.completeMultipartUpload(ctx, tempDigest, w.uploadID, w.parts); err != nil { 291 + return distribution.Descriptor{}, err 292 + } 293 + // Move from temp → final location (server-side S3 copy) 294 + tempPath := fmt.Sprintf("uploads/temp-%s", w.id) 295 + finalPath := desc.Digest.String() 296 + moveURL := fmt.Sprintf("%s/move?from=%s&to=%s&did=%s", 297 + w.store.storageEndpoint, tempPath, finalPath, w.store.did) 298 + req, err := http.NewRequestWithContext(ctx, "POST", moveURL, nil) 299 + if err != nil { 300 + return distribution.Descriptor{}, err 301 + } 302 + resp, err := w.store.httpClient.Do(req) 303 + if err != nil { 304 + return distribution.Descriptor{}, err 305 + } 306 + defer resp.Body.Close() 307 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 308 + bodyBytes, _ := io.ReadAll(resp.Body) 309 + return distribution.Descriptor{}, fmt.Errorf("move failed: %d, %s", resp.StatusCode, bodyBytes) 310 + } 311 + // Remove from global map 312 + globalUploadsMu.Lock() 313 + delete(globalUploads, w.id) 314 + globalUploadsMu.Unlock() 315 + return distribution.Descriptor{ 316 + Digest: desc.Digest, 317 + Size: w.size, 318 + MediaType: desc.MediaType, 319 + }, nil 320 + } 321 + Add Hold Service Client Methods 322 + func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, dgst digest.Digest) (string, error) { 323 + reqBody := map[string]any{ 324 + "did": p.did, 325 + "digest": dgst.String(), 326 + } 327 + body, _ := json.Marshal(reqBody) 328 + url := fmt.Sprintf("%s/start-multipart", p.storageEndpoint) 329 + req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 330 + req.Header.Set("Content-Type", "application/json") 331 + resp, err := p.httpClient.Do(req) 332 + if err != nil { 333 + return "", err 334 + } 335 + defer resp.Body.Close() 336 + var result struct { 337 + UploadID string `json:"upload_id"` 338 + } 339 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 340 + return "", err 341 + } 342 + return result.UploadID, nil 343 + } 344 + func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, dgst digest.Digest, uploadID string, partNumber int) (string, error) { 345 + reqBody := map[string]any{ 346 + "did": p.did, 347 + "digest": dgst.String(), 348 + "upload_id": uploadID, 349 + "part_number": partNumber, 350 + } 351 + body, _ := json.Marshal(reqBody) 352 + url := fmt.Sprintf("%s/part-presigned-url", p.storageEndpoint) 353 + req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 354 + req.Header.Set("Content-Type", "application/json") 355 + resp, err := p.httpClient.Do(req) 356 + if err != nil { 357 + return "", err 358 + } 359 + defer resp.Body.Close() 360 + var result struct { 361 + URL string `json:"url"` 362 + } 363 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 364 + return "", err 365 + } 366 + return result.URL, nil 367 + } 368 + func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, dgst digest.Digest, uploadID string, parts []CompletedPart) error { 369 + reqBody := map[string]any{ 370 + "did": p.did, 371 + "digest": dgst.String(), 372 + "upload_id": uploadID, 373 + "parts": parts, 374 + } 375 + body, _ := json.Marshal(reqBody) 376 + url := fmt.Sprintf("%s/complete-multipart", p.storageEndpoint) 377 + req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 378 + req.Header.Set("Content-Type", "application/json") 379 + resp, err := p.httpClient.Do(req) 380 + if err != nil { 381 + return err 382 + } 383 + defer resp.Body.Close() 384 + if resp.StatusCode != http.StatusOK { 385 + return fmt.Errorf("complete multipart failed: status %d", resp.StatusCode) 386 + } 387 + return nil 388 + } 389 + --- 390 + Testing Plan 391 + 1. Unit Tests 392 + - Test multipart upload initiation 393 + - Test part upload with presigned URLs 394 + - Test completion with ETags 395 + - Test abort on errors 396 + 2. Integration Tests 397 + - Push small images (< 5MB, single part) 398 + - Push medium images (10MB, 2 parts) 399 + - Push large images (100MB, 20 parts) 400 + - Test with Upcloud S3 401 + - Test with Storj S3 402 + 3. Validation 403 + - Monitor logs for "client disconnected" errors (should be gone) 404 + - Check Docker push success rate 405 + - Verify blobs stored correctly in S3 406 + - Check bandwidth usage on hold service (should be minimal) 407 + --- 408 + Migration & Deployment 409 + Backward Compatibility 410 + - Keep /put-presigned-url endpoint for fallback 411 + - Keep /move endpoint (still needed) 412 + - New multipart endpoints are additive 413 + Deployment Steps 414 + 1. Update hold service with new endpoints 415 + 2. Update AppView ProxyBlobStore 416 + 3. Deploy hold service first 417 + 4. Deploy AppView 418 + 5. Test with sample push 419 + 6. Monitor logs 420 + Rollback Plan 421 + - Revert AppView to previous version (uses old presigned URL method) 422 + - Hold service keeps both old and new endpoints 423 + --- 424 + Documentation Updates 425 + Update docs/PRESIGNED_URLS.md 426 + - Add section "Multipart Upload for Chunked Data" 427 + - Explain why single presigned URLs don't work with PATCH 428 + - Document new endpoints and flow 429 + - Add S3 part size recommendations (5MB-64MB for Storj) 430 + Add Troubleshooting Section 431 + - "Client disconnected during PATCH" → resolved by multipart 432 + - Storj-specific considerations (64MB parts recommended) 433 + - Upcloud compatibility notes 434 + --- 435 + Performance Impact 436 + Before (Broken) 437 + - Docker PATCH → blocks on pipe → timeout → retry → fail 438 + - Unable to push large images reliably 439 + After (Multipart) 440 + - Each PATCH → independent part upload → immediate response 441 + - No blocking, no timeouts 442 + - Parallel part uploads possible (future optimization) 443 + - Reliable pushes for any image size 444 + Bandwidth 445 + - Hold service: Only API calls (~1KB per part) 446 + - Direct S3 uploads: Full blob data 447 + - S3 copy for move: Server-side (no hold bandwidth) 448 + Estimated savings: 99.98% hold service bandwidth reduction (same as before, but now actually works!)
+1017
docs/PRESIGNED_UPLOADS.md
··· 1 + # Presigned Upload URLs Implementation Guide 2 + 3 + ## Current Architecture (Proxy Mode) 4 + 5 + ### Upload Flow Today 6 + 1. **AppView** receives blob upload request from Docker 7 + 2. **ProxyBlobStore.Create()** creates streaming upload via pipe 8 + 3. Data streams to **Hold Service** temp location: `uploads/temp-{id}` 9 + 4. Hold service uploads to S3 via storage driver 10 + 5. **ProxyBlobWriter.Commit()** moves blob: temp → final digest-based path 11 + 6. Hold service performs S3 Move operation 12 + 13 + ### Why Uploads Don't Use Presigned URLs Today 14 + - `Create()` doesn't know the blob digest upfront 15 + - Presigned S3 URLs require the full object key (which includes digest) 16 + - Current approach streams to temp location, calculates digest, then moves 17 + 18 + ### Bandwidth Flow (Current) 19 + ``` 20 + Docker → AppView → Hold Service → S3/Storj 21 + (proxy) (proxy) 22 + ``` 23 + 24 + All upload bandwidth flows through Hold Service. 25 + 26 + --- 27 + 28 + ## Proposed Architecture (Presigned Uploads) 29 + 30 + ### New Upload Flow 31 + 1. **AppView** receives blob upload request from Docker 32 + 2. **ProxyBlobStore.Create()** creates buffered upload writer 33 + 3. Data buffered in memory during `Write()` calls 34 + 4. **ProxyBlobWriter.Commit()** calculates digest from buffer 35 + 5. Request presigned PUT URL from Hold Service with digest 36 + 6. Upload buffered data directly to S3 via presigned URL 37 + 7. No move operation needed (uploaded to final path) 38 + 39 + ### Bandwidth Flow (Presigned) 40 + ``` 41 + Docker → AppView → S3/Storj (direct via presigned URL) 42 + (buffer) 43 + 44 + Hold Service only issues presigned URLs (minimal bandwidth) 45 + ``` 46 + 47 + --- 48 + 49 + ## Detailed Implementation 50 + 51 + ### Phase 1: Add Buffering to ProxyBlobWriter 52 + 53 + **File:** `pkg/storage/proxy_blob_store.go` 54 + 55 + #### Changes to ProxyBlobWriter struct 56 + 57 + ```go 58 + type ProxyBlobWriter struct { 59 + store *ProxyBlobStore 60 + options distribution.CreateOptions 61 + 62 + // Remove pipe-based streaming 63 + // pipeWriter *io.PipeWriter 64 + // pipeReader *io.PipeReader 65 + // digestChan chan string 66 + // uploadErr chan error 67 + 68 + // Add buffering 69 + buffer *bytes.Buffer // In-memory buffer for blob data 70 + hasher digest.Digester // Calculate digest while writing 71 + 72 + finalDigest string 73 + size int64 74 + closed bool 75 + id string 76 + startedAt time.Time 77 + } 78 + ``` 79 + 80 + **Rationale:** 81 + - Remove pipe mechanism (no longer streaming to temp) 82 + - Add buffer to store blob data in memory 83 + - Add hasher to calculate digest incrementally 84 + 85 + #### Modify Create() method 86 + 87 + **Before (lines 208-312):** 88 + ```go 89 + func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) { 90 + // Creates pipe and starts background goroutine for streaming 91 + pipeReader, pipeWriter := io.Pipe() 92 + // ... streams to temp location 93 + } 94 + ``` 95 + 96 + **After:** 97 + ```go 98 + func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) { 99 + fmt.Printf("🔧 [proxy_blob_store/Create] Starting buffered upload for presigned URL\n") 100 + 101 + // Parse options 102 + var opts distribution.CreateOptions 103 + for _, option := range options { 104 + if err := option.Apply(&opts); err != nil { 105 + return nil, err 106 + } 107 + } 108 + 109 + // Create buffered writer 110 + writer := &ProxyBlobWriter{ 111 + store: p, 112 + options: opts, 113 + buffer: new(bytes.Buffer), 114 + hasher: digest.Canonical.Digester(), // Usually SHA256 115 + id: fmt.Sprintf("upload-%d", time.Now().UnixNano()), 116 + startedAt: time.Now(), 117 + } 118 + 119 + // Store in global uploads map for resume support 120 + globalUploadsMu.Lock() 121 + globalUploads[writer.id] = writer 122 + globalUploadsMu.Unlock() 123 + 124 + fmt.Printf(" Upload ID: %s\n", writer.id) 125 + fmt.Printf(" Repository: %s\n", p.repository) 126 + 127 + return writer, nil 128 + } 129 + ``` 130 + 131 + **Key Changes:** 132 + - No more pipe creation 133 + - No background goroutine 134 + - Initialize buffer and hasher 135 + - Everything else stays synchronous 136 + 137 + #### Modify Write() method 138 + 139 + **Before (lines 440-455):** 140 + ```go 141 + func (w *ProxyBlobWriter) Write(p []byte) (int, error) { 142 + // Writes to pipe, streams to hold service 143 + n, err := w.pipeWriter.Write(p) 144 + w.size += int64(n) 145 + return n, nil 146 + } 147 + ``` 148 + 149 + **After:** 150 + ```go 151 + func (w *ProxyBlobWriter) Write(p []byte) (int, error) { 152 + if w.closed { 153 + return 0, fmt.Errorf("writer closed") 154 + } 155 + 156 + // Write to buffer 157 + n, err := w.buffer.Write(p) 158 + if err != nil { 159 + return n, fmt.Errorf("failed to buffer data: %w", err) 160 + } 161 + 162 + // Update hasher for digest calculation 163 + w.hasher.Hash().Write(p) 164 + 165 + w.size += int64(n) 166 + 167 + // Memory pressure check (optional safety) 168 + if w.buffer.Len() > 500*1024*1024 { // 500MB limit 169 + return n, fmt.Errorf("blob too large for buffered upload: %d bytes", w.buffer.Len()) 170 + } 171 + 172 + return n, nil 173 + } 174 + ``` 175 + 176 + **Key Changes:** 177 + - Write to in-memory buffer instead of pipe 178 + - Update hasher incrementally (efficient) 179 + - Add safety check for excessive memory usage 180 + - No streaming to hold service yet 181 + 182 + #### Modify Commit() method 183 + 184 + **Before (lines 493-548):** 185 + ```go 186 + func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) { 187 + // Close pipe, send digest to goroutine 188 + // Wait for temp upload 189 + // Move temp → final 190 + } 191 + ``` 192 + 193 + **After:** 194 + ```go 195 + func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) { 196 + if w.closed { 197 + return distribution.Descriptor{}, fmt.Errorf("writer closed") 198 + } 199 + w.closed = true 200 + 201 + // Remove from global uploads map 202 + globalUploadsMu.Lock() 203 + delete(globalUploads, w.id) 204 + globalUploadsMu.Unlock() 205 + 206 + // Calculate digest from buffered data 207 + calculatedDigest := w.hasher.Digest() 208 + 209 + // Verify digest matches if provided 210 + if desc.Digest != "" && desc.Digest != calculatedDigest { 211 + return distribution.Descriptor{}, fmt.Errorf( 212 + "digest mismatch: expected %s, got %s", 213 + desc.Digest, calculatedDigest, 214 + ) 215 + } 216 + 217 + finalDigest := calculatedDigest 218 + if desc.Digest != "" { 219 + finalDigest = desc.Digest 220 + } 221 + 222 + fmt.Printf("📤 [ProxyBlobWriter.Commit] Uploading via presigned URL\n") 223 + fmt.Printf(" Digest: %s\n", finalDigest) 224 + fmt.Printf(" Size: %d bytes\n", w.size) 225 + fmt.Printf(" Buffered: %d bytes\n", w.buffer.Len()) 226 + 227 + // Get presigned upload URL from hold service 228 + url, err := w.store.getUploadURL(ctx, finalDigest, w.size) 229 + if err != nil { 230 + return distribution.Descriptor{}, fmt.Errorf("failed to get presigned upload URL: %w", err) 231 + } 232 + 233 + fmt.Printf(" Presigned URL: %s\n", url) 234 + 235 + // Upload directly to S3 via presigned URL 236 + req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(w.buffer.Bytes())) 237 + if err != nil { 238 + return distribution.Descriptor{}, fmt.Errorf("failed to create upload request: %w", err) 239 + } 240 + req.Header.Set("Content-Type", "application/octet-stream") 241 + req.ContentLength = w.size 242 + 243 + resp, err := w.store.httpClient.Do(req) 244 + if err != nil { 245 + return distribution.Descriptor{}, fmt.Errorf("presigned upload failed: %w", err) 246 + } 247 + defer resp.Body.Close() 248 + 249 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 250 + bodyBytes, _ := io.ReadAll(resp.Body) 251 + return distribution.Descriptor{}, fmt.Errorf( 252 + "presigned upload failed: status %d, body: %s", 253 + resp.StatusCode, string(bodyBytes), 254 + ) 255 + } 256 + 257 + fmt.Printf("✅ [ProxyBlobWriter.Commit] Upload successful\n") 258 + 259 + // Clear buffer to free memory 260 + w.buffer = nil 261 + 262 + return distribution.Descriptor{ 263 + Digest: finalDigest, 264 + Size: w.size, 265 + MediaType: desc.MediaType, 266 + }, nil 267 + } 268 + ``` 269 + 270 + **Key Changes:** 271 + - Calculate digest from hasher (already computed incrementally) 272 + - Verify digest if provided by client 273 + - Get presigned upload URL with final digest 274 + - Upload buffer contents directly to S3 275 + - No temp location, no move operation 276 + - Clear buffer to free memory immediately 277 + 278 + #### Modify Cancel() method 279 + 280 + **Before (lines 551-572):** 281 + ```go 282 + func (w *ProxyBlobWriter) Cancel(ctx context.Context) error { 283 + // Close pipe, cancel temp upload 284 + } 285 + ``` 286 + 287 + **After:** 288 + ```go 289 + func (w *ProxyBlobWriter) Cancel(ctx context.Context) error { 290 + w.closed = true 291 + 292 + // Remove from global uploads map 293 + globalUploadsMu.Lock() 294 + delete(globalUploads, w.id) 295 + globalUploadsMu.Unlock() 296 + 297 + // Clear buffer to free memory 298 + w.buffer = nil 299 + 300 + fmt.Printf("[ProxyBlobWriter.Cancel] Upload cancelled: id=%s\n", w.id) 301 + return nil 302 + } 303 + ``` 304 + 305 + **Key Changes:** 306 + - Simply clear buffer 307 + - No pipe cleanup needed 308 + - No temp cleanup needed (nothing uploaded yet) 309 + 310 + --- 311 + 312 + ### Phase 2: Update Hold Service (Optional Enhancement) 313 + 314 + The current `getUploadURL()` implementation in `cmd/hold/main.go` (lines 528-587) already supports presigned uploads correctly. No changes needed unless you want to add additional logging. 315 + 316 + **Optional logging enhancement at line 547:** 317 + 318 + ```go 319 + url, err := req.Presign(15 * time.Minute) 320 + if err != nil { 321 + log.Printf("Failed to generate presigned upload URL: %v", err) 322 + return s.getProxyUploadURL(digest, did), nil 323 + } 324 + 325 + log.Printf("🔑 Generated presigned upload URL:") 326 + log.Printf(" Digest: %s", digest) 327 + log.Printf(" S3 Key: %s", s3Key) 328 + log.Printf(" Size: %d bytes", size) 329 + log.Printf(" URL length: %d chars", len(url)) 330 + log.Printf(" Expires: 15min") 331 + 332 + return url, nil 333 + ``` 334 + 335 + --- 336 + 337 + ### Phase 3: Memory Management Considerations 338 + 339 + #### Add Configuration for Max Buffer Size 340 + 341 + **File:** `pkg/storage/proxy_blob_store.go` 342 + 343 + Add constants at top of file: 344 + 345 + ```go 346 + const ( 347 + maxChunkSize = 5 * 1024 * 1024 // 5MB (existing) 348 + 349 + // Maximum blob size for in-memory buffering 350 + // Blobs larger than this will fail (alternative: fallback to proxy mode) 351 + maxBufferedBlobSize = 500 * 1024 * 1024 // 500MB 352 + ) 353 + ``` 354 + 355 + #### Alternative: Disk-Based Buffering 356 + 357 + For very large blobs, consider disk-based buffering: 358 + 359 + ```go 360 + type ProxyBlobWriter struct { 361 + // ... existing fields ... 362 + 363 + // Choose one: 364 + buffer *bytes.Buffer // Memory buffer (current) 365 + // OR 366 + tempFile *os.File // Disk buffer (for large blobs) 367 + bufferSize int64 368 + } 369 + ``` 370 + 371 + **Memory buffer (simple, fast):** 372 + - Pro: Fast, no disk I/O 373 + - Con: Limited by available RAM 374 + - Use for: Blobs < 500MB 375 + 376 + **Disk buffer (scalable):** 377 + - Pro: No memory limit 378 + - Con: Slower, disk I/O overhead 379 + - Use for: Blobs > 500MB 380 + 381 + #### Hybrid Approach (Recommended) 382 + 383 + ```go 384 + const ( 385 + memoryBufferThreshold = 50 * 1024 * 1024 // 50MB 386 + ) 387 + 388 + func (w *ProxyBlobWriter) Write(p []byte) (int, error) { 389 + // If buffer exceeds threshold, switch to disk 390 + if w.buffer != nil && w.buffer.Len() > memoryBufferThreshold { 391 + return 0, fmt.Errorf("blob exceeds memory buffer threshold, disk buffering not implemented") 392 + // TODO: Implement disk buffering or fallback to proxy mode 393 + } 394 + 395 + // Otherwise use memory buffer 396 + // ... existing Write() logic ... 397 + } 398 + ``` 399 + 400 + --- 401 + 402 + ## Optional Enhancement: Presigned HEAD URLs 403 + 404 + ### Motivation 405 + 406 + Currently HEAD requests (blob verification) are proxied through the Hold Service. This is fine because HEAD bandwidth is negligible (~300 bytes per request), but we can eliminate this round-trip by using presigned HEAD URLs. 407 + 408 + ### Implementation 409 + 410 + #### Step 1: Add getHeadURL() to Hold Service 411 + 412 + **File:** `cmd/hold/main.go` 413 + 414 + Add new function after `getDownloadURL()`: 415 + 416 + ```go 417 + // getHeadURL generates a presigned HEAD URL for blob verification 418 + func (s *HoldService) getHeadURL(ctx context.Context, digest string) (string, error) { 419 + // Check if blob exists first 420 + path := blobPath(digest) 421 + _, err := s.driver.Stat(ctx, path) 422 + if err != nil { 423 + return "", fmt.Errorf("blob not found: %w", err) 424 + } 425 + 426 + // If S3 client available, generate presigned HEAD URL 427 + if s.s3Client != nil { 428 + s3Key := strings.TrimPrefix(path, "/") 429 + if s.s3PathPrefix != "" { 430 + s3Key = s.s3PathPrefix + "/" + s3Key 431 + } 432 + 433 + // Generate presigned HEAD URL (method-specific!) 434 + req, _ := s.s3Client.HeadObjectRequest(&s3.HeadObjectInput{ 435 + Bucket: aws.String(s.bucket), 436 + Key: aws.String(s3Key), 437 + }) 438 + 439 + log.Printf("🔍 [getHeadURL] Generating presigned HEAD URL:") 440 + log.Printf(" Digest: %s", digest) 441 + log.Printf(" S3 Key: %s", s3Key) 442 + 443 + url, err := req.Presign(15 * time.Minute) 444 + if err != nil { 445 + log.Printf("[getHeadURL] Presign failed: %v", err) 446 + // Fallback to proxy URL 447 + return s.getProxyHeadURL(digest), nil 448 + } 449 + 450 + log.Printf("✅ [getHeadURL] Presigned HEAD URL generated") 451 + return url, nil 452 + } 453 + 454 + // Fallback: return proxy URL 455 + return s.getProxyHeadURL(digest), nil 456 + } 457 + 458 + // getProxyHeadURL returns a proxy URL for HEAD requests 459 + func (s *HoldService) getProxyHeadURL(digest string) string { 460 + // HEAD requests don't need DID in query string (read-only check) 461 + return fmt.Sprintf("%s/blobs/%s", s.config.Server.PublicURL, digest) 462 + } 463 + ``` 464 + 465 + #### Step 2: Add HTTP endpoint for presigned HEAD URLs 466 + 467 + **File:** `cmd/hold/main.go` 468 + 469 + Add handler similar to `HandleGetPresignedURL()`: 470 + 471 + ```go 472 + // HeadPresignedURLRequest represents a request for a presigned HEAD URL 473 + type HeadPresignedURLRequest struct { 474 + DID string `json:"did"` 475 + Digest string `json:"digest"` 476 + } 477 + 478 + // HeadPresignedURLResponse contains the presigned HEAD URL 479 + type HeadPresignedURLResponse struct { 480 + URL string `json:"url"` 481 + ExpiresAt time.Time `json:"expires_at"` 482 + } 483 + 484 + // HandleHeadPresignedURL handles requests for HEAD URLs 485 + func (s *HoldService) HandleHeadPresignedURL(w http.ResponseWriter, r *http.Request) { 486 + if r.Method != http.MethodPost { 487 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 488 + return 489 + } 490 + 491 + var req HeadPresignedURLRequest 492 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 493 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 494 + return 495 + } 496 + 497 + // Validate DID authorization for READ 498 + if !s.isAuthorizedRead(req.DID) { 499 + if req.DID == "" { 500 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 501 + } else { 502 + http.Error(w, "forbidden: access denied", http.StatusForbidden) 503 + } 504 + return 505 + } 506 + 507 + // Generate presigned HEAD URL 508 + ctx := context.Background() 509 + expiry := time.Now().Add(15 * time.Minute) 510 + 511 + url, err := s.getHeadURL(ctx, req.Digest) 512 + if err != nil { 513 + http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError) 514 + return 515 + } 516 + 517 + resp := HeadPresignedURLResponse{ 518 + URL: url, 519 + ExpiresAt: expiry, 520 + } 521 + 522 + w.Header().Set("Content-Type", "application/json") 523 + json.NewEncoder(w).Encode(resp) 524 + } 525 + ``` 526 + 527 + #### Step 3: Register endpoint in main() 528 + 529 + **File:** `cmd/hold/main.go` 530 + 531 + In `main()` function, add route: 532 + 533 + ```go 534 + mux.HandleFunc("/head-presigned-url", service.HandleHeadPresignedURL) 535 + ``` 536 + 537 + #### Step 4: Update ProxyBlobStore.ServeBlob() 538 + 539 + **File:** `pkg/storage/proxy_blob_store.go` 540 + 541 + Modify HEAD handling (currently lines 197-224): 542 + 543 + **Before:** 544 + ```go 545 + if r.Method == http.MethodHead { 546 + // Check if blob exists via hold service HEAD request 547 + url := fmt.Sprintf("%s/blobs/%s?did=%s", p.storageEndpoint, dgst.String(), p.did) 548 + req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) 549 + // ... proxy through hold service ... 550 + } 551 + ``` 552 + 553 + **After:** 554 + ```go 555 + if r.Method == http.MethodHead { 556 + // Get presigned HEAD URL from hold service 557 + headURL, err := p.getHeadURL(ctx, dgst) 558 + if err != nil { 559 + return distribution.ErrBlobUnknown 560 + } 561 + 562 + // Redirect to presigned HEAD URL 563 + http.Redirect(w, r, headURL, http.StatusTemporaryRedirect) 564 + return nil 565 + } 566 + ``` 567 + 568 + #### Step 5: Add getHeadURL() to ProxyBlobStore 569 + 570 + **File:** `pkg/storage/proxy_blob_store.go` 571 + 572 + Add after `getDownloadURL()`: 573 + 574 + ```go 575 + // getHeadURL requests a presigned HEAD URL from the storage service 576 + func (p *ProxyBlobStore) getHeadURL(ctx context.Context, dgst digest.Digest) (string, error) { 577 + reqBody := map[string]any{ 578 + "did": p.did, 579 + "digest": dgst.String(), 580 + } 581 + 582 + body, err := json.Marshal(reqBody) 583 + if err != nil { 584 + return "", err 585 + } 586 + 587 + url := fmt.Sprintf("%s/head-presigned-url", p.storageEndpoint) 588 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 589 + if err != nil { 590 + return "", err 591 + } 592 + req.Header.Set("Content-Type", "application/json") 593 + 594 + resp, err := p.httpClient.Do(req) 595 + if err != nil { 596 + return "", err 597 + } 598 + defer resp.Body.Close() 599 + 600 + if resp.StatusCode != http.StatusOK { 601 + return "", fmt.Errorf("failed to get HEAD URL: status %d", resp.StatusCode) 602 + } 603 + 604 + var result struct { 605 + URL string `json:"url"` 606 + } 607 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 608 + return "", err 609 + } 610 + 611 + return result.URL, nil 612 + } 613 + ``` 614 + 615 + ### Presigned HEAD URLs: Trade-offs 616 + 617 + **Benefits:** 618 + - Offloads HEAD requests from Hold Service 619 + - Docker verifies blobs directly against S3 620 + - Slightly lower latency (one fewer hop) 621 + 622 + **Costs:** 623 + - Requires round-trip to get presigned HEAD URL 624 + - More complex code 625 + - Two HTTP requests instead of one proxy request 626 + 627 + **Bandwidth Analysis:** 628 + - Current: 1 HEAD request to Hold Service (~300 bytes) 629 + - Presigned: 1 POST to get URL (~200 bytes) + 1 HEAD to S3 (~300 bytes) 630 + - **Net difference: Adds ~200 bytes per verification** 631 + 632 + **Recommendation:** Optional enhancement. The current proxied HEAD approach is simpler and bandwidth difference is negligible. Only implement if: 633 + - Hold Service is becoming a bottleneck 634 + - You want to minimize Hold Service load completely 635 + - Latency of HEAD requests becomes noticeable 636 + 637 + --- 638 + 639 + ## Testing & Validation 640 + 641 + ### Test Plan for Presigned Uploads 642 + 643 + #### 1. Small Blob Upload (< 1MB) 644 + ```bash 645 + # Build test image with small layers 646 + echo "FROM scratch" > Dockerfile 647 + echo "COPY small-file /" >> Dockerfile 648 + dd if=/dev/urandom of=small-file bs=1024 count=512 # 512KB 649 + 650 + docker build -t atcr.io/youruser/test:small . 651 + docker push atcr.io/youruser/test:small 652 + ``` 653 + 654 + **Expected behavior:** 655 + - Blob buffered in memory 656 + - Presigned upload URL requested with correct digest 657 + - Direct upload to S3 via presigned URL 658 + - No temp location, no move operation 659 + 660 + **Verify in logs:** 661 + ``` 662 + 📤 [ProxyBlobWriter.Commit] Uploading via presigned URL 663 + Digest: sha256:... 664 + Size: 524288 bytes 665 + Presigned URL: https://gateway.storjshare.io/... 666 + ✅ [ProxyBlobWriter.Commit] Upload successful 667 + ``` 668 + 669 + #### 2. Medium Blob Upload (10-50MB) 670 + ```bash 671 + dd if=/dev/urandom of=medium-file bs=1048576 count=25 # 25MB 672 + 673 + docker build -t atcr.io/youruser/test:medium . 674 + docker push atcr.io/youruser/test:medium 675 + ``` 676 + 677 + **Monitor memory usage:** 678 + ```bash 679 + # While push is running 680 + docker stats atcr-appview 681 + ``` 682 + 683 + Should see ~25MB spike during buffer + upload. 684 + 685 + #### 3. Large Blob Upload (100-500MB) 686 + ```bash 687 + dd if=/dev/urandom of=large-file bs=1048576 count=200 # 200MB 688 + 689 + docker build -t atcr.io/youruser/test:large . 690 + docker push atcr.io/youruser/test:large 691 + ``` 692 + 693 + **Monitor:** 694 + - Memory usage (should see ~200MB spike) 695 + - Upload completes successfully 696 + - S3 shows blob in correct location 697 + 698 + #### 4. Concurrent Uploads 699 + ```bash 700 + # Push multiple images in parallel 701 + docker push atcr.io/youruser/test1:tag & 702 + docker push atcr.io/youruser/test2:tag & 703 + docker push atcr.io/youruser/test3:tag & 704 + wait 705 + ``` 706 + 707 + **Verify:** 708 + - All uploads complete successfully 709 + - Memory usage peaks but doesn't OOM 710 + - No data corruption (digests match) 711 + 712 + #### 5. Error Handling Tests 713 + 714 + **Test presigned URL failure:** 715 + - Temporarily break S3 credentials 716 + - Verify graceful error message 717 + - Check for memory leaks (buffer cleared on error) 718 + 719 + **Test digest mismatch:** 720 + - This shouldn't happen in practice, but verify error handling 721 + - Buffer should be cleared even on error 722 + 723 + **Test network interruption:** 724 + - Kill network during upload 725 + - Verify proper error propagation 726 + - Check for hanging goroutines 727 + 728 + ### Test Plan for Presigned HEAD URLs (Optional) 729 + 730 + #### 1. HEAD Request Redirect 731 + ```bash 732 + # Pull image (triggers HEAD verification) 733 + docker pull atcr.io/youruser/test:tag 734 + ``` 735 + 736 + **Expected behavior:** 737 + - AppView redirects HEAD to presigned HEAD URL 738 + - Docker follows redirect to S3 739 + - S3 responds to HEAD request successfully 740 + 741 + **Verify in logs:** 742 + ``` 743 + 🔍 [getHeadURL] Generating presigned HEAD URL: 744 + Digest: sha256:... 745 + ✅ [getHeadURL] Presigned HEAD URL generated 746 + ``` 747 + 748 + #### 2. Method Verification 749 + ```bash 750 + # Manually verify presigned HEAD URL works 751 + curl -I "presigned-head-url-here" 752 + ``` 753 + 754 + Should return 200 OK with Content-Length header. 755 + 756 + ```bash 757 + # Verify it ONLY works with HEAD (not GET) 758 + curl "presigned-head-url-here" 759 + ``` 760 + 761 + Should return 403 Forbidden (method mismatch). 762 + 763 + --- 764 + 765 + ## Performance Comparison 766 + 767 + ### Current Architecture (Proxy Mode) 768 + 769 + **Upload:** 770 + ``` 771 + Client → AppView (stream) → Hold Service (stream) → S3 772 + ~0ms delay ~0ms delay ~100ms 773 + ``` 774 + - Total latency: ~100ms + upload time 775 + - Bandwidth: All through Hold Service 776 + 777 + **Download:** 778 + ``` 779 + Client → AppView (redirect) → S3 (presigned GET) 780 + ~5ms ~50ms 781 + ``` 782 + - Total latency: ~55ms + download time 783 + - Bandwidth: Direct from S3 ✅ 784 + 785 + **Verification (HEAD):** 786 + ``` 787 + Client → AppView (redirect) → Hold Service (proxy HEAD) → S3 788 + ~5ms ~10ms ~50ms 789 + ``` 790 + - Total latency: ~65ms 791 + - Bandwidth: ~300 bytes through Hold Service 792 + 793 + ### Presigned Upload Architecture 794 + 795 + **Upload:** 796 + ``` 797 + Client → AppView (buffer) → S3 (presigned PUT) 798 + ~0ms ~100ms 799 + ``` 800 + - Total latency: ~100ms + upload time (same) 801 + - Bandwidth: Direct to S3 ✅ 802 + - Memory: +blob_size during buffer 803 + 804 + **Download:** (unchanged) 805 + ``` 806 + Client → AppView (redirect) → S3 (presigned GET) 807 + ``` 808 + 809 + **Verification (HEAD):** (if presigned HEAD enabled) 810 + ``` 811 + Client → AppView (redirect) → S3 (presigned HEAD) 812 + ~5ms ~50ms 813 + ``` 814 + - Total latency: ~55ms (10ms faster) 815 + - Bandwidth: Direct to S3 ✅ 816 + 817 + --- 818 + 819 + ## Trade-offs Summary 820 + 821 + ### Presigned Uploads 822 + 823 + | Aspect | Proxy Mode (Current) | Presigned URLs | 824 + |--------|---------------------|----------------| 825 + | **Upload Bandwidth** | Through Hold Service | Direct to S3 ✅ | 826 + | **Hold Service Load** | High (all upload traffic) | Low (only URL generation) ✅ | 827 + | **Memory Usage** | Low (streaming) | High (buffering) ⚠️ | 828 + | **Disk Usage** | None | Optional temp files for large blobs | 829 + | **Code Complexity** | Simple ✅ | Moderate | 830 + | **Max Blob Size** | Unlimited ✅ | Limited by memory (~500MB) ⚠️ | 831 + | **Latency** | Same | Same | 832 + | **Error Recovery** | Simple (cancel stream) | More complex (clear buffer) | 833 + 834 + ### Presigned HEAD URLs 835 + 836 + | Aspect | Proxy Mode (Current) | Presigned HEAD | 837 + |--------|---------------------|----------------| 838 + | **Bandwidth** | 300 bytes (negligible) | 500 bytes (still negligible) | 839 + | **Hold Service Load** | Low (HEAD is tiny) | Lower (but minimal gain) | 840 + | **Latency** | 65ms | 55ms (10ms faster) | 841 + | **Code Complexity** | Simple ✅ | More complex | 842 + | **Reliability** | High (fewer moving parts) ✅ | Moderate (more failure modes) | 843 + 844 + --- 845 + 846 + ## Recommendations 847 + 848 + ### Presigned Uploads 849 + 850 + **Implement if:** 851 + - ✅ Hold Service bandwidth is a concern 852 + - ✅ You want to minimize Hold Service load 853 + - ✅ Most blobs are < 100MB (typical Docker layers) 854 + - ✅ AppView has sufficient memory (2-4GB+ RAM) 855 + 856 + **Skip if:** 857 + - ⚠️ Memory is constrained 858 + - ⚠️ You regularly push very large layers (> 500MB) 859 + - ⚠️ Current proxy mode is working fine 860 + - ⚠️ Simplicity is priority 861 + 862 + ### Presigned HEAD URLs 863 + 864 + **Implement if:** 865 + - ✅ You want complete S3 offloading 866 + - ✅ You're already implementing presigned uploads 867 + - ✅ Hold Service is CPU/bandwidth constrained 868 + 869 + **Skip if:** 870 + - ⚠️ Current HEAD proxying works fine (it does) 871 + - ⚠️ You want to minimize code complexity 872 + - ⚠️ 10ms latency difference doesn't matter 873 + 874 + ### Suggested Approach 875 + 876 + **Phase 1:** Implement presigned uploads first 877 + - Bigger performance win (offloads upload bandwidth) 878 + - More valuable for write-heavy workflows 879 + - Test thoroughly with various blob sizes 880 + 881 + **Phase 2:** Monitor and evaluate 882 + - Check Hold Service load after presigned uploads 883 + - Measure HEAD request impact 884 + - Assess if presigned HEAD is worth the complexity 885 + 886 + **Phase 3:** Optionally add presigned HEAD 887 + - Only if Hold Service is still bottlenecked 888 + - Or if you want feature completeness 889 + 890 + --- 891 + 892 + ## Migration Path 893 + 894 + ### Step 1: Feature Flag 895 + Add configuration option to enable/disable presigned uploads: 896 + 897 + ```go 898 + // In AppView config 899 + type Config struct { 900 + // ... existing fields ... 901 + 902 + UsePresignedUploads bool `yaml:"use_presigned_uploads"` // Default: false 903 + } 904 + ``` 905 + 906 + ### Step 2: Gradual Rollout 907 + 1. Deploy with `use_presigned_uploads: false` (current behavior) 908 + 2. Test in staging with `use_presigned_uploads: true` 909 + 3. Roll out to production incrementally 910 + 4. Monitor memory usage and error rates 911 + 912 + ### Step 3: Fallback Mechanism 913 + If presigned upload fails, fallback to proxy mode: 914 + 915 + ```go 916 + func (w *ProxyBlobWriter) Commit(...) { 917 + // Try presigned upload 918 + url, err := w.store.getUploadURL(ctx, finalDigest, w.size) 919 + if err != nil { 920 + // Fallback: use proxy mode 921 + log.Printf("⚠️ Presigned upload unavailable, falling back to proxy") 922 + return w.proxyUpload(ctx, desc) 923 + } 924 + // ... presigned upload ... 925 + } 926 + ``` 927 + 928 + --- 929 + 930 + ## Appendix: Memory Profiling 931 + 932 + To monitor memory usage during development: 933 + 934 + ```bash 935 + # Enable Go memory profiling 936 + go tool pprof http://localhost:5000/debug/pprof/heap 937 + 938 + # Or use runtime metrics 939 + import "runtime" 940 + 941 + var m runtime.MemStats 942 + runtime.ReadMemStats(&m) 943 + fmt.Printf("Alloc = %v MB", m.Alloc / 1024 / 1024) 944 + ``` 945 + 946 + Monitor these metrics: 947 + - `Alloc`: Current memory allocation 948 + - `TotalAlloc`: Cumulative allocation (detect leaks) 949 + - `Sys`: Total memory from OS 950 + - `NumGC`: Garbage collection count 951 + 952 + Expected behavior with presigned uploads: 953 + - Memory spikes during `Write()` calls 954 + - Memory drops after `Commit()` completes 955 + - No memory leaks (TotalAlloc should plateau) 956 + 957 + --- 958 + 959 + ## Questions for Decision 960 + 961 + Before implementing, answer: 962 + 963 + 1. **What's the typical size of your Docker layers?** 964 + - < 50MB: Presigned uploads perfect fit 965 + - 50-200MB: Acceptable with memory monitoring 966 + - > 200MB: Consider disk buffering or stick with proxy 967 + 968 + 2. **What's your AppView's available memory?** 969 + - 1GB: Skip presigned uploads 970 + - 2-4GB: Fine for typical workloads 971 + - 8GB+: No concerns 972 + 973 + 3. **Is Hold Service bandwidth currently a problem?** 974 + - No: Current proxy mode is fine 975 + - Yes: Presigned uploads will help significantly 976 + 977 + 4. **How important is code simplicity?** 978 + - Very: Stick with proxy mode 979 + - Moderate: Implement presigned uploads only 980 + - Low: Implement both presigned uploads and HEAD 981 + 982 + 5. **What's your deployment model?** 983 + - Single Hold Service: Bandwidth matters more 984 + - Multiple Hold Services: Less critical 985 + 986 + --- 987 + 988 + ## Implementation Checklist 989 + 990 + ### Presigned Uploads 991 + - [ ] Modify `ProxyBlobWriter` struct (remove pipe, add buffer/hasher) 992 + - [ ] Update `Create()` to initialize buffer 993 + - [ ] Update `Write()` to buffer + hash data 994 + - [ ] Update `Commit()` to upload via presigned URL 995 + - [ ] Update `Cancel()` to clear buffer 996 + - [ ] Add memory usage monitoring 997 + - [ ] Add configuration flag 998 + - [ ] Test with small blobs (< 1MB) 999 + - [ ] Test with medium blobs (10-50MB) 1000 + - [ ] Test with large blobs (100-500MB) 1001 + - [ ] Test concurrent uploads 1002 + - [ ] Test error scenarios 1003 + - [ ] Update documentation 1004 + - [ ] Deploy to staging 1005 + - [ ] Monitor production rollout 1006 + 1007 + ### Presigned HEAD URLs (Optional) 1008 + - [ ] Add `getHeadURL()` to Hold Service 1009 + - [ ] Add `HandleHeadPresignedURL()` endpoint 1010 + - [ ] Register `/head-presigned-url` route 1011 + - [ ] Add `getHeadURL()` to ProxyBlobStore 1012 + - [ ] Update `ServeBlob()` to redirect HEAD requests 1013 + - [ ] Test HEAD redirects 1014 + - [ ] Verify method-specific signatures 1015 + - [ ] Test with Docker pull operations 1016 + - [ ] Deploy to staging 1017 + - [ ] Monitor production rollout
+49
docs/PRESIGNED_URLS.md
··· 718 718 719 719 The implementation has automatic fallbacks, so partial failures won't break functionality. 720 720 721 + ## Testing with DISABLE_PRESIGNED_URLS 722 + 723 + ### Environment Variable 724 + 725 + Set `DISABLE_PRESIGNED_URLS=true` to force proxy/buffered mode even when S3 is configured. 726 + 727 + **Use cases:** 728 + - Testing proxy/buffered code paths with S3 storage 729 + - Debugging multipart uploads in buffered mode 730 + - Simulating S3 providers that don't support presigned URLs 731 + - Verifying fallback behavior works correctly 732 + 733 + ### How It Works 734 + 735 + When `DISABLE_PRESIGNED_URLS=true`: 736 + 737 + **Single blob operations:** 738 + - `getDownloadURL()` returns proxy URL instead of S3 presigned URL 739 + - `getHeadURL()` returns proxy URL instead of S3 presigned HEAD URL 740 + - `getUploadURL()` returns proxy URL instead of S3 presigned PUT URL 741 + - Client uses `/blobs/{digest}` endpoints (proxy through hold service) 742 + 743 + **Multipart uploads:** 744 + - `StartMultipartUploadWithManager()` creates **Buffered** session instead of **S3Native** 745 + - `GetPartUploadURL()` returns `/multipart-parts/{uploadID}/{partNumber}` instead of S3 presigned URL 746 + - Parts are buffered in memory in the hold service 747 + - `CompleteMultipartUploadWithManager()` assembles parts and writes via storage driver 748 + 749 + ### Testing Example 750 + 751 + ```bash 752 + # Test S3 with forced proxy mode 753 + export STORAGE_DRIVER=s3 754 + export S3_BUCKET=my-bucket 755 + export AWS_ACCESS_KEY_ID=... 756 + export AWS_SECRET_ACCESS_KEY=... 757 + export DISABLE_PRESIGNED_URLS=true # Force buffered/proxy mode 758 + 759 + ./bin/atcr-hold 760 + 761 + # Push an image - should use proxy mode 762 + docker push atcr.io/yourdid/test:latest 763 + 764 + # Check logs for: 765 + # "Presigned URLs disabled, using proxy URL" 766 + # "Presigned URLs disabled (DISABLE_PRESIGNED_URLS=true), using buffered mode" 767 + # "Stored part: uploadID=... part=1 size=..." 768 + ``` 769 + 721 770 ## Future Enhancements 722 771 723 772 ### 1. Configurable Expiration
+21
pkg/atproto/client.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "encoding/base64" 6 7 "encoding/json" 7 8 "fmt" 8 9 "io" ··· 343 344 return nil, fmt.Errorf("failed to read blob data: %w", err) 344 345 } 345 346 347 + // Check if PDS returned JSON-wrapped blob (Bluesky implementation) 348 + // PDS may wrap blobs as JSON-encoded base64 strings 349 + // Detection: Check if content starts with a quote (indicating JSON string) 350 + if len(data) > 0 && data[0] == '"' { 351 + // Blob is JSON-encoded - decode it 352 + var base64Str string 353 + if err := json.Unmarshal(data, &base64Str); err != nil { 354 + return nil, fmt.Errorf("failed to unmarshal JSON-wrapped blob: %w", err) 355 + } 356 + 357 + // Base64-decode the blob content 358 + decoded, err := base64.StdEncoding.DecodeString(base64Str) 359 + if err != nil { 360 + return nil, fmt.Errorf("failed to base64-decode blob: %w", err) 361 + } 362 + 363 + return decoded, nil 364 + } 365 + 366 + // Raw blob response (expected ATProto behavior) 346 367 return data, nil 347 368 } 348 369
+7
pkg/auth/scope.go
··· 5 5 "strings" 6 6 ) 7 7 8 + // AccessEntry represents access permissions for a resource 9 + type AccessEntry struct { 10 + Type string `json:"type"` // "repository" 11 + Name string `json:"name,omitempty"` // e.g., "alice/myapp" 12 + Actions []string `json:"actions,omitempty"` // e.g., ["pull", "push"] 13 + } 14 + 8 15 // ParseScope parses Docker registry scope strings into AccessEntry structures 9 16 // Scope format: "repository:alice/myapp:pull,push" 10 17 // Multiple scopes can be provided
-8
pkg/auth/types.go
··· 1 - package auth 2 - 3 - // AccessEntry represents access permissions for a resource 4 - type AccessEntry struct { 5 - Type string `json:"type"` // "repository" 6 - Name string `json:"name,omitempty"` // e.g., "alice/myapp" 7 - Actions []string `json:"actions,omitempty"` // e.g., ["pull", "push"] 8 - }
+131
pkg/hold/authorization.go
··· 1 + package hold 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + 9 + "atcr.io/pkg/atproto" 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + ) 13 + 14 + // isAuthorizedRead checks if a DID can read from this hold 15 + // Authorization: 16 + // - Public hold: allow anonymous (empty DID) or any authenticated user 17 + // - Private hold: require authentication (any user with sailor.profile) 18 + func (s *HoldService) isAuthorizedRead(did string) bool { 19 + // Check hold public flag 20 + isPublic, err := s.isHoldPublic() 21 + if err != nil { 22 + log.Printf("ERROR: Failed to check hold public flag: %v", err) 23 + // Fail secure - deny access on error 24 + return false 25 + } 26 + 27 + if isPublic { 28 + // Public hold - allow anyone (even anonymous) 29 + return true 30 + } 31 + 32 + // Private hold - require authentication 33 + // Any authenticated user with sailor.profile can read 34 + if did == "" { 35 + // Anonymous user trying to access private hold 36 + return false 37 + } 38 + 39 + // For MVP: assume DID presence means they have sailor.profile 40 + // Future: could query PDS to verify sailor.profile exists 41 + return true 42 + } 43 + 44 + // isAuthorizedWrite checks if a DID can write to this hold 45 + // Authorization: must be hold owner OR crew member 46 + func (s *HoldService) isAuthorizedWrite(did string) bool { 47 + if did == "" { 48 + // Anonymous writes not allowed 49 + return false 50 + } 51 + 52 + // Check if DID is the hold owner 53 + ownerDID := s.config.Registration.OwnerDID 54 + if ownerDID == "" { 55 + log.Printf("ERROR: Hold owner DID not configured") 56 + return false 57 + } 58 + 59 + if did == ownerDID { 60 + // Owner always has write access 61 + return true 62 + } 63 + 64 + // Check if DID is a crew member 65 + isCrew, err := s.isCrewMember(did) 66 + if err != nil { 67 + log.Printf("ERROR: Failed to check crew membership: %v", err) 68 + return false 69 + } 70 + 71 + return isCrew 72 + } 73 + 74 + // isHoldPublic checks if this hold allows public (anonymous) reads 75 + func (s *HoldService) isHoldPublic() (bool, error) { 76 + // Use cached config value for now 77 + // Future: could query PDS for hold record to get live value 78 + return s.config.Server.Public, nil 79 + } 80 + 81 + // isCrewMember checks if a DID is a crew member of this hold 82 + func (s *HoldService) isCrewMember(did string) (bool, error) { 83 + ownerDID := s.config.Registration.OwnerDID 84 + if ownerDID == "" { 85 + return false, fmt.Errorf("hold owner DID not configured") 86 + } 87 + 88 + ctx := context.Background() 89 + 90 + // Resolve owner's PDS endpoint using indigo 91 + directory := identity.DefaultDirectory() 92 + ownerDIDParsed, err := syntax.ParseDID(ownerDID) 93 + if err != nil { 94 + return false, fmt.Errorf("invalid owner DID: %w", err) 95 + } 96 + 97 + ident, err := directory.LookupDID(ctx, ownerDIDParsed) 98 + if err != nil { 99 + return false, fmt.Errorf("failed to resolve owner PDS: %w", err) 100 + } 101 + 102 + pdsEndpoint := ident.PDSEndpoint() 103 + if pdsEndpoint == "" { 104 + return false, fmt.Errorf("no PDS endpoint found for owner") 105 + } 106 + 107 + // Create unauthenticated client to read public records 108 + client := atproto.NewClient(pdsEndpoint, ownerDID, "") 109 + 110 + // List crew records for this hold 111 + // Crew records are public, so we can read them without auth 112 + records, err := client.ListRecords(ctx, atproto.HoldCrewCollection, 100) 113 + if err != nil { 114 + return false, fmt.Errorf("failed to list crew records: %w", err) 115 + } 116 + 117 + // Check if DID is in crew list 118 + for _, record := range records { 119 + var crewRecord atproto.HoldCrewRecord 120 + if err := json.Unmarshal(record.Value, &crewRecord); err != nil { 121 + continue 122 + } 123 + 124 + if crewRecord.Member == did { 125 + // Found crew membership 126 + return true, nil 127 + } 128 + } 129 + 130 + return false, nil 131 + }
+134
pkg/hold/config.go
··· 1 + package hold 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "time" 7 + 8 + "github.com/distribution/distribution/v3/configuration" 9 + ) 10 + 11 + // Config represents the hold service configuration 12 + type Config struct { 13 + Version string `yaml:"version"` 14 + Storage StorageConfig `yaml:"storage"` 15 + Server ServerConfig `yaml:"server"` 16 + Registration RegistrationConfig `yaml:"registration"` 17 + } 18 + 19 + // RegistrationConfig defines auto-registration settings 20 + type RegistrationConfig struct { 21 + // OwnerDID is the owner's ATProto DID (from env: HOLD_OWNER) 22 + // If set, auto-registration is enabled 23 + OwnerDID string `yaml:"owner_did"` 24 + } 25 + 26 + // StorageConfig wraps distribution's storage configuration 27 + type StorageConfig struct { 28 + configuration.Storage `yaml:",inline"` 29 + } 30 + 31 + // ServerConfig defines server settings 32 + type ServerConfig struct { 33 + // Addr is the address to listen on (e.g., ":8080") 34 + Addr string `yaml:"addr"` 35 + 36 + // PublicURL is the public URL of this hold service (e.g., "https://hold.example.com") 37 + PublicURL string `yaml:"public_url"` 38 + 39 + // Public controls whether this hold allows public blob reads without auth (from env: HOLD_PUBLIC) 40 + Public bool `yaml:"public"` 41 + 42 + // TestMode uses localhost for OAuth redirects while storing real URL in hold record (from env: TEST_MODE) 43 + TestMode bool `yaml:"test_mode"` 44 + 45 + // DisablePresignedURLs forces proxy mode even with S3 configured (for testing) (from env: DISABLE_PRESIGNED_URLS) 46 + DisablePresignedURLs bool `yaml:"disable_presigned_urls"` 47 + 48 + // ReadTimeout for HTTP requests 49 + ReadTimeout time.Duration `yaml:"read_timeout"` 50 + 51 + // WriteTimeout for HTTP requests 52 + WriteTimeout time.Duration `yaml:"write_timeout"` 53 + } 54 + 55 + // LoadConfigFromEnv loads all configuration from environment variables 56 + func LoadConfigFromEnv() (*Config, error) { 57 + cfg := &Config{ 58 + Version: "0.1", 59 + } 60 + 61 + // Server configuration 62 + cfg.Server.Addr = getEnvOrDefault("HOLD_SERVER_ADDR", ":8080") 63 + cfg.Server.PublicURL = os.Getenv("HOLD_PUBLIC_URL") 64 + if cfg.Server.PublicURL == "" { 65 + return nil, fmt.Errorf("HOLD_PUBLIC_URL is required") 66 + } 67 + cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true" 68 + cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" 69 + cfg.Server.DisablePresignedURLs = os.Getenv("DISABLE_PRESIGNED_URLS") == "true" 70 + cfg.Server.ReadTimeout = 5 * time.Minute // Increased for large blob uploads 71 + cfg.Server.WriteTimeout = 5 * time.Minute // Increased for large blob uploads 72 + 73 + // Registration configuration (optional) 74 + cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER") 75 + 76 + // Storage configuration - build from env vars based on storage type 77 + storageType := getEnvOrDefault("STORAGE_DRIVER", "s3") 78 + var err error 79 + cfg.Storage, err = buildStorageConfig(storageType) 80 + if err != nil { 81 + return nil, fmt.Errorf("failed to build storage config: %w", err) 82 + } 83 + 84 + return cfg, nil 85 + } 86 + 87 + // buildStorageConfig creates storage configuration based on driver type 88 + func buildStorageConfig(driver string) (StorageConfig, error) { 89 + params := make(map[string]any) 90 + 91 + switch driver { 92 + case "s3": 93 + // S3/Storj/Minio configuration from standard AWS env vars 94 + accessKey := os.Getenv("AWS_ACCESS_KEY_ID") 95 + secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") 96 + region := getEnvOrDefault("AWS_REGION", "us-east-1") 97 + bucket := os.Getenv("S3_BUCKET") 98 + endpoint := os.Getenv("S3_ENDPOINT") // For Storj/Minio 99 + 100 + if bucket == "" { 101 + return StorageConfig{}, fmt.Errorf("S3_BUCKET is required for S3 storage") 102 + } 103 + 104 + params["accesskey"] = accessKey 105 + params["secretkey"] = secretKey 106 + params["region"] = region 107 + params["bucket"] = bucket 108 + if endpoint != "" { 109 + params["regionendpoint"] = endpoint 110 + } 111 + 112 + case "filesystem": 113 + // Filesystem configuration 114 + rootDir := getEnvOrDefault("STORAGE_ROOT_DIR", "/var/lib/atcr/hold") 115 + params["rootdirectory"] = rootDir 116 + 117 + default: 118 + return StorageConfig{}, fmt.Errorf("unsupported storage driver: %s", driver) 119 + } 120 + 121 + // Build distribution Storage config 122 + storageCfg := configuration.Storage{} 123 + storageCfg[driver] = configuration.Parameters(params) 124 + 125 + return StorageConfig{Storage: storageCfg}, nil 126 + } 127 + 128 + // getEnvOrDefault gets an environment variable or returns a default value 129 + func getEnvOrDefault(key, defaultValue string) string { 130 + if val := os.Getenv(key); val != "" { 131 + return val 132 + } 133 + return defaultValue 134 + }
+587
pkg/hold/handlers.go
··· 1 + package hold 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log" 9 + "net/http" 10 + "time" 11 + 12 + "atcr.io/pkg/atproto" 13 + ) 14 + 15 + // PresignedURLOperation defines the type of presigned URL operation 16 + type PresignedURLOperation string 17 + 18 + const ( 19 + OperationGet PresignedURLOperation = "GET" 20 + OperationHead PresignedURLOperation = "HEAD" 21 + OperationPut PresignedURLOperation = "PUT" 22 + ) 23 + 24 + // PresignedURLRequest represents a request for a presigned URL (GET, HEAD, or PUT) 25 + type PresignedURLRequest struct { 26 + Operation PresignedURLOperation `json:"operation"` 27 + DID string `json:"did"` 28 + Digest string `json:"digest"` 29 + Size int64 `json:"size,omitempty"` // Only required for PUT operations 30 + } 31 + 32 + // PresignedURLResponse contains the presigned URL 33 + type PresignedURLResponse struct { 34 + URL string `json:"url"` 35 + ExpiresAt time.Time `json:"expires_at"` 36 + } 37 + 38 + // HandlePresignedURL handles presigned URL requests (GET, HEAD, or PUT) 39 + // Operation type is specified in the request body 40 + func (s *HoldService) HandlePresignedURL(w http.ResponseWriter, r *http.Request) { 41 + if r.Method != http.MethodPost { 42 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 43 + return 44 + } 45 + 46 + var req PresignedURLRequest 47 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 48 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 49 + return 50 + } 51 + 52 + // Validate DID authorization based on operation type 53 + var authorized bool 54 + switch req.Operation { 55 + case OperationGet, OperationHead: 56 + authorized = s.isAuthorizedRead(req.DID) 57 + case OperationPut: 58 + authorized = s.isAuthorizedWrite(req.DID) 59 + default: 60 + http.Error(w, "unsupported operation", http.StatusBadRequest) 61 + return 62 + } 63 + 64 + if !authorized { 65 + log.Printf("[HandlePresignedURL:%s] Authorization FAILED", req.Operation) 66 + if req.DID == "" { 67 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 68 + } else { 69 + http.Error(w, "forbidden: access denied", http.StatusForbidden) 70 + } 71 + return 72 + } 73 + 74 + // Generate presigned URL (15 minute expiry) 75 + ctx := context.Background() 76 + expiry := time.Now().Add(15 * time.Minute) 77 + 78 + url, err := s.getPresignedURL(ctx, req.Operation, req.Digest, req.DID) 79 + if err != nil { 80 + log.Printf("[HandlePresignedURL:%s] getPresignedURL failed: %v", req.Operation, err) 81 + http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError) 82 + return 83 + } 84 + 85 + log.Printf("[HandlePresignedURL:%s] Returning URL to client", req.Operation) 86 + 87 + resp := PresignedURLResponse{ 88 + URL: url, 89 + ExpiresAt: expiry, 90 + } 91 + 92 + w.Header().Set("Content-Type", "application/json") 93 + json.NewEncoder(w).Encode(resp) 94 + } 95 + 96 + // HandleProxyGet proxies a blob download through the service 97 + func (s *HoldService) HandleProxyGet(w http.ResponseWriter, r *http.Request) { 98 + if r.Method != http.MethodGet && r.Method != http.MethodHead { 99 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 100 + return 101 + } 102 + 103 + // Extract digest from path (e.g., /blobs/sha256:abc123) 104 + digest := r.URL.Path[len("/blobs/"):] 105 + if digest == "" { 106 + http.Error(w, "missing digest", http.StatusBadRequest) 107 + return 108 + } 109 + 110 + // Get DID from query param or header 111 + did := r.URL.Query().Get("did") 112 + if did == "" { 113 + did = r.Header.Get("X-ATCR-DID") 114 + } 115 + log.Printf(" DID: %s", did) 116 + 117 + // Authorize READ access 118 + if !s.isAuthorizedRead(did) { 119 + log.Printf("[HandleProxyGet] Authorization FAILED") 120 + if did == "" { 121 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 122 + } else { 123 + http.Error(w, "forbidden: access denied", http.StatusForbidden) 124 + } 125 + return 126 + } 127 + 128 + ctx := r.Context() 129 + path := blobPath(digest) 130 + 131 + // For HEAD requests, just check if blob exists 132 + if r.Method == http.MethodHead { 133 + stat, err := s.driver.Stat(ctx, path) 134 + if err != nil { 135 + http.Error(w, "blob not found", http.StatusNotFound) 136 + return 137 + } 138 + w.Header().Set("Content-Type", "application/octet-stream") 139 + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) 140 + w.WriteHeader(http.StatusOK) 141 + return 142 + } 143 + 144 + // For GET requests, read and return the blob 145 + content, err := s.driver.GetContent(ctx, path) 146 + if err != nil { 147 + http.Error(w, "blob not found", http.StatusNotFound) 148 + return 149 + } 150 + 151 + w.Header().Set("Content-Type", "application/octet-stream") 152 + w.Write(content) 153 + } 154 + 155 + // HandleMove moves a blob from one path to another 156 + // POST /move?from={path}&to={digest}&did={did} 157 + func (s *HoldService) HandleMove(w http.ResponseWriter, r *http.Request) { 158 + if r.Method != http.MethodPost { 159 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 160 + return 161 + } 162 + 163 + fromPath := r.URL.Query().Get("from") 164 + toDigest := r.URL.Query().Get("to") 165 + did := r.URL.Query().Get("did") 166 + 167 + if fromPath == "" || toDigest == "" { 168 + http.Error(w, "missing from or to parameter", http.StatusBadRequest) 169 + return 170 + } 171 + 172 + // Authorize WRITE access 173 + if !s.isAuthorizedWrite(did) { 174 + if did == "" { 175 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 176 + } else { 177 + http.Error(w, "forbidden: write access denied", http.StatusForbidden) 178 + } 179 + return 180 + } 181 + 182 + ctx := r.Context() 183 + sourcePath := blobPath(fromPath) 184 + destPath := blobPath(toDigest) 185 + 186 + // Try to move using driver's Move operation 187 + if err := s.driver.Move(ctx, sourcePath, destPath); err != nil { 188 + log.Printf("HandleMove: failed to move blob: %v", err) 189 + http.Error(w, fmt.Sprintf("failed to move blob: %v", err), http.StatusInternalServerError) 190 + return 191 + } 192 + 193 + log.Printf("HandleMove: successfully moved blob from=%s to=%s", fromPath, toDigest) 194 + w.WriteHeader(http.StatusOK) 195 + } 196 + 197 + // HandleProxyPut proxies a blob upload through the service 198 + func (s *HoldService) HandleProxyPut(w http.ResponseWriter, r *http.Request) { 199 + if r.Method != http.MethodPut { 200 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 201 + return 202 + } 203 + 204 + digest := r.URL.Path[len("/blobs/"):] 205 + if digest == "" { 206 + http.Error(w, "missing digest", http.StatusBadRequest) 207 + return 208 + } 209 + 210 + did := r.URL.Query().Get("did") 211 + if did == "" { 212 + did = r.Header.Get("X-ATCR-DID") 213 + } 214 + 215 + // Authorize WRITE access 216 + if !s.isAuthorizedWrite(did) { 217 + log.Printf("[HandleProxyPut] Authorization FAILED") 218 + if did == "" { 219 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 220 + } else { 221 + http.Error(w, "forbidden: write access denied", http.StatusForbidden) 222 + } 223 + return 224 + } 225 + 226 + // Stream blob to storage (no buffering) 227 + ctx := r.Context() 228 + path := blobPath(digest) 229 + 230 + // Create writer for streaming 231 + writer, err := s.driver.Writer(ctx, path, false) 232 + if err != nil { 233 + log.Printf("HandleProxyPut: failed to create writer: %v", err) 234 + http.Error(w, "failed to create writer", http.StatusInternalServerError) 235 + return 236 + } 237 + 238 + // Stream directly from request body to storage 239 + written, err := io.Copy(writer, r.Body) 240 + if err != nil { 241 + writer.Cancel(ctx) 242 + log.Printf("HandleProxyPut: failed to write blob: %v", err) 243 + http.Error(w, "failed to write blob", http.StatusInternalServerError) 244 + return 245 + } 246 + 247 + // Commit the write 248 + if err := writer.Commit(ctx); err != nil { 249 + log.Printf("HandleProxyPut: failed to commit blob: %v", err) 250 + http.Error(w, "failed to commit blob", http.StatusInternalServerError) 251 + return 252 + } 253 + 254 + log.Printf("HandleProxyPut: successfully stored blob path=%s, size=%d", digest, written) 255 + w.WriteHeader(http.StatusCreated) 256 + } 257 + 258 + // StartMultipartUploadRequest initiates a multipart upload 259 + type StartMultipartUploadRequest struct { 260 + DID string `json:"did"` 261 + Digest string `json:"digest"` 262 + } 263 + 264 + // StartMultipartUploadResponse contains the multipart upload ID 265 + type StartMultipartUploadResponse struct { 266 + UploadID string `json:"upload_id"` 267 + ExpiresAt time.Time `json:"expires_at"` 268 + } 269 + 270 + // HandleStartMultipart initiates a multipart upload 271 + func (s *HoldService) HandleStartMultipart(w http.ResponseWriter, r *http.Request) { 272 + if r.Method != http.MethodPost { 273 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 274 + return 275 + } 276 + 277 + var req StartMultipartUploadRequest 278 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 279 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 280 + return 281 + } 282 + 283 + // Validate DID authorization for WRITE 284 + if !s.isAuthorizedWrite(req.DID) { 285 + if req.DID == "" { 286 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 287 + } else { 288 + http.Error(w, "forbidden: write access denied", http.StatusForbidden) 289 + } 290 + return 291 + } 292 + 293 + // Start multipart upload with manager (supports both S3Native and Buffered modes) 294 + ctx := r.Context() 295 + uploadID, mode, err := s.StartMultipartUploadWithManager(ctx, req.Digest, s.MultipartMgr) 296 + if err != nil { 297 + http.Error(w, fmt.Sprintf("failed to start multipart upload: %v", err), http.StatusInternalServerError) 298 + return 299 + } 300 + 301 + log.Printf("Started multipart upload: uploadID=%s, mode=%v, digest=%s", uploadID, mode, req.Digest) 302 + 303 + expiry := time.Now().Add(24 * time.Hour) // Multipart uploads can take longer 304 + 305 + resp := StartMultipartUploadResponse{ 306 + UploadID: uploadID, 307 + ExpiresAt: expiry, 308 + } 309 + 310 + w.Header().Set("Content-Type", "application/json") 311 + json.NewEncoder(w).Encode(resp) 312 + } 313 + 314 + // GetPartURLRequest requests a presigned URL for a specific part 315 + type GetPartURLRequest struct { 316 + DID string `json:"did"` 317 + Digest string `json:"digest"` 318 + UploadID string `json:"upload_id"` 319 + PartNumber int `json:"part_number"` 320 + } 321 + 322 + // GetPartURLResponse contains the presigned URL for a part 323 + type GetPartURLResponse struct { 324 + URL string `json:"url"` 325 + ExpiresAt time.Time `json:"expires_at"` 326 + } 327 + 328 + // HandleGetPartURL generates a presigned URL for uploading a specific part 329 + func (s *HoldService) HandleGetPartURL(w http.ResponseWriter, r *http.Request) { 330 + if r.Method != http.MethodPost { 331 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 332 + return 333 + } 334 + 335 + var req GetPartURLRequest 336 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 337 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 338 + return 339 + } 340 + 341 + // Validate DID authorization for WRITE 342 + if !s.isAuthorizedWrite(req.DID) { 343 + if req.DID == "" { 344 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 345 + } else { 346 + http.Error(w, "forbidden: write access denied", http.StatusForbidden) 347 + } 348 + return 349 + } 350 + 351 + // Get multipart session 352 + session, err := s.MultipartMgr.GetSession(req.UploadID) 353 + if err != nil { 354 + http.Error(w, fmt.Sprintf("session not found: %v", err), http.StatusNotFound) 355 + return 356 + } 357 + 358 + // Get part upload URL (presigned for S3Native, proxy for Buffered) 359 + ctx := r.Context() 360 + url, err := s.GetPartUploadURL(ctx, session, req.PartNumber, req.DID) 361 + if err != nil { 362 + http.Error(w, fmt.Sprintf("failed to generate part URL: %v", err), http.StatusInternalServerError) 363 + return 364 + } 365 + 366 + expiry := time.Now().Add(15 * time.Minute) 367 + 368 + resp := GetPartURLResponse{ 369 + URL: url, 370 + ExpiresAt: expiry, 371 + } 372 + 373 + w.Header().Set("Content-Type", "application/json") 374 + json.NewEncoder(w).Encode(resp) 375 + } 376 + 377 + // CompleteMultipartRequest completes a multipart upload 378 + type CompleteMultipartRequest struct { 379 + DID string `json:"did"` 380 + Digest string `json:"digest"` 381 + UploadID string `json:"upload_id"` 382 + Parts []CompletedPart `json:"parts"` 383 + } 384 + 385 + // CompletedPart represents an uploaded part with its ETag 386 + type CompletedPart struct { 387 + PartNumber int `json:"part_number"` 388 + ETag string `json:"etag"` 389 + } 390 + 391 + // HandleCompleteMultipart completes a multipart upload 392 + func (s *HoldService) HandleCompleteMultipart(w http.ResponseWriter, r *http.Request) { 393 + if r.Method != http.MethodPost { 394 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 395 + return 396 + } 397 + 398 + var req CompleteMultipartRequest 399 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 400 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 401 + return 402 + } 403 + 404 + // Validate DID authorization for WRITE 405 + if !s.isAuthorizedWrite(req.DID) { 406 + if req.DID == "" { 407 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 408 + } else { 409 + http.Error(w, "forbidden: write access denied", http.StatusForbidden) 410 + } 411 + return 412 + } 413 + 414 + // Get multipart session 415 + session, err := s.MultipartMgr.GetSession(req.UploadID) 416 + if err != nil { 417 + http.Error(w, fmt.Sprintf("session not found: %v", err), http.StatusNotFound) 418 + return 419 + } 420 + 421 + // For S3Native mode, use parts from request (uploaded directly to S3) 422 + // For Buffered mode, parts are in the session 423 + if session.Mode == S3Native { 424 + // Record parts from AppView's request (they have ETags from S3) 425 + for _, p := range req.Parts { 426 + session.RecordS3Part(p.PartNumber, p.ETag, 0) 427 + } 428 + log.Printf("Recorded %d S3 parts from request for uploadID=%s", len(req.Parts), req.UploadID) 429 + } 430 + 431 + // Complete multipart upload (handles both S3Native and Buffered modes) 432 + ctx := r.Context() 433 + if err := s.CompleteMultipartUploadWithManager(ctx, session, s.MultipartMgr); err != nil { 434 + http.Error(w, fmt.Sprintf("failed to complete multipart upload: %v", err), http.StatusInternalServerError) 435 + return 436 + } 437 + 438 + log.Printf("Completed multipart upload: uploadID=%s, mode=%v", req.UploadID, session.Mode) 439 + 440 + w.WriteHeader(http.StatusOK) 441 + w.Header().Set("Content-Type", "application/json") 442 + json.NewEncoder(w).Encode(map[string]string{ 443 + "status": "completed", 444 + }) 445 + } 446 + 447 + // AbortMultipartRequest aborts an in-progress upload 448 + type AbortMultipartRequest struct { 449 + DID string `json:"did"` 450 + Digest string `json:"digest"` 451 + UploadID string `json:"upload_id"` 452 + } 453 + 454 + // HandleAbortMultipart aborts an in-progress multipart upload 455 + func (s *HoldService) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) { 456 + if r.Method != http.MethodPost { 457 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 458 + return 459 + } 460 + 461 + var req AbortMultipartRequest 462 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 463 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 464 + return 465 + } 466 + 467 + // Validate DID authorization for WRITE 468 + if !s.isAuthorizedWrite(req.DID) { 469 + if req.DID == "" { 470 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 471 + } else { 472 + http.Error(w, "forbidden: write access denied", http.StatusForbidden) 473 + } 474 + return 475 + } 476 + 477 + // Get multipart session 478 + session, err := s.MultipartMgr.GetSession(req.UploadID) 479 + if err != nil { 480 + http.Error(w, fmt.Sprintf("session not found: %v", err), http.StatusNotFound) 481 + return 482 + } 483 + 484 + // Abort multipart upload (handles both S3Native and Buffered modes) 485 + ctx := r.Context() 486 + if err := s.AbortMultipartUploadWithManager(ctx, session, s.MultipartMgr); err != nil { 487 + http.Error(w, fmt.Sprintf("failed to abort multipart upload: %v", err), http.StatusInternalServerError) 488 + return 489 + } 490 + 491 + log.Printf("Aborted multipart upload: uploadID=%s, mode=%v", req.UploadID, session.Mode) 492 + 493 + w.WriteHeader(http.StatusOK) 494 + w.Header().Set("Content-Type", "application/json") 495 + json.NewEncoder(w).Encode(map[string]string{ 496 + "status": "aborted", 497 + }) 498 + } 499 + 500 + // RegisterRequest represents a request to register this hold in a user's PDS 501 + type RegisterRequest struct { 502 + DID string `json:"did"` 503 + AccessToken string `json:"access_token"` 504 + PDSEndpoint string `json:"pds_endpoint"` 505 + } 506 + 507 + // RegisterResponse contains the registration result 508 + type RegisterResponse struct { 509 + HoldURI string `json:"hold_uri"` 510 + CrewURI string `json:"crew_uri"` 511 + Message string `json:"message"` 512 + } 513 + 514 + // HandleRegister registers this hold service in a user's PDS (manual endpoint) 515 + func (s *HoldService) HandleRegister(w http.ResponseWriter, r *http.Request) { 516 + if r.Method != http.MethodPost { 517 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 518 + return 519 + } 520 + 521 + var req RegisterRequest 522 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 523 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 524 + return 525 + } 526 + 527 + // Validate required fields 528 + if req.DID == "" || req.AccessToken == "" || req.PDSEndpoint == "" { 529 + http.Error(w, "missing required fields: did, access_token, pds_endpoint", http.StatusBadRequest) 530 + return 531 + } 532 + 533 + // Get public URL from config 534 + publicURL := s.config.Server.PublicURL 535 + if publicURL == "" { 536 + // Fallback to constructing URL from request 537 + scheme := "http" 538 + if r.TLS != nil { 539 + scheme = "https" 540 + } 541 + publicURL = fmt.Sprintf("%s://%s", scheme, r.Host) 542 + } 543 + 544 + // Derive hold name from URL 545 + holdName, err := extractHostname(publicURL) 546 + if err != nil { 547 + http.Error(w, fmt.Sprintf("failed to extract hostname: %v", err), http.StatusBadRequest) 548 + return 549 + } 550 + 551 + ctx := r.Context() 552 + 553 + // Create ATProto client with user's credentials 554 + client := atproto.NewClient(req.PDSEndpoint, req.DID, req.AccessToken) 555 + 556 + // Create HoldRecord 557 + holdRecord := atproto.NewHoldRecord(publicURL, req.DID, s.config.Server.Public) 558 + 559 + holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord) 560 + if err != nil { 561 + http.Error(w, fmt.Sprintf("failed to create hold record: %v", err), http.StatusInternalServerError) 562 + return 563 + } 564 + 565 + log.Printf("Created hold record: %s", holdResult.URI) 566 + 567 + // Create HoldCrewRecord for the owner 568 + crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, req.DID, "owner") 569 + 570 + crewRKey := fmt.Sprintf("%s-%s", holdName, req.DID) 571 + crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord) 572 + if err != nil { 573 + http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError) 574 + return 575 + } 576 + 577 + log.Printf("Created crew record: %s", crewResult.URI) 578 + 579 + resp := RegisterResponse{ 580 + HoldURI: holdResult.URI, 581 + CrewURI: crewResult.URI, 582 + Message: fmt.Sprintf("Successfully registered hold service. Storage endpoint: %s", publicURL), 583 + } 584 + 585 + w.Header().Set("Content-Type", "application/json") 586 + json.NewEncoder(w).Encode(resp) 587 + }
+381
pkg/hold/multipart.go
··· 1 + package hold 2 + 3 + import ( 4 + "context" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "fmt" 8 + "io" 9 + "log" 10 + "net/http" 11 + "sync" 12 + "time" 13 + 14 + "github.com/google/uuid" 15 + ) 16 + 17 + // MultipartMode indicates how multipart uploads are handled 18 + type MultipartMode int 19 + 20 + const ( 21 + // S3Native uses S3's native multipart API with presigned URLs 22 + S3Native MultipartMode = iota 23 + // Buffered buffers parts in memory and assembles them in the hold service 24 + Buffered 25 + ) 26 + 27 + // MultipartSession tracks an in-progress multipart upload 28 + type MultipartSession struct { 29 + UploadID string // Unique upload ID 30 + Digest string // Target digest path 31 + Mode MultipartMode // Upload mode (S3Native or Buffered) 32 + S3UploadID string // S3 upload ID (for S3Native mode) 33 + Parts map[int]*MultipartPart // Buffered parts (for Buffered mode) 34 + CreatedAt time.Time // When upload started 35 + LastActivity time.Time // Last part upload 36 + mu sync.RWMutex // Protects Parts map 37 + } 38 + 39 + // MultipartPart represents a single part in a multipart upload 40 + type MultipartPart struct { 41 + PartNumber int // Part number (1-indexed) 42 + Data []byte // Part data (for Buffered mode) 43 + ETag string // ETag from S3 or computed hash 44 + Size int64 // Part size in bytes 45 + UploadedAt time.Time // When part was uploaded 46 + } 47 + 48 + // MultipartManager manages multipart upload sessions 49 + type MultipartManager struct { 50 + sessions map[string]*MultipartSession // uploadID -> session 51 + mu sync.RWMutex // Protects sessions map 52 + } 53 + 54 + // NewMultipartManager creates a new multipart manager 55 + func NewMultipartManager() *MultipartManager { 56 + mgr := &MultipartManager{ 57 + sessions: make(map[string]*MultipartSession), 58 + } 59 + 60 + // Start cleanup goroutine for abandoned uploads 61 + go mgr.cleanupLoop() 62 + 63 + return mgr 64 + } 65 + 66 + // cleanupLoop periodically cleans up expired sessions 67 + func (m *MultipartManager) cleanupLoop() { 68 + ticker := time.NewTicker(15 * time.Minute) 69 + defer ticker.Stop() 70 + 71 + for range ticker.C { 72 + m.cleanupExpiredSessions() 73 + } 74 + } 75 + 76 + // cleanupExpiredSessions removes sessions inactive for >24 hours 77 + func (m *MultipartManager) cleanupExpiredSessions() { 78 + m.mu.Lock() 79 + defer m.mu.Unlock() 80 + 81 + now := time.Now() 82 + for uploadID, session := range m.sessions { 83 + if now.Sub(session.LastActivity) > 24*time.Hour { 84 + log.Printf("Cleaning up expired multipart session: uploadID=%s, age=%v", uploadID, now.Sub(session.CreatedAt)) 85 + delete(m.sessions, uploadID) 86 + } 87 + } 88 + } 89 + 90 + // CreateSession creates a new multipart upload session 91 + func (m *MultipartManager) CreateSession(digest string, mode MultipartMode, s3UploadID string) *MultipartSession { 92 + uploadID := uuid.New().String() 93 + 94 + session := &MultipartSession{ 95 + UploadID: uploadID, 96 + Digest: digest, 97 + Mode: mode, 98 + S3UploadID: s3UploadID, 99 + Parts: make(map[int]*MultipartPart), 100 + CreatedAt: time.Now(), 101 + LastActivity: time.Now(), 102 + } 103 + 104 + m.mu.Lock() 105 + m.sessions[uploadID] = session 106 + m.mu.Unlock() 107 + 108 + log.Printf("Created multipart session: uploadID=%s, digest=%s, mode=%v", uploadID, digest, mode) 109 + return session 110 + } 111 + 112 + // GetSession retrieves a multipart session by upload ID 113 + func (m *MultipartManager) GetSession(uploadID string) (*MultipartSession, error) { 114 + m.mu.RLock() 115 + defer m.mu.RUnlock() 116 + 117 + session, ok := m.sessions[uploadID] 118 + if !ok { 119 + return nil, fmt.Errorf("multipart session not found: %s", uploadID) 120 + } 121 + 122 + return session, nil 123 + } 124 + 125 + // DeleteSession removes a multipart session 126 + func (m *MultipartManager) DeleteSession(uploadID string) { 127 + m.mu.Lock() 128 + defer m.mu.Unlock() 129 + 130 + delete(m.sessions, uploadID) 131 + log.Printf("Deleted multipart session: uploadID=%s", uploadID) 132 + } 133 + 134 + // StorePart stores a part in the session (for Buffered mode) 135 + func (s *MultipartSession) StorePart(partNumber int, data []byte) string { 136 + s.mu.Lock() 137 + defer s.mu.Unlock() 138 + 139 + // Compute ETag as SHA256 hash of part data 140 + hash := sha256.Sum256(data) 141 + etag := hex.EncodeToString(hash[:]) 142 + 143 + part := &MultipartPart{ 144 + PartNumber: partNumber, 145 + Data: data, 146 + ETag: etag, 147 + Size: int64(len(data)), 148 + UploadedAt: time.Now(), 149 + } 150 + 151 + s.Parts[partNumber] = part 152 + s.LastActivity = time.Now() 153 + 154 + log.Printf("Stored part: uploadID=%s, part=%d, size=%d bytes, etag=%s", s.UploadID, partNumber, len(data), etag) 155 + return etag 156 + } 157 + 158 + // RecordS3Part records a part uploaded to S3 (for S3Native mode) 159 + func (s *MultipartSession) RecordS3Part(partNumber int, etag string, size int64) { 160 + s.mu.Lock() 161 + defer s.mu.Unlock() 162 + 163 + part := &MultipartPart{ 164 + PartNumber: partNumber, 165 + ETag: etag, 166 + Size: size, 167 + UploadedAt: time.Now(), 168 + } 169 + 170 + s.Parts[partNumber] = part 171 + s.LastActivity = time.Now() 172 + 173 + log.Printf("Recorded S3 part: uploadID=%s, part=%d, size=%d bytes, etag=%s", s.UploadID, partNumber, size, etag) 174 + } 175 + 176 + // AssembleBufferedParts assembles all buffered parts into a single blob 177 + // Returns the complete data and total size 178 + func (s *MultipartSession) AssembleBufferedParts() ([]byte, int64, error) { 179 + s.mu.RLock() 180 + defer s.mu.RUnlock() 181 + 182 + if s.Mode != Buffered { 183 + return nil, 0, fmt.Errorf("session is not in buffered mode") 184 + } 185 + 186 + // Calculate total size 187 + var totalSize int64 188 + maxPart := 0 189 + for partNum, part := range s.Parts { 190 + totalSize += part.Size 191 + if partNum > maxPart { 192 + maxPart = partNum 193 + } 194 + } 195 + 196 + // Check for missing parts 197 + for i := 1; i <= maxPart; i++ { 198 + if _, ok := s.Parts[i]; !ok { 199 + return nil, 0, fmt.Errorf("missing part %d", i) 200 + } 201 + } 202 + 203 + // Assemble parts in order 204 + assembled := make([]byte, 0, totalSize) 205 + for i := 1; i <= maxPart; i++ { 206 + part := s.Parts[i] 207 + assembled = append(assembled, part.Data...) 208 + } 209 + 210 + log.Printf("Assembled buffered parts: uploadID=%s, parts=%d, totalSize=%d bytes", s.UploadID, maxPart, totalSize) 211 + return assembled, totalSize, nil 212 + } 213 + 214 + // GetCompletedParts returns the list of completed parts for S3 multipart completion 215 + func (s *MultipartSession) GetCompletedParts() []CompletedPart { 216 + s.mu.RLock() 217 + defer s.mu.RUnlock() 218 + 219 + parts := make([]CompletedPart, 0, len(s.Parts)) 220 + for _, part := range s.Parts { 221 + parts = append(parts, CompletedPart{ 222 + PartNumber: part.PartNumber, 223 + ETag: part.ETag, 224 + }) 225 + } 226 + 227 + return parts 228 + } 229 + 230 + // StartMultipartUploadWithManager initiates a multipart upload using the manager 231 + // Returns uploadID and mode 232 + func (s *HoldService) StartMultipartUploadWithManager(ctx context.Context, digest string, manager *MultipartManager) (string, MultipartMode, error) { 233 + // Check if presigned URLs are disabled for testing 234 + if s.config.Server.DisablePresignedURLs { 235 + log.Printf("Presigned URLs disabled (DISABLE_PRESIGNED_URLS=true), using buffered mode") 236 + session := manager.CreateSession(digest, Buffered, "") 237 + log.Printf("Started buffered multipart: uploadID=%s", session.UploadID) 238 + return session.UploadID, Buffered, nil 239 + } 240 + 241 + // Try S3 native multipart first 242 + if s.s3Client != nil { 243 + s3UploadID, err := s.startMultipartUpload(ctx, digest) 244 + if err == nil { 245 + // S3 native multipart succeeded 246 + session := manager.CreateSession(digest, S3Native, s3UploadID) 247 + log.Printf("Started S3 native multipart: uploadID=%s, s3UploadID=%s", session.UploadID, s3UploadID) 248 + return session.UploadID, S3Native, nil 249 + } 250 + log.Printf("S3 native multipart failed, falling back to buffered mode: %v", err) 251 + } 252 + 253 + // Fallback to buffered mode 254 + session := manager.CreateSession(digest, Buffered, "") 255 + log.Printf("Started buffered multipart: uploadID=%s", session.UploadID) 256 + return session.UploadID, Buffered, nil 257 + } 258 + 259 + // GetPartUploadURL generates a URL for uploading a part 260 + // For S3Native: returns presigned URL 261 + // For Buffered: returns proxy endpoint 262 + func (s *HoldService) GetPartUploadURL(ctx context.Context, session *MultipartSession, partNumber int, did string) (string, error) { 263 + if session.Mode == S3Native { 264 + // Generate S3 presigned URL for this part 265 + url, err := s.getPartPresignedURL(ctx, session.Digest, session.S3UploadID, partNumber) 266 + if err != nil { 267 + return "", fmt.Errorf("failed to generate S3 part URL: %w", err) 268 + } 269 + return url, nil 270 + } 271 + 272 + // Buffered mode: return proxy endpoint 273 + url := fmt.Sprintf("%s/multipart-parts/%s/%d?did=%s", 274 + s.config.Server.PublicURL, session.UploadID, partNumber, did) 275 + return url, nil 276 + } 277 + 278 + // CompleteMultipartUploadWithManager completes a multipart upload 279 + func (s *HoldService) CompleteMultipartUploadWithManager(ctx context.Context, session *MultipartSession, manager *MultipartManager) error { 280 + defer manager.DeleteSession(session.UploadID) 281 + 282 + if session.Mode == S3Native { 283 + // Complete S3 multipart upload 284 + parts := session.GetCompletedParts() 285 + if err := s.completeMultipartUpload(ctx, session.Digest, session.S3UploadID, parts); err != nil { 286 + return fmt.Errorf("failed to complete S3 multipart: %w", err) 287 + } 288 + log.Printf("Completed S3 native multipart: uploadID=%s, parts=%d", session.UploadID, len(parts)) 289 + return nil 290 + } 291 + 292 + // Buffered mode: assemble parts and write via driver 293 + data, size, err := session.AssembleBufferedParts() 294 + if err != nil { 295 + return fmt.Errorf("failed to assemble parts: %w", err) 296 + } 297 + 298 + // Write assembled blob to storage 299 + path := blobPath(session.Digest) 300 + writer, err := s.driver.Writer(ctx, path, false) 301 + if err != nil { 302 + return fmt.Errorf("failed to create writer: %w", err) 303 + } 304 + 305 + written, err := writer.Write(data) 306 + if err != nil { 307 + writer.Cancel(ctx) 308 + return fmt.Errorf("failed to write blob: %w", err) 309 + } 310 + 311 + if err := writer.Commit(ctx); err != nil { 312 + return fmt.Errorf("failed to commit blob: %w", err) 313 + } 314 + 315 + log.Printf("Completed buffered multipart: uploadID=%s, size=%d bytes, written=%d", session.UploadID, size, written) 316 + return nil 317 + } 318 + 319 + // AbortMultipartUploadWithManager aborts a multipart upload 320 + func (s *HoldService) AbortMultipartUploadWithManager(ctx context.Context, session *MultipartSession, manager *MultipartManager) error { 321 + defer manager.DeleteSession(session.UploadID) 322 + 323 + if session.Mode == S3Native { 324 + // Abort S3 multipart upload 325 + if err := s.abortMultipartUpload(ctx, session.Digest, session.S3UploadID); err != nil { 326 + return fmt.Errorf("failed to abort S3 multipart: %w", err) 327 + } 328 + log.Printf("Aborted S3 native multipart: uploadID=%s", session.UploadID) 329 + return nil 330 + } 331 + 332 + // Buffered mode: just delete the session (parts are in memory) 333 + log.Printf("Aborted buffered multipart: uploadID=%s", session.UploadID) 334 + return nil 335 + } 336 + 337 + // HandleMultipartPartUpload handles uploading a part in buffered mode 338 + // This is a new endpoint: PUT /multipart-parts/{uploadID}/{partNumber} 339 + func (s *HoldService) HandleMultipartPartUpload(w http.ResponseWriter, r *http.Request, uploadID string, partNumber int, did string, manager *MultipartManager) { 340 + if r.Method != http.MethodPut { 341 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 342 + return 343 + } 344 + 345 + // Get session 346 + session, err := manager.GetSession(uploadID) 347 + if err != nil { 348 + http.Error(w, fmt.Sprintf("session not found: %v", err), http.StatusNotFound) 349 + return 350 + } 351 + 352 + // Verify authorization 353 + if !s.isAuthorizedWrite(did) { 354 + if did == "" { 355 + http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 356 + } else { 357 + http.Error(w, "forbidden: write access denied", http.StatusForbidden) 358 + } 359 + return 360 + } 361 + 362 + // Verify session is in buffered mode 363 + if session.Mode != Buffered { 364 + http.Error(w, "session is not in buffered mode", http.StatusBadRequest) 365 + return 366 + } 367 + 368 + // Read part data 369 + data, err := io.ReadAll(r.Body) 370 + if err != nil { 371 + http.Error(w, fmt.Sprintf("failed to read part data: %v", err), http.StatusInternalServerError) 372 + return 373 + } 374 + 375 + // Store part and get ETag 376 + etag := session.StorePart(partNumber, data) 377 + 378 + // Return ETag in response 379 + w.Header().Set("ETag", etag) 380 + w.WriteHeader(http.StatusOK) 381 + }
+267
pkg/hold/registration.go
··· 1 + package hold 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + "net/url" 10 + "strings" 11 + "time" 12 + 13 + "atcr.io/pkg/atproto" 14 + "atcr.io/pkg/auth/oauth" 15 + "github.com/bluesky-social/indigo/atproto/identity" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + ) 18 + 19 + // HealthHandler handles health check requests 20 + func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) { 21 + w.Header().Set("Content-Type", "application/json") 22 + w.Write([]byte(`{"status":"ok"}`)) 23 + } 24 + 25 + // isHoldRegistered checks if a hold with the given public URL is already registered in the PDS 26 + func (s *HoldService) isHoldRegistered(ctx context.Context, did, pdsEndpoint, publicURL string) (bool, error) { 27 + // We need to query the PDS without authentication to check public records 28 + // ATProto records are publicly readable, so we can use an unauthenticated client 29 + client := atproto.NewClient(pdsEndpoint, did, "") 30 + 31 + // List all hold records for this DID 32 + records, err := client.ListRecords(ctx, atproto.HoldCollection, 100) 33 + if err != nil { 34 + return false, fmt.Errorf("failed to list hold records: %w", err) 35 + } 36 + 37 + // Check if any hold record matches our public URL 38 + for _, record := range records { 39 + var holdRecord atproto.HoldRecord 40 + if err := json.Unmarshal(record.Value, &holdRecord); err != nil { 41 + continue 42 + } 43 + 44 + if holdRecord.Endpoint == publicURL { 45 + return true, nil 46 + } 47 + } 48 + 49 + return false, nil 50 + } 51 + 52 + // AutoRegister registers this hold service in the owner's PDS 53 + // Checks if already registered first, then does OAuth if needed 54 + func (s *HoldService) AutoRegister(callbackHandler *http.HandlerFunc) error { 55 + reg := &s.config.Registration 56 + publicURL := s.config.Server.PublicURL 57 + 58 + if publicURL == "" { 59 + return fmt.Errorf("HOLD_PUBLIC_URL not set") 60 + } 61 + 62 + if reg.OwnerDID == "" { 63 + return fmt.Errorf("HOLD_OWNER not set - required for registration") 64 + } 65 + 66 + ctx := context.Background() 67 + 68 + log.Printf("Checking registration status for DID: %s", reg.OwnerDID) 69 + 70 + // Resolve DID to PDS endpoint using indigo 71 + directory := identity.DefaultDirectory() 72 + didParsed, err := syntax.ParseDID(reg.OwnerDID) 73 + if err != nil { 74 + return fmt.Errorf("invalid owner DID: %w", err) 75 + } 76 + 77 + ident, err := directory.LookupDID(ctx, didParsed) 78 + if err != nil { 79 + return fmt.Errorf("failed to resolve PDS for DID: %w", err) 80 + } 81 + 82 + pdsEndpoint := ident.PDSEndpoint() 83 + if pdsEndpoint == "" { 84 + return fmt.Errorf("no PDS endpoint found for DID") 85 + } 86 + 87 + log.Printf("PDS endpoint: %s", pdsEndpoint) 88 + 89 + // Check if hold is already registered 90 + isRegistered, err := s.isHoldRegistered(ctx, reg.OwnerDID, pdsEndpoint, publicURL) 91 + if err != nil { 92 + log.Printf("Warning: failed to check registration status: %v", err) 93 + log.Printf("Proceeding with OAuth registration...") 94 + } else if isRegistered { 95 + log.Printf("✓ Hold service already registered in PDS") 96 + log.Printf("Public URL: %s", publicURL) 97 + return nil 98 + } 99 + 100 + // Not registered, need to do OAuth 101 + log.Printf("Hold not registered, starting OAuth flow...") 102 + 103 + // Get handle from DID document (already resolved above) 104 + handle := ident.Handle.String() 105 + if handle == "" || handle == "handle.invalid" { 106 + return fmt.Errorf("no valid handle found for DID") 107 + } 108 + 109 + log.Printf("Resolved handle: %s", handle) 110 + log.Printf("Starting OAuth registration for hold service") 111 + log.Printf("Public URL: %s", publicURL) 112 + 113 + return s.registerWithOAuth(publicURL, handle, reg.OwnerDID, pdsEndpoint, callbackHandler) 114 + } 115 + 116 + // registerWithOAuth performs OAuth flow and registers the hold 117 + func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string, callbackHandler *http.HandlerFunc) error { 118 + // Define the scopes we need for hold registration 119 + holdScopes := []string{ 120 + "atproto", 121 + fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection), 122 + fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection), 123 + fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection), 124 + fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), 125 + fmt.Sprintf("repo:%s?action=create", atproto.SailorProfileCollection), 126 + fmt.Sprintf("repo:%s?action=update", atproto.SailorProfileCollection), 127 + } 128 + 129 + // Determine base URL based on mode 130 + // Callback path standardized to /auth/oauth/callback across ATCR 131 + var baseURL string 132 + 133 + if s.config.Server.TestMode { 134 + // Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record 135 + // Extract port from publicURL (e.g., "http://172.28.0.3:8080" -> ":8080") 136 + parsedURL, err := url.Parse(publicURL) 137 + if err != nil { 138 + return fmt.Errorf("failed to parse public URL: %w", err) 139 + } 140 + port := parsedURL.Port() 141 + if port == "" { 142 + port = "8080" // default 143 + } 144 + baseURL = fmt.Sprintf("http://127.0.0.1:%s", port) 145 + } else { 146 + baseURL = publicURL 147 + } 148 + 149 + // Run interactive OAuth flow with persistent server 150 + ctx := context.Background() 151 + 152 + result, err := oauth.InteractiveFlowWithCallback( 153 + ctx, 154 + baseURL, 155 + handle, 156 + holdScopes, // Pass hold-specific scopes 157 + func(handler http.HandlerFunc) error { 158 + // Populate the pre-registered callback handler 159 + *callbackHandler = handler 160 + return nil 161 + }, 162 + func(authURL string) error { 163 + // Display OAuth URL for user to visit 164 + log.Print("\n" + strings.Repeat("=", 80)) 165 + log.Printf("OAUTH AUTHORIZATION REQUIRED") 166 + log.Print(strings.Repeat("=", 80)) 167 + log.Printf("\nPlease visit this URL to authorize the hold service:\n") 168 + log.Printf(" %s\n", authURL) 169 + log.Printf("Waiting for authorization...") 170 + log.Print(strings.Repeat("=", 80) + "\n") 171 + return nil 172 + }, 173 + ) 174 + if err != nil { 175 + return err 176 + } 177 + 178 + log.Printf("Authorization received!") 179 + log.Printf("OAuth session obtained successfully") 180 + log.Printf("DID: %s", did) 181 + log.Printf("PDS: %s", pdsEndpoint) 182 + 183 + // Create ATProto client with indigo's API client (handles DPoP automatically) 184 + apiClient := result.Session.APIClient() 185 + client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient) 186 + 187 + return s.registerWithClient(publicURL, did, client) 188 + } 189 + 190 + // registerWithClient registers the hold using an authenticated ATProto client 191 + func (s *HoldService) registerWithClient(publicURL, did string, client *atproto.Client) error { 192 + // Derive hold name from URL (hostname) 193 + holdName, err := extractHostname(publicURL) 194 + if err != nil { 195 + return fmt.Errorf("failed to extract hostname from URL: %w", err) 196 + } 197 + 198 + log.Printf("Registering hold service: url=%s, name=%s, owner=%s", publicURL, holdName, did) 199 + 200 + ctx := context.Background() 201 + 202 + // Create HoldRecord 203 + holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public) 204 + 205 + // Use hostname as record key 206 + holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord) 207 + if err != nil { 208 + return fmt.Errorf("failed to create hold record: %w", err) 209 + } 210 + 211 + log.Printf("✓ Created hold record: %s", holdResult.URI) 212 + 213 + // Create HoldCrewRecord for the owner 214 + crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, did, "owner") 215 + 216 + crewRKey := fmt.Sprintf("%s-%s", holdName, did) 217 + crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord) 218 + if err != nil { 219 + return fmt.Errorf("failed to create crew record: %w", err) 220 + } 221 + 222 + log.Printf("✓ Created crew record: %s", crewResult.URI) 223 + 224 + // Update sailor profile to set this as the default hold 225 + profile, err := atproto.GetProfile(ctx, client) 226 + if err != nil { 227 + log.Printf("Warning: failed to get sailor profile: %v", err) 228 + } else { 229 + if profile == nil { 230 + // Create new profile with this hold as default 231 + profile = atproto.NewSailorProfileRecord(publicURL) 232 + } else { 233 + // Update existing profile with new defaultHold 234 + profile.DefaultHold = publicURL 235 + profile.UpdatedAt = time.Now() 236 + } 237 + 238 + err = atproto.UpdateProfile(ctx, client, profile) 239 + if err != nil { 240 + log.Printf("Warning: failed to update sailor profile: %v", err) 241 + } else { 242 + log.Printf("✓ Updated sailor profile defaultHold: %s", publicURL) 243 + } 244 + } 245 + 246 + log.Print("\n" + strings.Repeat("=", 80)) 247 + log.Printf("REGISTRATION COMPLETE") 248 + log.Print(strings.Repeat("=", 80)) 249 + log.Printf("Hold service is now registered and ready to use!") 250 + log.Print(strings.Repeat("=", 80) + "\n") 251 + 252 + return nil 253 + } 254 + 255 + // extractHostname extracts the hostname from a URL to use as the hold name 256 + func extractHostname(urlStr string) (string, error) { 257 + u, err := url.Parse(urlStr) 258 + if err != nil { 259 + return "", err 260 + } 261 + // Remove port if present 262 + hostname := u.Hostname() 263 + if hostname == "" { 264 + return "", fmt.Errorf("no hostname in URL") 265 + } 266 + return hostname, nil 267 + }
+221
pkg/hold/s3.go
··· 1 + package hold 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "sort" 8 + "strings" 9 + "time" 10 + 11 + "github.com/aws/aws-sdk-go/aws" 12 + "github.com/aws/aws-sdk-go/aws/credentials" 13 + "github.com/aws/aws-sdk-go/aws/session" 14 + "github.com/aws/aws-sdk-go/service/s3" 15 + ) 16 + 17 + // initS3Client initializes the S3 client for presigned URL generation 18 + // Returns nil error if S3 client is successfully initialized 19 + // Returns error if storage is not S3 or if initialization fails (service will fall back to proxy mode) 20 + func (s *HoldService) initS3Client() error { 21 + // Check if presigned URLs are explicitly disabled 22 + if s.config.Server.DisablePresignedURLs { 23 + log.Printf("⚠️ S3 presigned URLs DISABLED by config (DISABLE_PRESIGNED_URLS=true)") 24 + log.Printf(" All uploads will use buffered mode (parts buffered in hold service)") 25 + return nil // Not an error - just using buffered mode 26 + } 27 + 28 + // Check if storage driver is S3 29 + if s.config.Storage.Type() != "s3" { 30 + log.Printf("Storage driver is %s (not S3), presigned URLs disabled", s.config.Storage.Type()) 31 + return nil // Not an error - just using different driver 32 + } 33 + 34 + // Extract S3 configuration from storage parameters 35 + params := s.config.Storage.Parameters() 36 + 37 + // Extract required S3 configuration 38 + region, _ := params["region"].(string) 39 + if region == "" { 40 + region = "us-east-1" // Default region 41 + } 42 + 43 + accessKey, _ := params["accesskey"].(string) 44 + secretKey, _ := params["secretkey"].(string) 45 + bucket, _ := params["bucket"].(string) 46 + 47 + if bucket == "" { 48 + return fmt.Errorf("S3 bucket not configured") 49 + } 50 + 51 + // Build AWS config 52 + awsConfig := &aws.Config{ 53 + Region: aws.String(region), 54 + } 55 + 56 + // Add credentials if provided (allow IAM role auth if not provided) 57 + if accessKey != "" && secretKey != "" { 58 + awsConfig.Credentials = credentials.NewStaticCredentials(accessKey, secretKey, "") 59 + } 60 + 61 + // Add custom endpoint for S3-compatible services (Storj, MinIO, R2, etc.) 62 + if endpoint, ok := params["regionendpoint"].(string); ok && endpoint != "" { 63 + awsConfig.Endpoint = aws.String(endpoint) 64 + awsConfig.S3ForcePathStyle = aws.Bool(true) // Required for MinIO, Storj 65 + } 66 + 67 + // Create AWS session 68 + sess, err := session.NewSession(awsConfig) 69 + if err != nil { 70 + return fmt.Errorf("failed to create AWS session: %w", err) 71 + } 72 + 73 + // Create S3 client 74 + s.s3Client = s3.New(sess) 75 + s.bucket = bucket 76 + 77 + // Extract path prefix if configured (rootdirectory in S3 params) 78 + if rootDir, ok := params["rootdirectory"].(string); ok && rootDir != "" { 79 + s.s3PathPrefix = strings.TrimPrefix(rootDir, "/") 80 + } 81 + 82 + log.Printf("✅ S3 presigned URLs enabled") 83 + 84 + return nil 85 + } 86 + 87 + // startMultipartUpload initiates a multipart upload and returns upload ID 88 + func (s *HoldService) startMultipartUpload(ctx context.Context, digest string) (string, error) { 89 + if s.s3Client == nil { 90 + return "", fmt.Errorf("S3 not configured") 91 + } 92 + 93 + path := blobPath(digest) 94 + s3Key := strings.TrimPrefix(path, "/") 95 + if s.s3PathPrefix != "" { 96 + s3Key = s.s3PathPrefix + "/" + s3Key 97 + } 98 + 99 + result, err := s.s3Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{ 100 + Bucket: aws.String(s.bucket), 101 + Key: aws.String(s3Key), 102 + }) 103 + if err != nil { 104 + return "", err 105 + } 106 + 107 + log.Printf("Started multipart upload: digest=%s, uploadID=%s", digest, *result.UploadId) 108 + return *result.UploadId, nil 109 + } 110 + 111 + // getPartPresignedURL generates presigned URL for a specific part 112 + func (s *HoldService) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) { 113 + if s.s3Client == nil { 114 + return "", fmt.Errorf("S3 not configured") 115 + } 116 + 117 + path := blobPath(digest) 118 + s3Key := strings.TrimPrefix(path, "/") 119 + if s.s3PathPrefix != "" { 120 + s3Key = s.s3PathPrefix + "/" + s3Key 121 + } 122 + 123 + req, _ := s.s3Client.UploadPartRequest(&s3.UploadPartInput{ 124 + Bucket: aws.String(s.bucket), 125 + Key: aws.String(s3Key), 126 + UploadId: aws.String(uploadID), 127 + PartNumber: aws.Int64(int64(partNumber)), 128 + }) 129 + 130 + url, err := req.Presign(15 * time.Minute) 131 + if err != nil { 132 + return "", err 133 + } 134 + 135 + log.Printf("Generated part presigned URL: digest=%s, uploadID=%s, part=%d", digest, uploadID, partNumber) 136 + return url, nil 137 + } 138 + 139 + // normalizeETag ensures an ETag has quotes (required by S3 CompleteMultipartUpload) 140 + // S3 returns ETags with quotes, but HTTP clients may strip them 141 + func normalizeETag(etag string) string { 142 + // Already has quotes 143 + if strings.HasPrefix(etag, "\"") && strings.HasSuffix(etag, "\"") { 144 + return etag 145 + } 146 + // Add quotes 147 + return fmt.Sprintf("\"%s\"", etag) 148 + } 149 + 150 + // completeMultipartUpload finalizes the multipart upload 151 + func (s *HoldService) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error { 152 + if s.s3Client == nil { 153 + return fmt.Errorf("S3 not configured") 154 + } 155 + 156 + path := blobPath(digest) 157 + s3Key := strings.TrimPrefix(path, "/") 158 + if s.s3PathPrefix != "" { 159 + s3Key = s.s3PathPrefix + "/" + s3Key 160 + } 161 + 162 + // Sort parts by part number (S3 requires ascending order) 163 + sort.Slice(parts, func(i, j int) bool { 164 + return parts[i].PartNumber < parts[j].PartNumber 165 + }) 166 + 167 + // Convert to S3 CompletedPart format 168 + // IMPORTANT: S3 requires ETags to be quoted in the CompleteMultipartUpload XML 169 + s3Parts := make([]*s3.CompletedPart, len(parts)) 170 + for i, p := range parts { 171 + etag := normalizeETag(p.ETag) 172 + s3Parts[i] = &s3.CompletedPart{ 173 + PartNumber: aws.Int64(int64(p.PartNumber)), 174 + ETag: aws.String(etag), 175 + } 176 + } 177 + 178 + _, err := s.s3Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{ 179 + Bucket: aws.String(s.bucket), 180 + Key: aws.String(s3Key), 181 + UploadId: aws.String(uploadID), 182 + MultipartUpload: &s3.CompletedMultipartUpload{ 183 + Parts: s3Parts, 184 + }, 185 + }) 186 + 187 + if err != nil { 188 + log.Printf("Failed to complete multipart upload: digest=%s, uploadID=%s, err=%v", digest, uploadID, err) 189 + return err 190 + } 191 + 192 + log.Printf("Completed multipart upload: digest=%s, uploadID=%s, parts=%d", digest, uploadID, len(parts)) 193 + return nil 194 + } 195 + 196 + // abortMultipartUpload aborts an in-progress multipart upload 197 + func (s *HoldService) abortMultipartUpload(ctx context.Context, digest, uploadID string) error { 198 + if s.s3Client == nil { 199 + return fmt.Errorf("S3 not configured") 200 + } 201 + 202 + path := blobPath(digest) 203 + s3Key := strings.TrimPrefix(path, "/") 204 + if s.s3PathPrefix != "" { 205 + s3Key = s.s3PathPrefix + "/" + s3Key 206 + } 207 + 208 + _, err := s.s3Client.AbortMultipartUploadWithContext(ctx, &s3.AbortMultipartUploadInput{ 209 + Bucket: aws.String(s.bucket), 210 + Key: aws.String(s3Key), 211 + UploadId: aws.String(uploadID), 212 + }) 213 + 214 + if err != nil { 215 + log.Printf("Failed to abort multipart upload: digest=%s, uploadID=%s, err=%v", digest, uploadID, err) 216 + return err 217 + } 218 + 219 + log.Printf("Aborted multipart upload: digest=%s, uploadID=%s", digest, uploadID) 220 + return nil 221 + }
+44
pkg/hold/service.go
··· 1 + package hold 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + 8 + "github.com/aws/aws-sdk-go/service/s3" 9 + storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 10 + "github.com/distribution/distribution/v3/registry/storage/driver/factory" 11 + ) 12 + 13 + // HoldService provides presigned URLs for blob storage in a hold 14 + type HoldService struct { 15 + driver storagedriver.StorageDriver 16 + config *Config 17 + s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage) 18 + bucket string // S3 bucket name 19 + s3PathPrefix string // S3 path prefix (if any) 20 + MultipartMgr *MultipartManager // Exported for access in route handlers 21 + } 22 + 23 + // NewHoldService creates a new hold service 24 + func NewHoldService(cfg *Config) (*HoldService, error) { 25 + // Create storage driver from config 26 + ctx := context.Background() 27 + driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters()) 28 + if err != nil { 29 + return nil, fmt.Errorf("failed to create storage driver: %w", err) 30 + } 31 + 32 + service := &HoldService{ 33 + driver: driver, 34 + config: cfg, 35 + MultipartMgr: NewMultipartManager(), 36 + } 37 + 38 + // Initialize S3 client for presigned URLs (if using S3 storage) 39 + if err := service.initS3Client(); err != nil { 40 + log.Printf("WARNING: S3 presigned URLs disabled: %v", err) 41 + } 42 + 43 + return service, nil 44 + }
+115
pkg/hold/storage.go
··· 1 + package hold 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "strings" 8 + "time" 9 + 10 + "github.com/aws/aws-sdk-go/aws" 11 + "github.com/aws/aws-sdk-go/service/s3" 12 + ) 13 + 14 + // blobPath converts a digest (e.g., "sha256:abc123...") or temp path to a storage path 15 + // Distribution stores blobs as: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data 16 + // where xx is the first 2 characters of the hash for directory sharding 17 + // NOTE: Path must start with / for filesystem driver 18 + func blobPath(digest string) string { 19 + // Handle temp paths (start with uploads/temp-) 20 + if strings.HasPrefix(digest, "uploads/temp-") { 21 + return fmt.Sprintf("/docker/registry/v2/%s/data", digest) 22 + } 23 + 24 + // Split digest into algorithm and hash 25 + parts := strings.SplitN(digest, ":", 2) 26 + if len(parts) != 2 { 27 + // Fallback for malformed digest 28 + return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) 29 + } 30 + 31 + algorithm := parts[0] 32 + hash := parts[1] 33 + 34 + // Use first 2 characters for sharding 35 + if len(hash) < 2 { 36 + return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash) 37 + } 38 + 39 + return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash) 40 + } 41 + 42 + // getPresignedURL generates a presigned URL for GET, HEAD, or PUT operations 43 + func (s *HoldService) getPresignedURL(ctx context.Context, operation PresignedURLOperation, digest string, did string) (string, error) { 44 + path := blobPath(digest) 45 + 46 + // Check blob exists for GET/HEAD operations (not for PUT since blob doesn't exist yet) 47 + if operation == OperationGet || operation == OperationHead { 48 + if _, err := s.driver.Stat(ctx, path); err != nil { 49 + return "", fmt.Errorf("blob not found: %w", err) 50 + } 51 + } 52 + 53 + // Check if presigned URLs are disabled 54 + if s.config.Server.DisablePresignedURLs { 55 + log.Printf("Presigned URLs disabled, using proxy URL") 56 + return s.getProxyURL(digest, did), nil 57 + } 58 + 59 + // Generate presigned URL if S3 client is available 60 + if s.s3Client != nil { 61 + // Build S3 key from blob path 62 + s3Key := strings.TrimPrefix(path, "/") 63 + if s.s3PathPrefix != "" { 64 + s3Key = s.s3PathPrefix + "/" + s3Key 65 + } 66 + 67 + // Create appropriate S3 request based on operation 68 + var req interface { 69 + Presign(time.Duration) (string, error) 70 + } 71 + switch operation { 72 + case OperationGet: 73 + // Note: Don't use ResponseContentType - not supported by all S3-compatible services 74 + req, _ = s.s3Client.GetObjectRequest(&s3.GetObjectInput{ 75 + Bucket: aws.String(s.bucket), 76 + Key: aws.String(s3Key), 77 + }) 78 + 79 + case OperationHead: 80 + req, _ = s.s3Client.HeadObjectRequest(&s3.HeadObjectInput{ 81 + Bucket: aws.String(s.bucket), 82 + Key: aws.String(s3Key), 83 + }) 84 + 85 + case OperationPut: 86 + req, _ = s.s3Client.PutObjectRequest(&s3.PutObjectInput{ 87 + Bucket: aws.String(s.bucket), 88 + Key: aws.String(s3Key), 89 + ContentType: aws.String("application/octet-stream"), 90 + }) 91 + 92 + default: 93 + return "", fmt.Errorf("unsupported operation: %s", operation) 94 + } 95 + 96 + // Generate presigned URL with 15 minute expiry 97 + url, err := req.Presign(15 * time.Minute) 98 + if err != nil { 99 + log.Printf("[getPresignedURL] Presign FAILED for %s: %v", operation, err) 100 + log.Printf(" Falling back to proxy URL") 101 + return s.getProxyURL(digest, did), nil 102 + } 103 + 104 + return url, nil 105 + } 106 + 107 + // Fallback: return proxy URL through this service 108 + return s.getProxyURL(digest, did), nil 109 + } 110 + 111 + // getProxyURL returns a proxy URL for blob operations (fallback when presigned URLs unavailable) 112 + func (s *HoldService) getProxyURL(digest, did string) string { 113 + // All operations use the same proxy endpoint 114 + return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did) 115 + }
+1 -1
pkg/middleware/registry.go pkg/appview/middleware/registry.go
··· 14 14 "github.com/distribution/distribution/v3/registry/storage/driver" 15 15 "github.com/distribution/reference" 16 16 17 + "atcr.io/pkg/appview/storage" 17 18 "atcr.io/pkg/atproto" 18 19 "atcr.io/pkg/auth" 19 20 "atcr.io/pkg/auth/oauth" 20 - "atcr.io/pkg/storage" 21 21 ) 22 22 23 23 // Global refresher instance (set by main.go)
pkg/storage/hold_cache.go pkg/appview/storage/hold_cache.go
+229 -192
pkg/storage/proxy_blob_store.go pkg/appview/storage/proxy_blob_store.go
··· 7 7 "fmt" 8 8 "io" 9 9 "net/http" 10 - "strings" 11 10 "sync" 12 11 "time" 13 12 ··· 16 15 ) 17 16 18 17 const ( 19 - // minPartSize is S3's minimum part size for multipart uploads 20 - // Parts must be at least 5MB (except the last part) 21 - minPartSize = 5 * 1024 * 1024 // 5MB 18 + // maxChunkSize is the maximum buffer size before flushing to hold service 19 + // Matches S3's minimum multipart upload size 20 + maxChunkSize = 5 * 1024 * 1024 // 5MB 22 21 ) 23 - 24 - // CompletedPart represents a completed multipart upload part 25 - type CompletedPart struct { 26 - PartNumber int `json:"part_number"` 27 - ETag string `json:"etag"` 28 - } 29 22 30 23 // Global upload tracking (shared across all ProxyBlobStore instances) 31 24 // This is necessary because distribution creates new repository/blob store instances per request ··· 66 59 67 60 // Stat returns the descriptor for a blob 68 61 func (p *ProxyBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) { 69 - // Quick HEAD request to hold service to check if blob exists 70 - url := fmt.Sprintf("%s/blobs/%s?did=%s", p.storageEndpoint, dgst.String(), p.did) 62 + // Get presigned HEAD URL 63 + url, err := p.getHeadURL(ctx, dgst) 64 + if err != nil { 65 + return distribution.Descriptor{}, distribution.ErrBlobUnknown 66 + } 67 + 68 + // Make HEAD request to presigned URL 71 69 req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) 72 70 if err != nil { 73 71 return distribution.Descriptor{}, distribution.ErrBlobUnknown ··· 149 147 // Get upload URL 150 148 url, err := p.getUploadURL(ctx, dgst, int64(len(content))) 151 149 if err != nil { 150 + fmt.Printf("[proxy_blob_store/Put] Failed to get upload URL: digest=%s, error=%v\n", dgst, err) 152 151 return distribution.Descriptor{}, err 153 152 } 154 153 155 154 // Upload the blob 156 155 req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(content)) 157 156 if err != nil { 157 + fmt.Printf("[proxy_blob_store/Put] Failed to create request: %v\n", err) 158 158 return distribution.Descriptor{}, err 159 159 } 160 160 req.Header.Set("Content-Type", "application/octet-stream") 161 161 162 162 resp, err := p.httpClient.Do(req) 163 163 if err != nil { 164 + fmt.Printf("[proxy_blob_store/Put] HTTP request failed: %v\n", err) 164 165 return distribution.Descriptor{}, err 165 166 } 166 167 defer resp.Body.Close() 167 168 168 169 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 169 - return distribution.Descriptor{}, fmt.Errorf("upload failed with status %d", resp.StatusCode) 170 + bodyBytes, _ := io.ReadAll(resp.Body) 171 + fmt.Printf(" Error Body: %s\n", string(bodyBytes)) 172 + return distribution.Descriptor{}, fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 170 173 } 174 + 175 + fmt.Printf("[proxy_blob_store/Put] Upload successful: digest=%s, size=%d\n", dgst, len(content)) 171 176 172 177 return distribution.Descriptor{ 173 178 Digest: dgst, ··· 184 189 185 190 // ServeBlob serves a blob via HTTP redirect 186 191 func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error { 187 - // Get presigned download URL 192 + // For HEAD requests, redirect to presigned HEAD URL 193 + if r.Method == http.MethodHead { 194 + url, err := p.getHeadURL(ctx, dgst) 195 + if err != nil { 196 + return err 197 + } 198 + 199 + // Redirect to presigned HEAD URL 200 + http.Redirect(w, r, url, http.StatusTemporaryRedirect) 201 + return nil 202 + } 203 + 204 + // For GET requests, redirect to presigned URL 188 205 url, err := p.getDownloadURL(ctx, dgst) 189 206 if err != nil { 190 207 return err ··· 195 212 return nil 196 213 } 197 214 198 - // Create returns a blob writer for uploading 215 + // Create returns a blob writer for uploading using multipart upload 199 216 func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) { 200 217 // Parse options 201 218 var opts distribution.CreateOptions ··· 205 222 } 206 223 } 207 224 208 - // Use temp digest for upload location 225 + // Generate unique writer ID 209 226 writerID := fmt.Sprintf("upload-%d", time.Now().UnixNano()) 210 - tempPath := fmt.Sprintf("uploads/temp-%s", writerID) 211 - tempDigest := digest.Digest(tempPath) 227 + 228 + // Use temp digest for upload location (will be moved to final digest on commit) 229 + tempDigest := fmt.Sprintf("uploads/temp-%s", writerID) 212 230 213 231 // Start multipart upload via hold service 214 232 uploadID, err := p.startMultipartUpload(ctx, tempDigest) ··· 216 234 return nil, fmt.Errorf("failed to start multipart upload: %w", err) 217 235 } 218 236 219 - fmt.Printf("DEBUG [proxy_blob_store/Create]: Started multipart upload: id=%s, uploadID=%s\n", writerID, uploadID) 237 + fmt.Printf(" Started multipart upload: uploadID=%s\n", uploadID) 220 238 221 239 writer := &ProxyBlobWriter{ 222 240 store: p, ··· 224 242 uploadID: uploadID, 225 243 parts: make([]CompletedPart, 0), 226 244 partNumber: 1, 227 - buffer: bytes.NewBuffer(make([]byte, 0, minPartSize)), 245 + buffer: bytes.NewBuffer(make([]byte, 0, maxChunkSize)), // 5MB buffer 228 246 id: writerID, 229 247 startedAt: time.Now(), 230 - tempDigest: tempDigest, 231 248 } 232 249 233 - // Store in global map for Resume() 250 + // Store in global uploads map for resume support 234 251 globalUploadsMu.Lock() 235 252 globalUploads[writer.id] = writer 236 253 globalUploadsMu.Unlock() ··· 249 266 return nil, distribution.ErrBlobUploadUnknown 250 267 } 251 268 269 + // Just return the writer - parts are buffered and flushed on demand 252 270 return writer, nil 253 271 } 254 272 255 - // getDownloadURL requests a presigned download URL from the storage service 256 - func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) (string, error) { 273 + // getPresignedURL requests a presigned URL from the storage service for any operation 274 + func (p *ProxyBlobStore) getPresignedURL(ctx context.Context, operation, dgst string, size int64) (string, error) { 257 275 reqBody := map[string]any{ 258 - "did": p.did, 259 - "digest": dgst.String(), 276 + "operation": operation, 277 + "did": p.did, 278 + "digest": dgst, 279 + } 280 + 281 + // Only include size for PUT operations 282 + if size > 0 { 283 + reqBody["size"] = size 260 284 } 261 285 262 286 body, err := json.Marshal(reqBody) ··· 264 288 return "", err 265 289 } 266 290 267 - url := fmt.Sprintf("%s/get-presigned-url", p.storageEndpoint) 291 + url := fmt.Sprintf("%s/presigned-url", p.storageEndpoint) 268 292 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 269 293 if err != nil { 270 294 return "", err ··· 278 302 defer resp.Body.Close() 279 303 280 304 if resp.StatusCode != http.StatusOK { 281 - return "", fmt.Errorf("failed to get download URL: status %d", resp.StatusCode) 305 + return "", fmt.Errorf("failed to get presigned URL: status %d", resp.StatusCode) 282 306 } 283 307 284 308 var result struct { ··· 291 315 return result.URL, nil 292 316 } 293 317 318 + // getDownloadURL requests a presigned download URL from the storage service 319 + func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) (string, error) { 320 + return p.getPresignedURL(ctx, "GET", dgst.String(), 0) 321 + } 322 + 323 + // getHeadURL requests a presigned HEAD URL from the storage service 324 + func (p *ProxyBlobStore) getHeadURL(ctx context.Context, dgst digest.Digest) (string, error) { 325 + return p.getPresignedURL(ctx, "HEAD", dgst.String(), 0) 326 + } 327 + 294 328 // getUploadURL requests a presigned upload URL from the storage service 295 329 func (p *ProxyBlobStore) getUploadURL(ctx context.Context, dgst digest.Digest, size int64) (string, error) { 296 330 fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: storageEndpoint=%s, digest=%s\n", p.storageEndpoint, dgst) 331 + url, err := p.getPresignedURL(ctx, "PUT", dgst.String(), size) 332 + if err == nil { 333 + fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: Got presigned URL=%s\n", url) 334 + } 335 + return url, err 336 + } 297 337 338 + // startMultipartUpload initiates a multipart upload via hold service 339 + func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string) (string, error) { 298 340 reqBody := map[string]any{ 299 341 "did": p.did, 300 - "digest": dgst.String(), 301 - "size": size, 342 + "digest": digest, 343 + } 344 + 345 + body, err := json.Marshal(reqBody) 346 + if err != nil { 347 + return "", err 348 + } 349 + 350 + url := fmt.Sprintf("%s/start-multipart", p.storageEndpoint) 351 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 352 + if err != nil { 353 + return "", err 354 + } 355 + req.Header.Set("Content-Type", "application/json") 356 + 357 + resp, err := p.httpClient.Do(req) 358 + if err != nil { 359 + return "", err 360 + } 361 + defer resp.Body.Close() 362 + 363 + if resp.StatusCode != http.StatusOK { 364 + bodyBytes, _ := io.ReadAll(resp.Body) 365 + return "", fmt.Errorf("start multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes)) 366 + } 367 + 368 + var result struct { 369 + UploadID string `json:"upload_id"` 370 + } 371 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 372 + return "", err 373 + } 374 + 375 + return result.UploadID, nil 376 + } 377 + 378 + // getPartPresignedURL gets a presigned URL for uploading a specific part 379 + func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) { 380 + reqBody := map[string]any{ 381 + "did": p.did, 382 + "digest": digest, 383 + "upload_id": uploadID, 384 + "part_number": partNumber, 302 385 } 303 386 304 387 body, err := json.Marshal(reqBody) ··· 306 389 return "", err 307 390 } 308 391 309 - url := fmt.Sprintf("%s/put-presigned-url", p.storageEndpoint) 310 - fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: Calling %s\n", url) 392 + url := fmt.Sprintf("%s/part-presigned-url", p.storageEndpoint) 311 393 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 312 394 if err != nil { 313 395 return "", err ··· 321 403 defer resp.Body.Close() 322 404 323 405 if resp.StatusCode != http.StatusOK { 324 - return "", fmt.Errorf("failed to get upload URL: status %d", resp.StatusCode) 406 + bodyBytes, _ := io.ReadAll(resp.Body) 407 + return "", fmt.Errorf("get part URL failed: status %d, body: %s", resp.StatusCode, string(bodyBytes)) 325 408 } 326 409 327 410 var result struct { ··· 331 414 return "", err 332 415 } 333 416 334 - fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: Got presigned URL=%s\n", result.URL) 335 417 return result.URL, nil 336 418 } 337 419 338 - // ProxyBlobWriter implements distribution.BlobWriter for proxy uploads 420 + // completeMultipartUpload completes a multipart upload via hold service 421 + func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error { 422 + reqBody := map[string]any{ 423 + "did": p.did, 424 + "digest": digest, 425 + "upload_id": uploadID, 426 + "parts": parts, 427 + } 428 + 429 + body, err := json.Marshal(reqBody) 430 + if err != nil { 431 + return err 432 + } 433 + 434 + url := fmt.Sprintf("%s/complete-multipart", p.storageEndpoint) 435 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 436 + if err != nil { 437 + return err 438 + } 439 + req.Header.Set("Content-Type", "application/json") 440 + 441 + resp, err := p.httpClient.Do(req) 442 + if err != nil { 443 + return err 444 + } 445 + defer resp.Body.Close() 446 + 447 + if resp.StatusCode != http.StatusOK { 448 + bodyBytes, _ := io.ReadAll(resp.Body) 449 + return fmt.Errorf("complete multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes)) 450 + } 451 + 452 + return nil 453 + } 454 + 455 + // abortMultipartUpload aborts a multipart upload via hold service 456 + func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, digest, uploadID string) error { 457 + reqBody := map[string]any{ 458 + "did": p.did, 459 + "digest": digest, 460 + "upload_id": uploadID, 461 + } 462 + 463 + body, err := json.Marshal(reqBody) 464 + if err != nil { 465 + return err 466 + } 467 + 468 + url := fmt.Sprintf("%s/abort-multipart", p.storageEndpoint) 469 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 470 + if err != nil { 471 + return err 472 + } 473 + req.Header.Set("Content-Type", "application/json") 474 + 475 + resp, err := p.httpClient.Do(req) 476 + if err != nil { 477 + return err 478 + } 479 + defer resp.Body.Close() 480 + 481 + if resp.StatusCode != http.StatusOK { 482 + bodyBytes, _ := io.ReadAll(resp.Body) 483 + return fmt.Errorf("abort multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes)) 484 + } 485 + 486 + return nil 487 + } 488 + 489 + // CompletedPart represents an uploaded part with its ETag 490 + type CompletedPart struct { 491 + PartNumber int `json:"part_number"` 492 + ETag string `json:"etag"` 493 + } 494 + 495 + // ProxyBlobWriter implements distribution.BlobWriter for proxy uploads using multipart upload 339 496 type ProxyBlobWriter struct { 340 497 store *ProxyBlobStore 341 498 options distribution.CreateOptions ··· 345 502 buffer *bytes.Buffer // Buffer for current part 346 503 size int64 // Total bytes written 347 504 closed bool 348 - id string // Distribution's upload ID (for state) 505 + id string // Distribution's upload ID (for state) 349 506 startedAt time.Time 350 - finalDigest string // Set on Commit 351 - tempDigest digest.Digest // Temp location digest 507 + finalDigest string // Set on Commit 352 508 } 353 509 354 510 // ID returns the upload ID ··· 362 518 } 363 519 364 520 // Write writes data to the upload 365 - // Buffers data and flushes parts when buffer reaches minPartSize 521 + // Buffers data and flushes when buffer reaches 5MB 366 522 func (w *ProxyBlobWriter) Write(p []byte) (int, error) { 367 523 if w.closed { 368 524 return 0, fmt.Errorf("writer closed") ··· 371 527 n, err := w.buffer.Write(p) 372 528 w.size += int64(n) 373 529 374 - // Flush if buffer reaches minimum part size (5MB) 375 - if w.buffer.Len() >= minPartSize { 530 + // Flush if buffer reaches 5MB (S3 minimum part size) 531 + if w.buffer.Len() >= maxChunkSize { 376 532 if err := w.flushPart(); err != nil { 377 - return n, fmt.Errorf("failed to flush part: %w", err) 533 + return n, err 378 534 } 379 535 } 380 536 381 537 return n, err 382 538 } 383 539 384 - // flushPart uploads the current buffer as a multipart upload part 540 + // flushPart uploads the current buffer as a part 385 541 func (w *ProxyBlobWriter) flushPart() error { 386 542 if w.buffer.Len() == 0 { 387 543 return nil ··· 391 547 defer cancel() 392 548 393 549 // Get presigned URL for this part 394 - url, err := w.store.getPartPresignedURL(ctx, w.tempDigest, w.uploadID, w.partNumber) 550 + tempDigest := fmt.Sprintf("uploads/temp-%s", w.id) 551 + url, err := w.store.getPartPresignedURL(ctx, tempDigest, w.uploadID, w.partNumber) 395 552 if err != nil { 396 553 return fmt.Errorf("failed to get part presigned URL: %w", err) 397 554 } ··· 403 560 } 404 561 req.Header.Set("Content-Type", "application/octet-stream") 405 562 406 - fmt.Printf("DEBUG [proxy_blob_store/flushPart]: Uploading part %d, size=%d bytes\n", w.partNumber, w.buffer.Len()) 407 - 408 563 resp, err := w.store.httpClient.Do(req) 409 564 if err != nil { 410 - return fmt.Errorf("part upload failed: %w", err) 565 + return err 411 566 } 412 567 defer resp.Body.Close() 413 568 ··· 422 577 return fmt.Errorf("no ETag in response") 423 578 } 424 579 425 - // Remove quotes from ETag if present (S3 sometimes adds them) 426 - etag = strings.Trim(etag, "\"") 427 - 428 580 w.parts = append(w.parts, CompletedPart{ 429 581 PartNumber: w.partNumber, 430 582 ETag: etag, 431 583 }) 432 584 433 - fmt.Printf("DEBUG [proxy_blob_store/flushPart]: Part %d uploaded successfully, ETag=%s\n", w.partNumber, etag) 585 + fmt.Printf("[flushPart] Part %d uploaded successfully: ETag=%s\n", w.partNumber, etag) 434 586 435 587 // Reset buffer and increment part number 436 588 w.buffer.Reset() ··· 474 626 return w.size 475 627 } 476 628 477 - // Commit finalizes the upload 629 + // Commit finalizes the upload by completing multipart upload and moving to final location 478 630 func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) { 479 631 if w.closed { 480 632 return distribution.Descriptor{}, fmt.Errorf("writer closed") 481 633 } 482 634 w.closed = true 483 635 484 - // Flush any remaining buffered data as the final part 636 + // Remove from global uploads map 637 + globalUploadsMu.Lock() 638 + delete(globalUploads, w.id) 639 + globalUploadsMu.Unlock() 640 + 641 + // Flush any remaining buffered data 485 642 if w.buffer.Len() > 0 { 643 + fmt.Printf("[Commit] Flushing final buffer: %d bytes\n", w.buffer.Len()) 486 644 if err := w.flushPart(); err != nil { 487 645 // Try to abort multipart on error 488 - w.store.abortMultipartUpload(ctx, w.tempDigest, w.uploadID) 646 + tempDigest := fmt.Sprintf("uploads/temp-%s", w.id) 647 + w.store.abortMultipartUpload(ctx, tempDigest, w.uploadID) 489 648 return distribution.Descriptor{}, fmt.Errorf("failed to flush final part: %w", err) 490 649 } 491 650 } 492 651 493 652 // Complete multipart upload at temp location 494 - if err := w.store.completeMultipartUpload(ctx, w.tempDigest, w.uploadID, w.parts); err != nil { 653 + tempDigest := fmt.Sprintf("uploads/temp-%s", w.id) 654 + fmt.Printf("🔒 [Commit] Completing multipart upload: uploadID=%s, parts=%d\n", w.uploadID, len(w.parts)) 655 + if err := w.store.completeMultipartUpload(ctx, tempDigest, w.uploadID, w.parts); err != nil { 495 656 return distribution.Descriptor{}, fmt.Errorf("failed to complete multipart upload: %w", err) 496 657 } 497 658 498 - fmt.Printf("DEBUG [proxy_blob_store/Commit]: Completed multipart upload with %d parts, total size=%d\n", len(w.parts), w.size) 499 - 500 659 // Move from temp → final location (server-side S3 copy) 501 660 tempPath := fmt.Sprintf("uploads/temp-%s", w.id) 502 661 finalPath := desc.Digest.String() 503 662 663 + fmt.Printf("[Commit] Moving blob: %s → %s\n", tempPath, finalPath) 504 664 moveURL := fmt.Sprintf("%s/move?from=%s&to=%s&did=%s", 505 665 w.store.storageEndpoint, tempPath, finalPath, w.store.did) 506 666 ··· 517 677 518 678 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 519 679 bodyBytes, _ := io.ReadAll(resp.Body) 520 - return distribution.Descriptor{}, fmt.Errorf("move failed: status %d, body: %s", resp.StatusCode, string(bodyBytes)) 680 + return distribution.Descriptor{}, fmt.Errorf("move blob failed: status %d, body: %s", resp.StatusCode, string(bodyBytes)) 521 681 } 522 682 523 - // Remove from global map 524 - globalUploadsMu.Lock() 525 - delete(globalUploads, w.id) 526 - globalUploadsMu.Unlock() 527 - 528 - fmt.Printf("DEBUG [proxy_blob_store/Commit]: Successfully committed: digest=%s, size=%d\n", desc.Digest, w.size) 683 + fmt.Printf("[Commit] Upload completed successfully: digest=%s, size=%d, parts=%d\n", desc.Digest, w.size, len(w.parts)) 529 684 530 685 return distribution.Descriptor{ 531 686 Digest: desc.Digest, ··· 534 689 }, nil 535 690 } 536 691 537 - // Cancel cancels the upload 692 + // Cancel cancels the upload by aborting the multipart upload 538 693 func (w *ProxyBlobWriter) Cancel(ctx context.Context) error { 539 694 w.closed = true 695 + 696 + fmt.Printf("[Cancel] Cancelling upload: id=%s\n", w.id) 540 697 541 698 // Remove from global uploads map 542 699 globalUploadsMu.Lock() 543 700 delete(globalUploads, w.id) 544 701 globalUploadsMu.Unlock() 545 702 546 - // Abort multipart upload on S3 547 - if err := w.store.abortMultipartUpload(ctx, w.tempDigest, w.uploadID); err != nil { 548 - fmt.Printf("DEBUG [proxy_blob_store/Cancel]: Failed to abort multipart upload: %v\n", err) 549 - // Continue anyway - we still want to clean up 703 + // Abort multipart upload 704 + tempDigest := fmt.Sprintf("uploads/temp-%s", w.id) 705 + if err := w.store.abortMultipartUpload(ctx, tempDigest, w.uploadID); err != nil { 706 + fmt.Printf("⚠️ [Cancel] Failed to abort multipart upload: %v\n", err) 707 + // Continue anyway - we want to mark upload as cancelled 550 708 } 551 709 552 - fmt.Printf("DEBUG [proxy_blob_store/Cancel]: Cancelled upload: id=%s, uploadID=%s\n", w.id, w.uploadID) 710 + fmt.Printf("[Cancel] Upload cancelled: id=%s\n", w.id) 553 711 return nil 554 712 } 555 713 556 714 // Close closes the writer 557 - // Does nothing - actual completion happens in Commit() or Cancel() 715 + // Parts are flushed on demand, so this is a no-op 558 716 func (w *ProxyBlobWriter) Close() error { 559 717 // Don't set w.closed = true - allow resuming for next PATCH 560 - return nil 561 - } 562 - 563 - // startMultipartUpload initiates a multipart upload via hold service 564 - func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, dgst digest.Digest) (string, error) { 565 - reqBody := map[string]any{ 566 - "did": p.did, 567 - "digest": dgst.String(), 568 - } 569 - body, _ := json.Marshal(reqBody) 570 - 571 - url := fmt.Sprintf("%s/start-multipart", p.storageEndpoint) 572 - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 573 - req.Header.Set("Content-Type", "application/json") 574 - 575 - resp, err := p.httpClient.Do(req) 576 - if err != nil { 577 - return "", err 578 - } 579 - defer resp.Body.Close() 580 - 581 - if resp.StatusCode != http.StatusOK { 582 - return "", fmt.Errorf("failed to start multipart upload: status %d", resp.StatusCode) 583 - } 584 - 585 - var result struct { 586 - UploadID string `json:"upload_id"` 587 - } 588 - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 589 - return "", err 590 - } 591 - 592 - return result.UploadID, nil 593 - } 594 - 595 - // getPartPresignedURL gets a presigned URL for uploading a specific part 596 - func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, dgst digest.Digest, uploadID string, partNumber int) (string, error) { 597 - reqBody := map[string]any{ 598 - "did": p.did, 599 - "digest": dgst.String(), 600 - "upload_id": uploadID, 601 - "part_number": partNumber, 602 - } 603 - body, _ := json.Marshal(reqBody) 604 - 605 - url := fmt.Sprintf("%s/part-presigned-url", p.storageEndpoint) 606 - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 607 - req.Header.Set("Content-Type", "application/json") 608 - 609 - resp, err := p.httpClient.Do(req) 610 - if err != nil { 611 - return "", err 612 - } 613 - defer resp.Body.Close() 614 - 615 - if resp.StatusCode != http.StatusOK { 616 - return "", fmt.Errorf("failed to get part presigned URL: status %d", resp.StatusCode) 617 - } 618 - 619 - var result struct { 620 - URL string `json:"url"` 621 - } 622 - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 623 - return "", err 624 - } 625 - 626 - return result.URL, nil 627 - } 628 - 629 - // completeMultipartUpload completes a multipart upload 630 - func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, dgst digest.Digest, uploadID string, parts []CompletedPart) error { 631 - reqBody := map[string]any{ 632 - "did": p.did, 633 - "digest": dgst.String(), 634 - "upload_id": uploadID, 635 - "parts": parts, 636 - } 637 - body, _ := json.Marshal(reqBody) 638 - 639 - url := fmt.Sprintf("%s/complete-multipart", p.storageEndpoint) 640 - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 641 - req.Header.Set("Content-Type", "application/json") 642 - 643 - resp, err := p.httpClient.Do(req) 644 - if err != nil { 645 - return err 646 - } 647 - defer resp.Body.Close() 648 - 649 - if resp.StatusCode != http.StatusOK { 650 - bodyBytes, _ := io.ReadAll(resp.Body) 651 - return fmt.Errorf("complete multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes)) 652 - } 653 - 654 - return nil 655 - } 656 - 657 - // abortMultipartUpload aborts a multipart upload 658 - func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, dgst digest.Digest, uploadID string) error { 659 - reqBody := map[string]any{ 660 - "did": p.did, 661 - "digest": dgst.String(), 662 - "upload_id": uploadID, 663 - } 664 - body, _ := json.Marshal(reqBody) 665 - 666 - url := fmt.Sprintf("%s/abort-multipart", p.storageEndpoint) 667 - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 668 - req.Header.Set("Content-Type", "application/json") 669 - 670 - resp, err := p.httpClient.Do(req) 671 - if err != nil { 672 - return err 673 - } 674 - defer resp.Body.Close() 675 - 676 - if resp.StatusCode != http.StatusOK { 677 - bodyBytes, _ := io.ReadAll(resp.Body) 678 - return fmt.Errorf("abort multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes)) 679 - } 680 - 681 718 return nil 682 719 } 683 720
pkg/storage/routing_repository.go pkg/appview/storage/routing_repository.go
-54
pkg/storage/s3_blob_store.go
··· 1 - package storage 2 - 3 - import ( 4 - "context" 5 - 6 - "github.com/distribution/distribution/v3" 7 - "github.com/distribution/distribution/v3/registry/storage" 8 - "github.com/distribution/distribution/v3/registry/storage/driver" 9 - "github.com/distribution/reference" 10 - ) 11 - 12 - // S3BlobStore wraps distribution's blob store with S3 backend 13 - type S3BlobStore struct { 14 - distribution.BlobStore 15 - } 16 - 17 - // NewS3BlobStore creates a new S3-backed blob store 18 - func NewS3BlobStore(ctx context.Context, storageDriver driver.StorageDriver, repoName string) (*S3BlobStore, error) { 19 - // Create a registry instance with the S3 driver 20 - reg, err := storage.NewRegistry(ctx, storageDriver) 21 - if err != nil { 22 - return nil, err 23 - } 24 - 25 - // Parse the repository name into a Named reference 26 - named, err := reference.ParseNamed(repoName) 27 - if err != nil { 28 - return nil, err 29 - } 30 - 31 - // Get the repository 32 - repo, err := reg.Repository(ctx, named) 33 - if err != nil { 34 - return nil, err 35 - } 36 - 37 - // Get the blob store 38 - blobStore := repo.Blobs(ctx) 39 - 40 - return &S3BlobStore{ 41 - BlobStore: blobStore, 42 - }, nil 43 - } 44 - 45 - // Note: S3BlobStore inherits all methods from distribution.BlobStore 46 - // including: 47 - // - Stat(ctx, dgst) - Check if blob exists 48 - // - Get(ctx, dgst) - Retrieve blob 49 - // - Open(ctx, dgst) - Open blob for reading 50 - // - Put(ctx, mediaType, payload) - Store blob 51 - // - Create(ctx, options...) - Create blob writer 52 - // - Resume(ctx, id) - Resume blob upload 53 - // - ServeBlob(ctx, w, r, dgst) - Serve blob over HTTP 54 - // - Delete(ctx, dgst) - Delete blob