v2.11.0 dca6537

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

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