v3.3.2 9697f9d

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	"sync/atomic"
13
14	"github.com/bnema/zerowrap"
15
16	"github.com/bnema/gordon/internal/adapters/dto"
17	"github.com/bnema/gordon/internal/adapters/in/http/registry/route"
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
98// handleRegistryRoutes parses the request path once and dispatches on the
99// resulting operation. Authorization middleware parses the same path with
100// the same parser, so the repository that is authorized is exactly the
101// repository the handler accesses.
102func (h *Handler) handleRegistryRoutes(w http.ResponseWriter, r *http.Request) {
103	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
104		zerowrap.FieldLayer:   "adapter",
105		zerowrap.FieldAdapter: "http",
106		zerowrap.FieldHandler: "registry",
107		zerowrap.FieldMethod:  r.Method,
108		zerowrap.FieldPath:    r.URL.Path,
109	})
110	r = r.WithContext(ctx)
111
112	op, err := route.Parse(r.URL.Path)
113	if err != nil {
114		var parseErr *route.ParseError
115		if errors.As(err, &parseErr) {
116			h.sendRegistryError(w, http.StatusBadRequest, parseErr.Code, parseErr.Error())
117			return
118		}
119		h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
120		return
121	}
122
123	// Methods are validated per operation kind after the parsed values are
124	// installed, so handlers only read the path values the parser produced.
125	switch op.Kind {
126	case route.KindBase:
127		h.handleBase(w, r)
128	case route.KindManifest:
129		r.SetPathValue("name", op.Repository)
130		r.SetPathValue("reference", op.Reference)
131		h.handleManifestRoutes(w, r)
132	case route.KindBlob:
133		r.SetPathValue("name", op.Repository)
134		r.SetPathValue("digest", op.Digest)
135		h.handleBlobRoutes(w, r)
136	case route.KindUpload:
137		r.SetPathValue("name", op.Repository)
138		if op.UploadID != "" {
139			r.SetPathValue("uuid", op.UploadID)
140		}
141		h.handleBlobUploadRoutes(w, r)
142	case route.KindTagList:
143		r.SetPathValue("name", op.Repository)
144		h.handleTagListRoutes(w, r)
145	default:
146		h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
147	}
148}
149
150func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {
151	switch r.Method {
152	case "HEAD", "GET":
153		h.handleGetManifest(w, r)
154	case "PUT":
155		h.handlePutManifest(w, r)
156	default:
157		h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
158	}
159}
160
161func (h *Handler) handleBlobRoutes(w http.ResponseWriter, r *http.Request) {
162	switch r.Method {
163	case "HEAD", "GET":
164		h.handleGetBlob(w, r)
165	default:
166		h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
167	}
168}
169
170func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request) {
171	if r.PathValue("uuid") == "" {
172		// POST /v2/{name}/blobs/uploads/
173		switch r.Method {
174		case "POST":
175			h.handleStartBlobUpload(w, r)
176		default:
177			h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
178		}
179		return
180	}
181	// PATCH/PUT /v2/{name}/blobs/uploads/{uuid}
182	switch r.Method {
183	case "PATCH", "PUT":
184		h.handleBlobUpload(w, r)
185	default:
186		h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
187	}
188}
189
190func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
191	switch r.Method {
192	case "GET":
193		h.handleListTags(w, r)
194	default:
195		h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
196	}
197}
198
199func (h *Handler) handleBase(w http.ResponseWriter, _ *http.Request) {
200	w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
201	w.WriteHeader(http.StatusOK)
202}
203
204// sendRegistryError sends a Docker Registry V2 formatted error response.
205func (h *Handler) sendRegistryError(w http.ResponseWriter, status int, code, message string) {
206	w.Header().Set("Content-Type", "application/json")
207	w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
208	w.WriteHeader(status)
209	_ = json.NewEncoder(w).Encode(dto.RegistryErrorResponse{
210		Errors: []dto.RegistryErrorItem{{
211			Code:    code,
212			Message: message,
213		}},
214	})
215}
216
217func (h *Handler) handleGetManifest(w http.ResponseWriter, r *http.Request) {
218	ctx := r.Context()
219	log := zerowrap.FromCtx(ctx)
220	name := r.PathValue("name")
221	reference := r.PathValue("reference")
222
223	log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
224
225	manifestData, err := h.registrySvc.GetManifest(ctx, name, reference)
226	if err != nil {
227		log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("manifest not found")
228		h.sendRegistryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest not found")
229		return
230	}
231
232	w.Header().Set("Content-Type", manifestData.ContentType)
233	w.Header().Set("Content-Length", strconv.Itoa(len(manifestData.Data)))
234	w.WriteHeader(http.StatusOK)
235
236	if r.Method == "GET" {
237		_, _ = w.Write(manifestData.Data)
238	}
239}
240
241func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
242	ctx := r.Context()
243	log := zerowrap.FromCtx(ctx)
244	name := r.PathValue("name")
245	reference := r.PathValue("reference")
246
247	contentType := r.Header.Get("Content-Type")
248	log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("PUT manifest")
249
250	if contentType == "" {
251		log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing")
252		h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "Content-Type header required")
253		return
254	}
255
256	// Limit manifest size to prevent memory exhaustion
257	r.Body = http.MaxBytesReader(w, r.Body, MaxManifestSize)
258
259	// Read manifest data
260	data, err := io.ReadAll(r.Body)
261	if err != nil {
262		var maxBytesErr *http.MaxBytesError
263		if errors.As(err, &maxBytesErr) {
264			log.Warn().Int64("max_size", MaxManifestSize).Msg("manifest too large")
265			h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "manifest exceeds maximum size")
266			return
267		}
268		log.Error().Err(err).Msg("failed to read manifest data")
269		h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "invalid manifest data")
270		return
271	}
272
273	// Parse manifest annotations
274	annotations, err := manifest.ParseManifestAnnotations(data, contentType)
275	if err != nil {
276		log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("failed to parse manifest annotations")
277		annotations = map[string]string{}
278	}
279
280	// Store manifest via service
281	manifestObj := &domain.Manifest{
282		Name:        name,
283		Reference:   reference,
284		ContentType: contentType,
285		Data:        data,
286		Annotations: annotations,
287	}
288
289	digest, err := h.registrySvc.PutManifest(ctx, manifestObj)
290	if err != nil {
291		log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
292		if errors.Is(err, domain.ErrDigestMismatch) || errors.Is(err, domain.ErrInvalidDigest) {
293			h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "manifest digest does not match content")
294			return
295		}
296		if errors.Is(err, domain.ErrManifestBlobUnknown) {
297			h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_BLOB_UNKNOWN", "manifest references unknown content")
298			return
299		}
300		h.sendRegistryError(w, http.StatusInternalServerError, "MANIFEST_INVALID", "failed to store manifest")
301		return
302	}
303
304	w.Header().Set("Docker-Content-Digest", digest)
305	w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
306	w.WriteHeader(http.StatusCreated)
307}
308
309func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
310	ctx := r.Context()
311	log := zerowrap.FromCtx(ctx)
312	name := r.PathValue("name")
313	digest := r.PathValue("digest")
314
315	log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
316
317	path, err := h.registrySvc.GetBlobPath(ctx, name, digest)
318	if err != nil {
319		log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
320		h.sendRegistryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found")
321		return
322	}
323
324	// Serve the file directly
325	http.ServeFile(w, r, path)
326}
327
328func (h *Handler) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {
329	ctx := r.Context()
330	log := zerowrap.FromCtx(ctx)
331	name := r.PathValue("name")
332
333	log.Debug().Str("name", name).Msg("starting blob upload")
334
335	uuid, err := h.registrySvc.StartUpload(ctx, name)
336	if err != nil {
337		log.Error().Err(err).Msg("failed to start blob upload")
338		h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to start upload")
339		return
340	}
341
342	w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
343	w.Header().Set("Range", "0-0")
344	w.Header().Set("Docker-Upload-UUID", uuid)
345	w.WriteHeader(http.StatusAccepted)
346}
347
348func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
349	ctx := r.Context()
350	log := zerowrap.FromCtx(ctx)
351	name := r.PathValue("name")
352	uuid := r.PathValue("uuid")
353	digest := r.URL.Query().Get("digest")
354
355	log.Debug().
356		Str("name", name).
357		Str("uuid", uuid).
358		Str("digest", digest).
359		Str(zerowrap.FieldMethod, r.Method).
360		Msg("handling blob upload chunk")
361
362	// Validate digest if provided (for PUT finalization)
363	if digest != "" {
364		if err := validation.ValidateDigest(digest); err != nil {
365			h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", err.Error())
366			return
367		}
368	}
369
370	// Load a consistent snapshot of both blob limits atomically
371	limits := h.blobLimits.Load().(*blobLimits)
372	maxBlobChunkSize := limits.MaxChunkSize
373	maxBlobSize := limits.MaxBlobSize
374
375	// Limit blob chunk size to prevent excessive uploads.
376	// MaxBytesReader wraps the body so the downstream io.Copy stops at the limit.
377	r.Body = http.MaxBytesReader(w, r.Body, maxBlobChunkSize)
378
379	// Stream the body directly to storage — memory usage is bounded to a small
380	// copy buffer (~32KB) regardless of chunk size.
381	length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body, r.ContentLength, maxBlobSize)
382	if err != nil {
383		if errors.Is(err, domain.ErrUploadNotFound) {
384			log.Warn().Str("uuid", uuid).Msg("blob upload not found for repository")
385			h.sendRegistryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "blob upload unknown")
386			return
387		}
388		var maxBytesErr *http.MaxBytesError
389		if errors.As(err, &maxBytesErr) {
390			log.Warn().Int64("max_size", maxBlobChunkSize).Msg("blob chunk too large")
391			if !h.cancelUploadAfterError(w, ctx, name, uuid, "failed to cancel oversized blob upload") {
392				return
393			}
394			h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
395			return
396		}
397		if errors.Is(err, domain.ErrBlobSizeExceeded) {
398			log.Warn().Int64("max_size", maxBlobSize).Msg("blob upload too large")
399			if !h.cancelUploadAfterError(w, ctx, name, uuid, "failed to cancel oversized blob upload") {
400				return
401			}
402			h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob exceeds maximum size")
403			return
404		}
405		log.Error().Err(err).Msg("failed to append blob chunk")
406		h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")
407		return
408	}
409
410	// If this is the final chunk (PUT request with digest), finalize the upload
411	if r.Method == "PUT" && digest != "" {
412		if err := h.registrySvc.FinishUpload(ctx, name, uuid, digest); err != nil {
413			log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload")
414			if errors.Is(err, domain.ErrUploadNotFound) {
415				h.sendRegistryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "blob upload unknown")
416				return
417			}
418			if !h.cancelUploadAfterError(w, ctx, name, uuid, "failed to cancel invalid blob upload") {
419				return
420			}
421			h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest mismatch")
422			return
423		}
424
425		w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
426		w.Header().Set("Docker-Content-Digest", digest)
427		w.WriteHeader(http.StatusCreated)
428		return
429	}
430
431	// For PATCH requests, respond with 202 Accepted
432	w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
433	if length > 0 {
434		w.Header().Set("Range", fmt.Sprintf("0-%d", length-1))
435	}
436	w.Header().Set("Docker-Upload-UUID", uuid)
437	w.WriteHeader(http.StatusAccepted)
438}
439
440func (h *Handler) cancelUploadAfterError(w http.ResponseWriter, ctx context.Context, name, uuid, message string) bool {
441	if err := h.registrySvc.CancelUpload(ctx, name, uuid); err != nil {
442		log := zerowrap.FromCtx(ctx)
443		log.Error().Err(err).Str("uuid", uuid).Msg(message)
444		h.sendRegistryError(w, http.StatusInternalServerError, "UNKNOWN", "failed to cancel upload")
445		return false
446	}
447	return true
448}
449
450func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) {
451	ctx := r.Context()
452	log := zerowrap.FromCtx(ctx)
453	name := r.PathValue("name")
454
455	log.Debug().Str("name", name).Msg("listing tags")
456
457	tags, err := h.registrySvc.ListTags(ctx, name)
458	if err != nil {
459		log.Warn().Err(err).Str("name", name).Msg("tags not found")
460		h.sendRegistryError(w, http.StatusNotFound, "NAME_UNKNOWN", "repository not found")
461		return
462	}
463
464	response := dto.TagListResponse{
465		Name: name,
466		Tags: tags,
467	}
468
469	w.Header().Set("Content-Type", "application/json")
470	w.WriteHeader(http.StatusOK)
471
472	if err := json.NewEncoder(w).Encode(response); err != nil {
473		log.Error().Err(err).Str("name", name).Msg("failed to encode tags")
474	}
475}

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