1// Package registry implements the HTTP adapter for the registry API.
2package registry
3
4import (
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "strconv"
12 "strings"
13 "sync/atomic"
14
15 "github.com/bnema/zerowrap"
16
17 "github.com/bnema/gordon/internal/adapters/dto"
18 "github.com/bnema/gordon/internal/boundaries/in"
19 "github.com/bnema/gordon/internal/domain"
20 "github.com/bnema/gordon/pkg/manifest"
21 "github.com/bnema/gordon/pkg/validation"
22)
23
24const (
25 // MaxManifestSize limits manifest uploads to 10MB.
26 MaxManifestSize = 10 * 1024 * 1024
27 // DefaultMaxBlobChunkSize is the default maximum size for a single blob upload chunk.
28 // Kept at 95MB to stay within Cloudflare's 100MB per-request limit.
29 // Users who need larger chunks can configure it explicitly via max_blob_chunk_size.
30 DefaultMaxBlobChunkSize = 95 * 1024 * 1024
31 // DefaultMaxBlobSize is the default maximum cumulative size for a blob upload.
32 DefaultMaxBlobSize = 1024 * 1024 * 1024
33)
34
35// blobLimits holds consistent blob size limits.
36type blobLimits struct {
37 MaxChunkSize int64
38 MaxBlobSize int64
39}
40
41// Handler implements the HTTP handler for Docker Registry API v2.
42type Handler struct {
43 registrySvc in.RegistryService
44 log zerowrap.Logger
45 blobLimits atomic.Value // stores *blobLimits
46}
47
48// NewHandler creates a new registry HTTP handler.
49func NewHandler(
50 registrySvc in.RegistryService,
51 log zerowrap.Logger,
52 maxBlobChunkSize int64,
53 maxBlobSize ...int64,
54) *Handler {
55 if maxBlobChunkSize <= 0 {
56 maxBlobChunkSize = DefaultMaxBlobChunkSize
57 }
58 blobSizeLimit := int64(DefaultMaxBlobSize)
59 if len(maxBlobSize) > 0 && maxBlobSize[0] > 0 {
60 blobSizeLimit = maxBlobSize[0]
61 }
62
63 h := &Handler{
64 registrySvc: registrySvc,
65 log: log,
66 }
67 h.blobLimits.Store(&blobLimits{
68 MaxChunkSize: maxBlobChunkSize,
69 MaxBlobSize: blobSizeLimit,
70 })
71 return h
72}
73
74// UpdateBlobLimits updates request-size limits used by live upload handlers.
75func (h *Handler) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) {
76 if maxBlobChunkSize <= 0 {
77 maxBlobChunkSize = DefaultMaxBlobChunkSize
78 }
79 if maxBlobSize <= 0 {
80 maxBlobSize = DefaultMaxBlobSize
81 }
82 h.blobLimits.Store(&blobLimits{
83 MaxChunkSize: maxBlobChunkSize,
84 MaxBlobSize: maxBlobSize,
85 })
86}
87
88// RegisterRoutes registers the registry routes on the given mux.
89func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
90 mux.HandleFunc("/v2/", h.handleRegistryRoutes)
91}
92
93// ServeHTTP implements http.Handler.
94func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
95 h.handleRegistryRoutes(w, r)
96}
97
98func (h *Handler) handleRegistryRoutes(w http.ResponseWriter, r *http.Request) {
99 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
100 zerowrap.FieldLayer: "adapter",
101 zerowrap.FieldAdapter: "http",
102 zerowrap.FieldHandler: "registry",
103 zerowrap.FieldMethod: r.Method,
104 zerowrap.FieldPath: r.URL.Path,
105 })
106 r = r.WithContext(ctx)
107
108 path := r.URL.Path
109
110 // Route manifest operations: /v2/{name}/manifests/{reference}
111 if strings.Contains(path, "/manifests/") {
112 h.handleManifestRoutes(w, r)
113 return
114 }
115
116 // Route blob operations: /v2/{name}/blobs/{digest}
117 if strings.Contains(path, "/blobs/") && !strings.Contains(path, "/uploads/") {
118 h.handleBlobRoutes(w, r)
119 return
120 }
121
122 // Route blob upload operations: /v2/{name}/blobs/uploads/
123 if strings.Contains(path, "/blobs/uploads/") {
124 h.handleBlobUploadRoutes(w, r)
125 return
126 }
127
128 // Route tag list operations: /v2/{name}/tags/list
129 if strings.Contains(path, "/tags/list") {
130 h.handleTagListRoutes(w, r)
131 return
132 }
133
134 // Base endpoint: /v2/
135 if path == "/v2/" {
136 h.handleBase(w, r)
137 return
138 }
139 h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
140
141}
142
143func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {
144 // Parse path: /v2/{name}/manifests/{reference}
145 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
146 if len(parts) < 3 || parts[len(parts)-2] != "manifests" {
147 h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
148 return
149 }
150
151 reference := parts[len(parts)-1]
152 name := strings.Join(parts[:len(parts)-2], "/")
153
154 // Validate inputs to prevent path traversal
155 if err := validation.ValidateRepositoryName(name); err != nil {
156 h.sendRegistryError(w, http.StatusBadRequest, "NAME_INVALID", err.Error())
157 return
158 }
159 if err := validation.ValidateReference(reference); err != nil {
160 h.sendRegistryError(w, http.StatusBadRequest, "TAG_INVALID", err.Error())
161 return
162 }
163
164 r.SetPathValue("name", name)
165 r.SetPathValue("reference", reference)
166
167 switch r.Method {
168 case "HEAD", "GET":
169 h.handleGetManifest(w, r)
170 case "PUT":
171 h.handlePutManifest(w, r)
172 default:
173 h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
174 }
175}
176
177func (h *Handler) handleBlobRoutes(w http.ResponseWriter, r *http.Request) {
178 // Parse path: /v2/{name}/blobs/{digest}
179 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
180 if len(parts) < 3 || parts[len(parts)-2] != "blobs" {
181 h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
182 return
183 }
184
185 digest := parts[len(parts)-1]
186 name := strings.Join(parts[:len(parts)-2], "/")
187
188 // Validate inputs to prevent path traversal
189 if err := validation.ValidateRepositoryName(name); err != nil {
190 h.sendRegistryError(w, http.StatusBadRequest, "NAME_INVALID", err.Error())
191 return
192 }
193 if err := validation.ValidateDigest(digest); err != nil {
194 h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", err.Error())
195 return
196 }
197
198 r.SetPathValue("name", name)
199 r.SetPathValue("digest", digest)
200
201 switch r.Method {
202 case "HEAD", "GET":
203 h.handleGetBlob(w, r)
204 default:
205 h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
206 }
207}
208
209func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request) {
210 // Parse path: /v2/{name}/blobs/uploads/{uuid?}
211 path := strings.TrimPrefix(r.URL.Path, "/v2/")
212 uploadIndex := strings.Index(path, "/blobs/uploads/")
213 if uploadIndex == -1 {
214 h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
215 return
216 }
217
218 name := path[:uploadIndex]
219 uploadPart := path[uploadIndex+15:] // len("/blobs/uploads/") = 15
220
221 // Validate repository name to prevent path traversal
222 if err := validation.ValidateRepositoryName(name); err != nil {
223 h.sendRegistryError(w, http.StatusBadRequest, "NAME_INVALID", err.Error())
224 return
225 }
226
227 r.SetPathValue("name", name)
228
229 if uploadPart == "" {
230 // POST /v2/{name}/blobs/uploads/
231 switch r.Method {
232 case "POST":
233 h.handleStartBlobUpload(w, r)
234 default:
235 h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
236 }
237 } else {
238 // Validate UUID to prevent path traversal
239 if err := validation.ValidateUUID(uploadPart); err != nil {
240 h.sendRegistryError(w, http.StatusBadRequest, "BLOB_UPLOAD_INVALID", err.Error())
241 return
242 }
243 // PATCH/PUT /v2/{name}/blobs/uploads/{uuid}
244 r.SetPathValue("uuid", uploadPart)
245 switch r.Method {
246 case "PATCH", "PUT":
247 h.handleBlobUpload(w, r)
248 default:
249 h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
250 }
251 }
252}
253
254func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
255 // Parse path: /v2/{name}/tags/list
256 path := strings.TrimPrefix(r.URL.Path, "/v2/")
257 if !strings.HasSuffix(path, "/tags/list") {
258 h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
259 return
260 }
261
262 name := strings.TrimSuffix(path, "/tags/list")
263
264 // Validate repository name to prevent path traversal
265 if err := validation.ValidateRepositoryName(name); err != nil {
266 h.sendRegistryError(w, http.StatusBadRequest, "NAME_INVALID", err.Error())
267 return
268 }
269
270 r.SetPathValue("name", name)
271
272 switch r.Method {
273 case "GET":
274 h.handleListTags(w, r)
275 default:
276 h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
277 }
278}
279
280func (h *Handler) handleBase(w http.ResponseWriter, _ *http.Request) {
281 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
282 w.WriteHeader(http.StatusOK)
283}
284
285// sendRegistryError sends a Docker Registry V2 formatted error response.
286func (h *Handler) sendRegistryError(w http.ResponseWriter, status int, code, message string) {
287 w.Header().Set("Content-Type", "application/json")
288 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
289 w.WriteHeader(status)
290 _ = json.NewEncoder(w).Encode(dto.RegistryErrorResponse{
291 Errors: []dto.RegistryErrorItem{{
292 Code: code,
293 Message: message,
294 }},
295 })
296}
297
298func (h *Handler) handleGetManifest(w http.ResponseWriter, r *http.Request) {
299 ctx := r.Context()
300 log := zerowrap.FromCtx(ctx)
301 name := r.PathValue("name")
302 reference := r.PathValue("reference")
303
304 log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
305
306 manifestData, err := h.registrySvc.GetManifest(ctx, name, reference)
307 if err != nil {
308 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("manifest not found")
309 h.sendRegistryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest not found")
310 return
311 }
312
313 w.Header().Set("Content-Type", manifestData.ContentType)
314 w.Header().Set("Content-Length", strconv.Itoa(len(manifestData.Data)))
315 w.WriteHeader(http.StatusOK)
316
317 if r.Method == "GET" {
318 _, _ = w.Write(manifestData.Data)
319 }
320}
321
322func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
323 ctx := r.Context()
324 log := zerowrap.FromCtx(ctx)
325 name := r.PathValue("name")
326 reference := r.PathValue("reference")
327
328 contentType := r.Header.Get("Content-Type")
329 log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("PUT manifest")
330
331 if contentType == "" {
332 log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing")
333 h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "Content-Type header required")
334 return
335 }
336
337 // Limit manifest size to prevent memory exhaustion
338 r.Body = http.MaxBytesReader(w, r.Body, MaxManifestSize)
339
340 // Read manifest data
341 data, err := io.ReadAll(r.Body)
342 if err != nil {
343 var maxBytesErr *http.MaxBytesError
344 if errors.As(err, &maxBytesErr) {
345 log.Warn().Int64("max_size", MaxManifestSize).Msg("manifest too large")
346 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "manifest exceeds maximum size")
347 return
348 }
349 log.Error().Err(err).Msg("failed to read manifest data")
350 h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "invalid manifest data")
351 return
352 }
353
354 // Parse manifest annotations
355 annotations, err := manifest.ParseManifestAnnotations(data, contentType)
356 if err != nil {
357 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("failed to parse manifest annotations")
358 annotations = map[string]string{}
359 }
360
361 // Store manifest via service
362 manifestObj := &domain.Manifest{
363 Name: name,
364 Reference: reference,
365 ContentType: contentType,
366 Data: data,
367 Annotations: annotations,
368 }
369
370 digest, err := h.registrySvc.PutManifest(ctx, manifestObj)
371 if err != nil {
372 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
373 h.sendRegistryError(w, http.StatusInternalServerError, "MANIFEST_INVALID", "failed to store manifest")
374 return
375 }
376
377 w.Header().Set("Docker-Content-Digest", digest)
378 w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
379 w.WriteHeader(http.StatusCreated)
380}
381
382func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
383 ctx := r.Context()
384 log := zerowrap.FromCtx(ctx)
385 name := r.PathValue("name")
386 digest := r.PathValue("digest")
387
388 log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
389
390 path, err := h.registrySvc.GetBlobPath(ctx, digest)
391 if err != nil {
392 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
393 h.sendRegistryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found")
394 return
395 }
396
397 // Serve the file directly
398 http.ServeFile(w, r, path)
399}
400
401func (h *Handler) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {
402 ctx := r.Context()
403 log := zerowrap.FromCtx(ctx)
404 name := r.PathValue("name")
405
406 log.Debug().Str("name", name).Msg("starting blob upload")
407
408 uuid, err := h.registrySvc.StartUpload(ctx, name)
409 if err != nil {
410 log.Error().Err(err).Msg("failed to start blob upload")
411 h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to start upload")
412 return
413 }
414
415 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
416 w.Header().Set("Range", "0-0")
417 w.Header().Set("Docker-Upload-UUID", uuid)
418 w.WriteHeader(http.StatusAccepted)
419}
420
421func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
422 ctx := r.Context()
423 log := zerowrap.FromCtx(ctx)
424 name := r.PathValue("name")
425 uuid := r.PathValue("uuid")
426 digest := r.URL.Query().Get("digest")
427
428 log.Debug().
429 Str("name", name).
430 Str("uuid", uuid).
431 Str("digest", digest).
432 Str(zerowrap.FieldMethod, r.Method).
433 Msg("handling blob upload chunk")
434
435 // Validate digest if provided (for PUT finalization)
436 if digest != "" {
437 if err := validation.ValidateDigest(digest); err != nil {
438 h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", err.Error())
439 return
440 }
441 }
442
443 // Load a consistent snapshot of both blob limits atomically
444 limits := h.blobLimits.Load().(*blobLimits)
445 maxBlobChunkSize := limits.MaxChunkSize
446 maxBlobSize := limits.MaxBlobSize
447
448 // Limit blob chunk size to prevent excessive uploads.
449 // MaxBytesReader wraps the body so the downstream io.Copy stops at the limit.
450 r.Body = http.MaxBytesReader(w, r.Body, maxBlobChunkSize)
451
452 // Stream the body directly to storage — memory usage is bounded to a small
453 // copy buffer (~32KB) regardless of chunk size.
454 length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body, r.ContentLength, maxBlobSize)
455 if err != nil {
456 var maxBytesErr *http.MaxBytesError
457 if errors.As(err, &maxBytesErr) {
458 log.Warn().Int64("max_size", maxBlobChunkSize).Msg("blob chunk too large")
459 if !h.cancelUploadAfterError(w, ctx, uuid, "failed to cancel oversized blob upload") {
460 return
461 }
462 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
463 return
464 }
465 if errors.Is(err, domain.ErrBlobSizeExceeded) {
466 log.Warn().Int64("max_size", maxBlobSize).Msg("blob upload too large")
467 if !h.cancelUploadAfterError(w, ctx, uuid, "failed to cancel oversized blob upload") {
468 return
469 }
470 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob exceeds maximum size")
471 return
472 }
473 log.Error().Err(err).Msg("failed to append blob chunk")
474 h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")
475 return
476 }
477
478 // If this is the final chunk (PUT request with digest), finalize the upload
479 if r.Method == "PUT" && digest != "" {
480 if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil {
481 log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload")
482 if !h.cancelUploadAfterError(w, ctx, uuid, "failed to cancel invalid blob upload") {
483 return
484 }
485 h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest mismatch")
486 return
487 }
488
489 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
490 w.Header().Set("Docker-Content-Digest", digest)
491 w.WriteHeader(http.StatusCreated)
492 return
493 }
494
495 // For PATCH requests, respond with 202 Accepted
496 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
497 if length > 0 {
498 w.Header().Set("Range", fmt.Sprintf("0-%d", length-1))
499 }
500 w.Header().Set("Docker-Upload-UUID", uuid)
501 w.WriteHeader(http.StatusAccepted)
502}
503
504func (h *Handler) cancelUploadAfterError(w http.ResponseWriter, ctx context.Context, uuid, message string) bool {
505 if err := h.registrySvc.CancelUpload(ctx, uuid); err != nil {
506 log := zerowrap.FromCtx(ctx)
507 log.Error().Err(err).Str("uuid", uuid).Msg(message)
508 h.sendRegistryError(w, http.StatusInternalServerError, "UNKNOWN", "failed to cancel upload")
509 return false
510 }
511 return true
512}
513
514func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) {
515 ctx := r.Context()
516 log := zerowrap.FromCtx(ctx)
517 name := r.PathValue("name")
518
519 log.Debug().Str("name", name).Msg("listing tags")
520
521 tags, err := h.registrySvc.ListTags(ctx, name)
522 if err != nil {
523 log.Warn().Err(err).Str("name", name).Msg("tags not found")
524 h.sendRegistryError(w, http.StatusNotFound, "NAME_UNKNOWN", "repository not found")
525 return
526 }
527
528 response := dto.TagListResponse{
529 Name: name,
530 Tags: tags,
531 }
532
533 w.Header().Set("Content-Type", "application/json")
534 w.WriteHeader(http.StatusOK)
535
536 if err := json.NewEncoder(w).Encode(response); err != nil {
537 log.Error().Err(err).Str("name", name).Msg("failed to encode tags")
538 }
539}