v2.30.0-pre 7341e0e

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

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