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