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