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