v2.1.0 0fb867d

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	"fmt"
8	"io"
9	"net/http"
10	"strconv"
11	"strings"
12
13	"github.com/bnema/zerowrap"
14
15	"gordon/internal/boundaries/in"
16	"gordon/internal/boundaries/out"
17	"gordon/internal/domain"
18	"gordon/pkg/manifest"
19)
20
21// Handler implements the HTTP handler for Docker Registry API v2.
22type Handler struct {
23	registrySvc in.RegistryService
24	blobStorage out.BlobStorage
25	eventBus    out.EventPublisher
26	log         zerowrap.Logger
27}
28
29// NewHandler creates a new registry HTTP handler.
30func NewHandler(
31	registrySvc in.RegistryService,
32	blobStorage out.BlobStorage,
33	eventBus out.EventPublisher,
34	log zerowrap.Logger,
35) *Handler {
36	return &Handler{
37		registrySvc: registrySvc,
38		blobStorage: blobStorage,
39		eventBus:    eventBus,
40		log:         log,
41	}
42}
43
44// RegisterRoutes registers the registry routes on the given mux.
45func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
46	mux.HandleFunc("/v2/", h.handleRegistryRoutes)
47}
48
49// ServeHTTP implements http.Handler.
50func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
51	h.handleRegistryRoutes(w, r)
52}
53
54func (h *Handler) handleRegistryRoutes(w http.ResponseWriter, r *http.Request) {
55	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
56		zerowrap.FieldLayer:   "adapter",
57		zerowrap.FieldAdapter: "http",
58		zerowrap.FieldHandler: "registry",
59		zerowrap.FieldMethod:  r.Method,
60		zerowrap.FieldPath:    r.URL.Path,
61	})
62	r = r.WithContext(ctx)
63
64	path := r.URL.Path
65
66	// Route manifest operations: /v2/{name}/manifests/{reference}
67	if strings.Contains(path, "/manifests/") {
68		h.handleManifestRoutes(w, r)
69		return
70	}
71
72	// Route blob operations: /v2/{name}/blobs/{digest}
73	if strings.Contains(path, "/blobs/") && !strings.Contains(path, "/uploads/") {
74		h.handleBlobRoutes(w, r)
75		return
76	}
77
78	// Route blob upload operations: /v2/{name}/blobs/uploads/
79	if strings.Contains(path, "/blobs/uploads/") {
80		h.handleBlobUploadRoutes(w, r)
81		return
82	}
83
84	// Route tag list operations: /v2/{name}/tags/list
85	if strings.Contains(path, "/tags/list") {
86		h.handleTagListRoutes(w, r)
87		return
88	}
89
90	// Base endpoint: /v2/
91	if path == "/v2/" {
92		h.handleBase(w, r)
93		return
94	}
95
96	http.NotFound(w, r)
97}
98
99func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {
100	// Parse path: /v2/{name}/manifests/{reference}
101	parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
102	if len(parts) < 3 || parts[len(parts)-2] != "manifests" {
103		http.NotFound(w, r)
104		return
105	}
106
107	reference := parts[len(parts)-1]
108	name := strings.Join(parts[:len(parts)-2], "/")
109
110	r.SetPathValue("name", name)
111	r.SetPathValue("reference", reference)
112
113	switch r.Method {
114	case "HEAD", "GET":
115		h.handleGetManifest(w, r)
116	case "PUT":
117		h.handlePutManifest(w, r)
118	default:
119		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
120	}
121}
122
123func (h *Handler) handleBlobRoutes(w http.ResponseWriter, r *http.Request) {
124	// Parse path: /v2/{name}/blobs/{digest}
125	parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
126	if len(parts) < 3 || parts[len(parts)-2] != "blobs" {
127		http.NotFound(w, r)
128		return
129	}
130
131	digest := parts[len(parts)-1]
132	name := strings.Join(parts[:len(parts)-2], "/")
133
134	r.SetPathValue("name", name)
135	r.SetPathValue("digest", digest)
136
137	switch r.Method {
138	case "HEAD", "GET":
139		h.handleGetBlob(w, r)
140	default:
141		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
142	}
143}
144
145func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request) {
146	// Parse path: /v2/{name}/blobs/uploads/{uuid?}
147	path := strings.TrimPrefix(r.URL.Path, "/v2/")
148	uploadIndex := strings.Index(path, "/blobs/uploads/")
149	if uploadIndex == -1 {
150		http.NotFound(w, r)
151		return
152	}
153
154	name := path[:uploadIndex]
155	uploadPart := path[uploadIndex+15:] // len("/blobs/uploads/") = 15
156
157	r.SetPathValue("name", name)
158
159	if uploadPart == "" {
160		// POST /v2/{name}/blobs/uploads/
161		switch r.Method {
162		case "POST":
163			h.handleStartBlobUpload(w, r)
164		default:
165			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
166		}
167	} else {
168		// PATCH/PUT /v2/{name}/blobs/uploads/{uuid}
169		r.SetPathValue("uuid", uploadPart)
170		switch r.Method {
171		case "PATCH", "PUT":
172			h.handleBlobUpload(w, r)
173		default:
174			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
175		}
176	}
177}
178
179func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
180	// Parse path: /v2/{name}/tags/list
181	path := strings.TrimPrefix(r.URL.Path, "/v2/")
182	if !strings.HasSuffix(path, "/tags/list") {
183		http.NotFound(w, r)
184		return
185	}
186
187	name := strings.TrimSuffix(path, "/tags/list")
188	r.SetPathValue("name", name)
189
190	switch r.Method {
191	case "GET":
192		h.handleListTags(w, r)
193	default:
194		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
195	}
196}
197
198func (h *Handler) handleBase(w http.ResponseWriter, r *http.Request) {
199	w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
200	w.WriteHeader(http.StatusOK)
201}
202
203func (h *Handler) handleGetManifest(w http.ResponseWriter, r *http.Request) {
204	ctx := r.Context()
205	log := zerowrap.FromCtx(ctx)
206	name := r.PathValue("name")
207	reference := r.PathValue("reference")
208
209	log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
210
211	manifestData, err := h.registrySvc.GetManifest(ctx, name, reference)
212	if err != nil {
213		log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("manifest not found")
214		http.NotFound(w, r)
215		return
216	}
217
218	w.Header().Set("Content-Type", manifestData.ContentType)
219	w.Header().Set("Content-Length", strconv.Itoa(len(manifestData.Data)))
220	w.WriteHeader(http.StatusOK)
221
222	if r.Method == "GET" {
223		_, _ = w.Write(manifestData.Data)
224	}
225}
226
227func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
228	ctx := r.Context()
229	log := zerowrap.FromCtx(ctx)
230	name := r.PathValue("name")
231	reference := r.PathValue("reference")
232
233	contentType := r.Header.Get("Content-Type")
234	log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("PUT manifest")
235
236	if contentType == "" {
237		log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing")
238		http.Error(w, "Content-Type header required", http.StatusBadRequest)
239		return
240	}
241
242	// Read manifest data
243	data, err := io.ReadAll(r.Body)
244	if err != nil {
245		log.Error().Err(err).Msg("failed to read manifest data")
246		http.Error(w, "Bad Request", http.StatusBadRequest)
247		return
248	}
249
250	// Parse manifest annotations
251	annotations, err := manifest.ParseManifestAnnotations(data, contentType)
252	if err != nil {
253		log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("failed to parse manifest annotations")
254		annotations = map[string]string{}
255	}
256
257	// Store manifest via service
258	manifestObj := &domain.Manifest{
259		Name:        name,
260		Reference:   reference,
261		ContentType: contentType,
262		Data:        data,
263		Annotations: annotations,
264	}
265
266	if err := h.registrySvc.PutManifest(ctx, manifestObj); err != nil {
267		log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
268		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
269		return
270	}
271
272	// Calculate digest for Docker-Content-Digest header
273	digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
274	w.Header().Set("Docker-Content-Digest", digest)
275	w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
276	w.WriteHeader(http.StatusCreated)
277}
278
279func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
280	ctx := r.Context()
281	log := zerowrap.FromCtx(ctx)
282	name := r.PathValue("name")
283	digest := r.PathValue("digest")
284
285	log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
286
287	path, err := h.blobStorage.GetBlobPath(digest)
288	if err != nil {
289		log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
290		http.NotFound(w, r)
291		return
292	}
293
294	// Serve the file directly
295	http.ServeFile(w, r, path)
296}
297
298func (h *Handler) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {
299	ctx := r.Context()
300	log := zerowrap.FromCtx(ctx)
301	name := r.PathValue("name")
302
303	log.Debug().Str("name", name).Msg("starting blob upload")
304
305	uuid, err := h.registrySvc.StartUpload(ctx, name)
306	if err != nil {
307		log.Error().Err(err).Msg("failed to start blob upload")
308		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
309		return
310	}
311
312	w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
313	w.Header().Set("Range", "0-0")
314	w.WriteHeader(http.StatusAccepted)
315}
316
317func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
318	ctx := r.Context()
319	log := zerowrap.FromCtx(ctx)
320	name := r.PathValue("name")
321	uuid := r.PathValue("uuid")
322	digest := r.URL.Query().Get("digest")
323
324	log.Debug().
325		Str("name", name).
326		Str("uuid", uuid).
327		Str("digest", digest).
328		Str(zerowrap.FieldMethod, r.Method).
329		Msg("handling blob upload chunk")
330
331	// Read the chunk from the request body
332	chunk, err := io.ReadAll(r.Body)
333	if err != nil {
334		log.Error().Err(err).Msg("failed to read blob chunk")
335		http.Error(w, "Bad Request", http.StatusBadRequest)
336		return
337	}
338
339	// Append the chunk to the upload
340	length, err := h.blobStorage.AppendBlobChunk(name, uuid, chunk)
341	if err != nil {
342		log.Error().Err(err).Msg("failed to append blob chunk")
343		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
344		return
345	}
346
347	// If this is the final chunk (PUT request with digest), finalize the upload
348	if r.Method == "PUT" && digest != "" {
349		if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil {
350			log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload")
351			_ = h.registrySvc.CancelUpload(ctx, uuid)
352			http.Error(w, "Bad Request: digest mismatch", http.StatusBadRequest)
353			return
354		}
355
356		w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
357		w.Header().Set("Docker-Content-Digest", digest)
358		w.WriteHeader(http.StatusCreated)
359		return
360	}
361
362	// For PATCH requests, respond with 202 Accepted
363	w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
364	w.Header().Set("Range", fmt.Sprintf("0-%d", length-1))
365	w.WriteHeader(http.StatusAccepted)
366}
367
368func (h *Handler) handleListTags(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("listing tags")
374
375	tags, err := h.registrySvc.ListTags(ctx, name)
376	if err != nil {
377		log.Warn().Err(err).Str("name", name).Msg("tags not found")
378		w.WriteHeader(http.StatusNotFound)
379		return
380	}
381
382	tagsJSON, err := json.Marshal(tags)
383	if err != nil {
384		log.Error().Err(err).Str("name", name).Msg("failed to marshal tags")
385		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
386		return
387	}
388
389	response := fmt.Sprintf(`{"name":"%s","tags":%s}`, name, string(tagsJSON))
390
391	w.Header().Set("Content-Type", "application/json")
392	w.Header().Set("Content-Length", strconv.Itoa(len(response)))
393	w.WriteHeader(http.StatusOK)
394	w.Write([]byte(response))
395}

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