v2.5.0 b9b6391

handler.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close