A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
72
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