v2.2.3 133ff88

handler.go RawHistoryFolder

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}

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