v2.4.1 7c3b292

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/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}

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