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 "sync/atomic"
13
14 "github.com/bnema/zerowrap"
15
16 "github.com/bnema/gordon/internal/adapters/dto"
17 "github.com/bnema/gordon/internal/adapters/in/http/registry/route"
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
98// handleRegistryRoutes parses the request path once and dispatches on the
99// resulting operation. Authorization middleware parses the same path with
100// the same parser, so the repository that is authorized is exactly the
101// repository the handler accesses.
102func (h *Handler) handleRegistryRoutes(w http.ResponseWriter, r *http.Request) {
103 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
104 zerowrap.FieldLayer: "adapter",
105 zerowrap.FieldAdapter: "http",
106 zerowrap.FieldHandler: "registry",
107 zerowrap.FieldMethod: r.Method,
108 zerowrap.FieldPath: r.URL.Path,
109 })
110 r = r.WithContext(ctx)
111
112 op, err := route.Parse(r.URL.Path)
113 if err != nil {
114 var parseErr *route.ParseError
115 if errors.As(err, &parseErr) {
116 h.sendRegistryError(w, http.StatusBadRequest, parseErr.Code, parseErr.Error())
117 return
118 }
119 h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
120 return
121 }
122
123 // Methods are validated per operation kind after the parsed values are
124 // installed, so handlers only read the path values the parser produced.
125 switch op.Kind {
126 case route.KindBase:
127 h.handleBase(w, r)
128 case route.KindManifest:
129 r.SetPathValue("name", op.Repository)
130 r.SetPathValue("reference", op.Reference)
131 h.handleManifestRoutes(w, r)
132 case route.KindBlob:
133 r.SetPathValue("name", op.Repository)
134 r.SetPathValue("digest", op.Digest)
135 h.handleBlobRoutes(w, r)
136 case route.KindUpload:
137 r.SetPathValue("name", op.Repository)
138 if op.UploadID != "" {
139 r.SetPathValue("uuid", op.UploadID)
140 }
141 h.handleBlobUploadRoutes(w, r)
142 case route.KindTagList:
143 r.SetPathValue("name", op.Repository)
144 h.handleTagListRoutes(w, r)
145 default:
146 h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
147 }
148}
149
150func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {
151 switch r.Method {
152 case "HEAD", "GET":
153 h.handleGetManifest(w, r)
154 case "PUT":
155 h.handlePutManifest(w, r)
156 default:
157 h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
158 }
159}
160
161func (h *Handler) handleBlobRoutes(w http.ResponseWriter, r *http.Request) {
162 switch r.Method {
163 case "HEAD", "GET":
164 h.handleGetBlob(w, r)
165 default:
166 h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
167 }
168}
169
170func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request) {
171 if r.PathValue("uuid") == "" {
172 // POST /v2/{name}/blobs/uploads/
173 switch r.Method {
174 case "POST":
175 h.handleStartBlobUpload(w, r)
176 default:
177 h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
178 }
179 return
180 }
181 // PATCH/PUT /v2/{name}/blobs/uploads/{uuid}
182 switch r.Method {
183 case "PATCH", "PUT":
184 h.handleBlobUpload(w, r)
185 default:
186 h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
187 }
188}
189
190func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
191 switch r.Method {
192 case "GET":
193 h.handleListTags(w, r)
194 default:
195 h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
196 }
197}
198
199func (h *Handler) handleBase(w http.ResponseWriter, _ *http.Request) {
200 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
201 w.WriteHeader(http.StatusOK)
202}
203
204// sendRegistryError sends a Docker Registry V2 formatted error response.
205func (h *Handler) sendRegistryError(w http.ResponseWriter, status int, code, message string) {
206 w.Header().Set("Content-Type", "application/json")
207 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
208 w.WriteHeader(status)
209 _ = json.NewEncoder(w).Encode(dto.RegistryErrorResponse{
210 Errors: []dto.RegistryErrorItem{{
211 Code: code,
212 Message: message,
213 }},
214 })
215}
216
217func (h *Handler) handleGetManifest(w http.ResponseWriter, r *http.Request) {
218 ctx := r.Context()
219 log := zerowrap.FromCtx(ctx)
220 name := r.PathValue("name")
221 reference := r.PathValue("reference")
222
223 log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
224
225 manifestData, err := h.registrySvc.GetManifest(ctx, name, reference)
226 if err != nil {
227 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("manifest not found")
228 h.sendRegistryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest not found")
229 return
230 }
231
232 w.Header().Set("Content-Type", manifestData.ContentType)
233 w.Header().Set("Content-Length", strconv.Itoa(len(manifestData.Data)))
234 w.WriteHeader(http.StatusOK)
235
236 if r.Method == "GET" {
237 _, _ = w.Write(manifestData.Data)
238 }
239}
240
241func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
242 ctx := r.Context()
243 log := zerowrap.FromCtx(ctx)
244 name := r.PathValue("name")
245 reference := r.PathValue("reference")
246
247 contentType := r.Header.Get("Content-Type")
248 log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("PUT manifest")
249
250 if contentType == "" {
251 log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing")
252 h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "Content-Type header required")
253 return
254 }
255
256 // Limit manifest size to prevent memory exhaustion
257 r.Body = http.MaxBytesReader(w, r.Body, MaxManifestSize)
258
259 // Read manifest data
260 data, err := io.ReadAll(r.Body)
261 if err != nil {
262 var maxBytesErr *http.MaxBytesError
263 if errors.As(err, &maxBytesErr) {
264 log.Warn().Int64("max_size", MaxManifestSize).Msg("manifest too large")
265 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "manifest exceeds maximum size")
266 return
267 }
268 log.Error().Err(err).Msg("failed to read manifest data")
269 h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "invalid manifest data")
270 return
271 }
272
273 // Parse manifest annotations
274 annotations, err := manifest.ParseManifestAnnotations(data, contentType)
275 if err != nil {
276 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("failed to parse manifest annotations")
277 annotations = map[string]string{}
278 }
279
280 // Store manifest via service
281 manifestObj := &domain.Manifest{
282 Name: name,
283 Reference: reference,
284 ContentType: contentType,
285 Data: data,
286 Annotations: annotations,
287 }
288
289 digest, err := h.registrySvc.PutManifest(ctx, manifestObj)
290 if err != nil {
291 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
292 if errors.Is(err, domain.ErrDigestMismatch) || errors.Is(err, domain.ErrInvalidDigest) {
293 h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "manifest digest does not match content")
294 return
295 }
296 if errors.Is(err, domain.ErrManifestBlobUnknown) {
297 h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_BLOB_UNKNOWN", "manifest references unknown content")
298 return
299 }
300 h.sendRegistryError(w, http.StatusInternalServerError, "MANIFEST_INVALID", "failed to store manifest")
301 return
302 }
303
304 w.Header().Set("Docker-Content-Digest", digest)
305 w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
306 w.WriteHeader(http.StatusCreated)
307}
308
309func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
310 ctx := r.Context()
311 log := zerowrap.FromCtx(ctx)
312 name := r.PathValue("name")
313 digest := r.PathValue("digest")
314
315 log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
316
317 path, err := h.registrySvc.GetBlobPath(ctx, name, digest)
318 if err != nil {
319 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
320 h.sendRegistryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found")
321 return
322 }
323
324 // Serve the file directly
325 http.ServeFile(w, r, path)
326}
327
328func (h *Handler) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {
329 ctx := r.Context()
330 log := zerowrap.FromCtx(ctx)
331 name := r.PathValue("name")
332
333 log.Debug().Str("name", name).Msg("starting blob upload")
334
335 uuid, err := h.registrySvc.StartUpload(ctx, name)
336 if err != nil {
337 log.Error().Err(err).Msg("failed to start blob upload")
338 h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to start upload")
339 return
340 }
341
342 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
343 w.Header().Set("Range", "0-0")
344 w.Header().Set("Docker-Upload-UUID", uuid)
345 w.WriteHeader(http.StatusAccepted)
346}
347
348func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
349 ctx := r.Context()
350 log := zerowrap.FromCtx(ctx)
351 name := r.PathValue("name")
352 uuid := r.PathValue("uuid")
353 digest := r.URL.Query().Get("digest")
354
355 log.Debug().
356 Str("name", name).
357 Str("uuid", uuid).
358 Str("digest", digest).
359 Str(zerowrap.FieldMethod, r.Method).
360 Msg("handling blob upload chunk")
361
362 // Validate digest if provided (for PUT finalization)
363 if digest != "" {
364 if err := validation.ValidateDigest(digest); err != nil {
365 h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", err.Error())
366 return
367 }
368 }
369
370 // Load a consistent snapshot of both blob limits atomically
371 limits := h.blobLimits.Load().(*blobLimits)
372 maxBlobChunkSize := limits.MaxChunkSize
373 maxBlobSize := limits.MaxBlobSize
374
375 // Limit blob chunk size to prevent excessive uploads.
376 // MaxBytesReader wraps the body so the downstream io.Copy stops at the limit.
377 r.Body = http.MaxBytesReader(w, r.Body, maxBlobChunkSize)
378
379 // Stream the body directly to storage — memory usage is bounded to a small
380 // copy buffer (~32KB) regardless of chunk size.
381 length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body, r.ContentLength, maxBlobSize)
382 if err != nil {
383 if errors.Is(err, domain.ErrUploadNotFound) {
384 log.Warn().Str("uuid", uuid).Msg("blob upload not found for repository")
385 h.sendRegistryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "blob upload unknown")
386 return
387 }
388 var maxBytesErr *http.MaxBytesError
389 if errors.As(err, &maxBytesErr) {
390 log.Warn().Int64("max_size", maxBlobChunkSize).Msg("blob chunk too large")
391 if !h.cancelUploadAfterError(w, ctx, name, uuid, "failed to cancel oversized blob upload") {
392 return
393 }
394 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
395 return
396 }
397 if errors.Is(err, domain.ErrBlobSizeExceeded) {
398 log.Warn().Int64("max_size", maxBlobSize).Msg("blob upload too large")
399 if !h.cancelUploadAfterError(w, ctx, name, uuid, "failed to cancel oversized blob upload") {
400 return
401 }
402 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob exceeds maximum size")
403 return
404 }
405 log.Error().Err(err).Msg("failed to append blob chunk")
406 h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")
407 return
408 }
409
410 // If this is the final chunk (PUT request with digest), finalize the upload
411 if r.Method == "PUT" && digest != "" {
412 if err := h.registrySvc.FinishUpload(ctx, name, uuid, digest); err != nil {
413 log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload")
414 if errors.Is(err, domain.ErrUploadNotFound) {
415 h.sendRegistryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "blob upload unknown")
416 return
417 }
418 if !h.cancelUploadAfterError(w, ctx, name, uuid, "failed to cancel invalid blob upload") {
419 return
420 }
421 h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest mismatch")
422 return
423 }
424
425 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
426 w.Header().Set("Docker-Content-Digest", digest)
427 w.WriteHeader(http.StatusCreated)
428 return
429 }
430
431 // For PATCH requests, respond with 202 Accepted
432 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
433 if length > 0 {
434 w.Header().Set("Range", fmt.Sprintf("0-%d", length-1))
435 }
436 w.Header().Set("Docker-Upload-UUID", uuid)
437 w.WriteHeader(http.StatusAccepted)
438}
439
440func (h *Handler) cancelUploadAfterError(w http.ResponseWriter, ctx context.Context, name, uuid, message string) bool {
441 if err := h.registrySvc.CancelUpload(ctx, name, uuid); err != nil {
442 log := zerowrap.FromCtx(ctx)
443 log.Error().Err(err).Str("uuid", uuid).Msg(message)
444 h.sendRegistryError(w, http.StatusInternalServerError, "UNKNOWN", "failed to cancel upload")
445 return false
446 }
447 return true
448}
449
450func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) {
451 ctx := r.Context()
452 log := zerowrap.FromCtx(ctx)
453 name := r.PathValue("name")
454
455 log.Debug().Str("name", name).Msg("listing tags")
456
457 tags, err := h.registrySvc.ListTags(ctx, name)
458 if err != nil {
459 log.Warn().Err(err).Str("name", name).Msg("tags not found")
460 h.sendRegistryError(w, http.StatusNotFound, "NAME_UNKNOWN", "repository not found")
461 return
462 }
463
464 response := dto.TagListResponse{
465 Name: name,
466 Tags: tags,
467 }
468
469 w.Header().Set("Content-Type", "application/json")
470 w.WriteHeader(http.StatusOK)
471
472 if err := json.NewEncoder(w).Encode(response); err != nil {
473 log.Error().Err(err).Str("name", name).Msg("failed to encode tags")
474 }
475}