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