v2.31.0-dev23 58a92f3

handler.go RawHistoryFolder

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

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