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