registry.go
⎇
Raw
1package web
2
3import (
4 "crypto/rand"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "io"
9 "net/http"
10 "os"
11 "path/filepath"
12 "regexp"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/go-chi/chi/v5"
18
19 "hearthforge/internal/db"
20 "hearthforge/internal/ratelimit"
21 "hearthforge/internal/util"
22)
23
24// The registry implements the OCI Distribution Spec under /v2/. An image
25// name is "<repo>" or "<repo>/<path>"; the first segment must be an existing
26// repository. Blob and manifest bodies are content-addressed files under
27// DATA_DIR/registry, the per-image index lives in SQLite.
28//
29// Admins push and delete. Pull access follows REGISTRY_PULL, except that
30// images of private repositories are always admin-only.
31
32const (
33 // maxManifestBytes bounds a manifest body. Real ones are a few KiB.
34 maxManifestBytes = 4 << 20
35 registryRealm = `Basic realm="Hearthforge registry"`
36)
37
38var (
39 digestRe = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
40 uploadIDRe = regexp.MustCompile(`^[a-f0-9]{32}$`)
41 tagRe = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$`)
42 imagePathRe = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`)
43)
44
45// registryAuthLimiter counts argon2 checks per IP. Clients send Basic auth
46// on every request, so verified credentials are cached and skip it.
47var registryAuthLimiter = ratelimit.New(10, time.Minute)
48
49func (s *Server) registryRoutes(r chi.Router) {
50 r.HandleFunc("/v2", s.registry)
51 r.HandleFunc("/v2/", s.registry)
52 r.HandleFunc("/v2/*", s.registry)
53}
54
55// registryError writes the spec's JSON error envelope.
56func registryError(w http.ResponseWriter, status int, code, msg string) {
57 w.Header().Set("Content-Type", "application/json")
58 w.WriteHeader(status)
59 _ = json.NewEncoder(w).Encode(map[string]any{
60 "errors": []map[string]string{{"code": code, "message": msg}},
61 })
62}
63
64// registryUser resolves Basic auth. ok is false when the header is missing
65// or wrong. Each argon2 check is reserved in the limiter before it runs, so
66// parallel guesses count too.
67func (s *Server) registryUser(r *http.Request) (username string, isAdmin, ok bool) {
68 username, password, found := r.BasicAuth()
69 if !found || len(password) > s.Cfg.MaxPasswordBytes {
70 return "", false, false
71 }
72 hash, exists, err := s.DB.ActivePasswordHash(r.Context(), username)
73 if err != nil {
74 return "", false, false
75 }
76 key := sha256.Sum256([]byte(username + "\x00" + password))
77 if cached, ok := registryAuth.Get(key); exists && ok && cached == hash {
78 return username, username == db.AdminUsername, true
79 }
80 if !s.allowed(r, registryAuthLimiter, true) {
81 return "", false, false
82 }
83 if !exists {
84 return "", false, false
85 }
86 if valid, err := db.VerifyPassword(hash, password); err != nil || !valid {
87 return "", false, false
88 }
89 registryAuth.Set(key, hash)
90 return username, username == db.AdminUsername, true
91}
92
93// registryAuth remembers verified credentials. The value is the hash it
94// matched, so a password change invalidates the entry.
95var registryAuth = util.NewCache[[32]byte, string](1000, 5*time.Minute)
96
97// challenge answers 401 with the Basic scheme so clients retry with
98// credentials.
99func challenge(w http.ResponseWriter) {
100 w.Header().Set("WWW-Authenticate", registryRealm)
101 registryError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required")
102}
103
104// registry dispatches one /v2/ request. Names may contain slashes, so the
105// path is split on the fixed keywords instead of chi params.
106func (s *Server) registry(w http.ResponseWriter, r *http.Request) {
107 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
108 _, isAdmin, authed := s.registryUser(r)
109 write := r.Method != http.MethodGet && r.Method != http.MethodHead
110
111 rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v2"), "/")
112 if rest == "" {
113 // The version check doubles as the auth probe for clients.
114 if write || !s.Cfg.CanPullImages(false, authed, isAdmin) {
115 challenge(w)
116 return
117 }
118 w.Header().Set("Content-Type", "application/json")
119 _, _ = w.Write([]byte("{}"))
120 return
121 }
122
123 var name, kind, ref string
124 switch {
125 case strings.Contains(rest, "/blobs/uploads/") || strings.HasSuffix(rest, "/blobs/uploads"):
126 i := strings.LastIndex(rest, "/blobs/uploads")
127 name, kind = rest[:i], "upload"
128 ref = strings.TrimPrefix(rest[i+len("/blobs/uploads"):], "/")
129 case strings.Contains(rest, "/blobs/"):
130 i := strings.LastIndex(rest, "/blobs/")
131 name, kind, ref = rest[:i], "blob", rest[i+len("/blobs/"):]
132 case strings.Contains(rest, "/manifests/"):
133 i := strings.LastIndex(rest, "/manifests/")
134 name, kind, ref = rest[:i], "manifest", rest[i+len("/manifests/"):]
135 case strings.HasSuffix(rest, "/tags/list"):
136 name, kind = strings.TrimSuffix(rest, "/tags/list"), "tags"
137 default:
138 registryError(w, http.StatusNotFound, "UNSUPPORTED", "unknown endpoint")
139 return
140 }
141
142 repo, image, ok := s.registryName(r, name)
143 // A private repo must look exactly like an unknown one to non-admins,
144 // or its name leaks through the status code.
145 if ok && repo.IsPrivate && !isAdmin {
146 ok = false
147 }
148 if !ok {
149 // Do not reveal whether the repo exists before auth.
150 if !authed && (write || !s.Cfg.CanPullImages(false, false, false)) {
151 challenge(w)
152 return
153 }
154 registryError(w, http.StatusNotFound, "NAME_UNKNOWN", "repository name not known to registry")
155 return
156 }
157 if write && !isAdmin {
158 if !authed {
159 challenge(w)
160 return
161 }
162 registryError(w, http.StatusForbidden, "DENIED", "only the admin may push")
163 return
164 }
165 if !write && !s.Cfg.CanPullImages(repo.IsPrivate, authed, isAdmin) {
166 if !authed {
167 challenge(w)
168 return
169 }
170 registryError(w, http.StatusForbidden, "DENIED", "pull access denied")
171 return
172 }
173
174 switch kind {
175 case "upload":
176 s.registryUpload(w, r, repo, image, ref)
177 case "blob":
178 s.registryBlob(w, r, repo, image, ref)
179 case "manifest":
180 s.registryManifest(w, r, repo, image, ref)
181 case "tags":
182 s.registryTags(w, r, repo, image)
183 }
184}
185
186// registryName maps "<repo>[/<path>]" to the repository row and image path.
187// It reports false for an unknown repo or a path the spec would reject.
188func (s *Server) registryName(r *http.Request, name string) (*db.Repo, string, bool) {
189 repoName, image, _ := strings.Cut(name, "/")
190 for _, seg := range strings.Split(image, "/") {
191 if seg != "" && !imagePathRe.MatchString(seg) {
192 return nil, "", false
193 }
194 // The path parser splits on these words, so they cannot be segments.
195 if seg == "blobs" || seg == "manifests" || seg == "tags" {
196 return nil, "", false
197 }
198 }
199 if image != "" && strings.Contains(image, "//") {
200 return nil, "", false
201 }
202 repo, err := s.DB.RepoByNameFold(r.Context(), repoName)
203 if err != nil || repo == nil {
204 return nil, "", false
205 }
206 return repo, image, true
207}
208
209// imageBase is the URL prefix of an image. Image names are lowercase on the
210// wire, so the repo name is lowered even when the repository is not.
211func imageBase(repo *db.Repo, image string) string {
212 return "/v2/" + strings.TrimSuffix(strings.ToLower(repo.Name)+"/"+image, "/")
213}
214
215// --- storage paths ---
216
217func (s *Server) blobPath(digest string) string {
218 return filepath.Join(s.Cfg.RegistryDir(), "blobs", strings.Replace(digest, ":", "/", 1))
219}
220
221func (s *Server) uploadPath(id string) string {
222 return filepath.Join(s.Cfg.RegistryDir(), "uploads", id)
223}
224
225// --- blob uploads ---
226
227func (s *Server) registryUpload(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, id string) {
228 base := imageBase(repo, image)
229 switch {
230 case r.Method == http.MethodPost && id == "":
231 // A cross-repo mount request falls through to a normal upload, which
232 // the spec allows. The client then uploads the blob.
233 if digest := r.URL.Query().Get("digest"); digest != "" && r.URL.Query().Get("mount") == "" {
234 // Monolithic upload in one request.
235 tmp, err := s.newUpload()
236 if err != nil {
237 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
238 return
239 }
240 if _, err := appendUpload(s.uploadPath(tmp), r.Body); err != nil {
241 _ = os.Remove(s.uploadPath(tmp))
242 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
243 return
244 }
245 s.finishUpload(w, r, repo, image, tmp, digest, base)
246 return
247 }
248 id, err := s.newUpload()
249 if err != nil {
250 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
251 return
252 }
253 w.Header().Set("Location", base+"/blobs/uploads/"+id)
254 w.Header().Set("Docker-Upload-UUID", id)
255 w.Header().Set("Range", "0-0")
256 w.WriteHeader(http.StatusAccepted)
257
258 case id == "" || !uploadIDRe.MatchString(id):
259 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
260
261 case r.Method == http.MethodGet:
262 st, err := os.Stat(s.uploadPath(id))
263 if err != nil {
264 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
265 return
266 }
267 w.Header().Set("Location", base+"/blobs/uploads/"+id)
268 w.Header().Set("Docker-Upload-UUID", id)
269 w.Header().Set("Range", rangeHeader(st.Size()))
270 w.WriteHeader(http.StatusNoContent)
271
272 case r.Method == http.MethodPatch:
273 path := s.uploadPath(id)
274 st, err := os.Stat(path)
275 if err != nil {
276 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
277 return
278 }
279 // Chunks must arrive in order. A stated start that is not the
280 // current end means the client and server disagree on the state.
281 if cr := r.Header.Get("Content-Range"); cr != "" {
282 start, _, _ := strings.Cut(cr, "-")
283 if n, err := strconv.ParseInt(start, 10, 64); err != nil || n != st.Size() {
284 w.Header().Set("Location", base+"/blobs/uploads/"+id)
285 w.Header().Set("Range", rangeHeader(st.Size()))
286 registryError(w, http.StatusRequestedRangeNotSatisfiable, "BLOB_UPLOAD_INVALID", "chunk out of order")
287 return
288 }
289 }
290 n, err := appendUpload(path, r.Body)
291 if err != nil {
292 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
293 return
294 }
295 w.Header().Set("Location", base+"/blobs/uploads/"+id)
296 w.Header().Set("Docker-Upload-UUID", id)
297 w.Header().Set("Range", rangeHeader(st.Size()+n))
298 w.WriteHeader(http.StatusAccepted)
299
300 case r.Method == http.MethodPut:
301 path := s.uploadPath(id)
302 if _, err := os.Stat(path); err != nil {
303 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
304 return
305 }
306 if _, err := appendUpload(path, r.Body); err != nil {
307 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
308 return
309 }
310 s.finishUpload(w, r, repo, image, id, r.URL.Query().Get("digest"), base)
311
312 case r.Method == http.MethodDelete:
313 if err := os.Remove(s.uploadPath(id)); err != nil {
314 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
315 return
316 }
317 w.WriteHeader(http.StatusNoContent)
318
319 default:
320 registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed")
321 }
322}
323
324func rangeHeader(size int64) string {
325 if size == 0 {
326 return "0-0"
327 }
328 return "0-" + strconv.FormatInt(size-1, 10)
329}
330
331// staleUploadAge is how long a partial upload may sit before it is removed.
332const staleUploadAge = 24 * time.Hour
333
334// sweepUploads removes upload files nobody finished. A client that
335// disconnects mid-push never sends the final request, so nothing else
336// would ever delete them.
337func (s *Server) sweepUploads() {
338 entries, err := os.ReadDir(filepath.Dir(s.uploadPath("x")))
339 if err != nil {
340 return
341 }
342 cutoff := time.Now().Add(-staleUploadAge)
343 for _, e := range entries {
344 if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
345 _ = os.Remove(s.uploadPath(e.Name()))
346 }
347 }
348}
349
350// newUpload creates an empty upload file and returns its id.
351func (s *Server) newUpload() (string, error) {
352 s.sweepUploads()
353 var b [16]byte
354 if _, err := rand.Read(b[:]); err != nil {
355 return "", err
356 }
357 id := hex.EncodeToString(b[:])
358 if err := os.MkdirAll(filepath.Dir(s.uploadPath(id)), 0o755); err != nil {
359 return "", err
360 }
361 f, err := os.OpenFile(s.uploadPath(id), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
362 if err != nil {
363 return "", err
364 }
365 return id, f.Close()
366}
367
368// appendUpload appends the body and returns how many bytes it added.
369func appendUpload(path string, body io.Reader) (int64, error) {
370 f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
371 if err != nil {
372 return 0, err
373 }
374 n, err := io.Copy(f, body)
375 if err != nil {
376 f.Close()
377 return n, err
378 }
379 return n, f.Close()
380}
381
382// finishUpload verifies the digest, moves the file into the blob store, and
383// links it to the image.
384func (s *Server) finishUpload(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, id, digest, base string) {
385 // Claim the file under a private name first. A late PATCH on the upload
386 // id then finds nothing, so the bytes hashed are the bytes stored.
387 path := s.uploadPath(id) + ".final"
388 if err := os.Rename(s.uploadPath(id), path); err != nil {
389 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
390 return
391 }
392 if !digestRe.MatchString(digest) {
393 _ = os.Remove(path)
394 registryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest must be sha256:<hex>")
395 return
396 }
397 size, actual, err := fileDigest(path)
398 if err != nil {
399 _ = os.Remove(path)
400 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
401 return
402 }
403 if actual != digest {
404 _ = os.Remove(path)
405 registryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest does not match uploaded content")
406 return
407 }
408 s.registryMu.Lock()
409 err = s.storeBlob(path, digest)
410 if err == nil {
411 err = s.DB.LinkBlob(r.Context(), repo.ID, image, digest, size)
412 }
413 s.registryMu.Unlock()
414 if err != nil {
415 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
416 return
417 }
418 w.Header().Set("Location", base+"/blobs/"+digest)
419 w.Header().Set("Docker-Content-Digest", digest)
420 w.WriteHeader(http.StatusCreated)
421}
422
423// storeBlob moves a verified file into place. An existing blob with the
424// same digest has identical content, so the upload is simply dropped.
425func (s *Server) storeBlob(src, digest string) error {
426 dst := s.blobPath(digest)
427 if _, err := os.Stat(dst); err == nil {
428 return os.Remove(src)
429 }
430 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
431 return err
432 }
433 return os.Rename(src, dst)
434}
435
436func fileDigest(path string) (int64, string, error) {
437 f, err := os.Open(path)
438 if err != nil {
439 return 0, "", err
440 }
441 defer f.Close()
442 h := sha256.New()
443 n, err := io.Copy(h, f)
444 if err != nil {
445 return 0, "", err
446 }
447 return n, "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
448}
449
450// --- blobs ---
451
452func (s *Server) registryBlob(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, digest string) {
453 if !digestRe.MatchString(digest) {
454 registryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest must be sha256:<hex>")
455 return
456 }
457 linked, err := s.DB.BlobLinked(r.Context(), repo.ID, image, digest)
458 if err != nil {
459 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
460 return
461 }
462 if !linked {
463 registryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob unknown to registry")
464 return
465 }
466 switch r.Method {
467 case http.MethodGet, http.MethodHead:
468 s.serveDigest(w, r, digest, "application/octet-stream")
469 case http.MethodDelete:
470 s.registryMu.Lock()
471 err = s.DB.UnlinkBlob(r.Context(), repo.ID, image, digest)
472 if err == nil {
473 s.removeUnreferenced(r, digest)
474 }
475 s.registryMu.Unlock()
476 if err != nil {
477 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
478 return
479 }
480 w.WriteHeader(http.StatusAccepted)
481 default:
482 registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed")
483 }
484}
485
486// removeUnreferenced deletes the file behind digest once no image uses it.
487// The caller holds registryMu. The lookup failing keeps the file; a stray
488// file is cheaper than a broken pull.
489func (s *Server) removeUnreferenced(r *http.Request, digest string) {
490 used, err := s.DB.DigestReferenced(r.Context(), digest)
491 if err == nil && !used {
492 _ = os.Remove(s.blobPath(digest))
493 }
494}
495
496// serveDigest streams a content-addressed file. ServeContent handles HEAD
497// and Range requests.
498func (s *Server) serveDigest(w http.ResponseWriter, r *http.Request, digest, contentType string) {
499 f, err := os.Open(s.blobPath(digest))
500 if err != nil {
501 registryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob file missing")
502 return
503 }
504 defer f.Close()
505 st, err := f.Stat()
506 if err != nil {
507 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
508 return
509 }
510 w.Header().Set("Content-Type", contentType)
511 w.Header().Set("Docker-Content-Digest", digest)
512 // Content-Disposition keeps a browser from rendering a layer or
513 // manifest inline. The raw endpoint's sandbox CSP does not apply here.
514 w.Header().Set("Content-Disposition", "attachment")
515 http.ServeContent(w, r, "", st.ModTime(), f)
516}
517
518// --- manifests ---
519
520// manifestRefs is the part of a manifest or index the registry checks.
521type manifestRefs struct {
522 MediaType string `json:"mediaType"`
523 Config *struct {
524 Digest string `json:"digest"`
525 } `json:"config"`
526 Layers []struct {
527 Digest string `json:"digest"`
528 } `json:"layers"`
529 Manifests []struct {
530 Digest string `json:"digest"`
531 } `json:"manifests"`
532}
533
534func (s *Server) registryManifest(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, ref string) {
535 isDigest := digestRe.MatchString(ref)
536 if !isDigest && !tagRe.MatchString(ref) {
537 registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "invalid reference")
538 return
539 }
540 switch r.Method {
541 case http.MethodPut:
542 s.putManifest(w, r, repo, image, ref, isDigest)
543 return
544 case http.MethodDelete:
545 if isDigest {
546 s.registryMu.Lock()
547 found, err := s.DB.DeleteManifest(r.Context(), repo.ID, image, ref)
548 if err == nil && found {
549 s.removeUnreferenced(r, ref)
550 }
551 s.registryMu.Unlock()
552 if err != nil {
553 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
554 return
555 }
556 if !found {
557 registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest unknown")
558 return
559 }
560 } else {
561 found, err := s.DB.DeleteTag(r.Context(), repo.ID, image, ref)
562 if err != nil {
563 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
564 return
565 }
566 if !found {
567 registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "tag unknown")
568 return
569 }
570 }
571 w.WriteHeader(http.StatusAccepted)
572 return
573 case http.MethodGet, http.MethodHead:
574 default:
575 registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed")
576 return
577 }
578
579 var m *db.Manifest
580 var err error
581 if isDigest {
582 m, err = s.DB.ManifestByDigest(r.Context(), repo.ID, image, ref)
583 } else {
584 m, err = s.DB.ManifestByTag(r.Context(), repo.ID, image, ref)
585 }
586 if err != nil {
587 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
588 return
589 }
590 if m == nil {
591 registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest unknown")
592 return
593 }
594 s.serveDigest(w, r, m.Digest, m.MediaType)
595}
596
597// putManifest stores a manifest after checking that everything it points
598// at is already in this image. That is what makes a pull reliable.
599func (s *Server) putManifest(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, ref string, isDigest bool) {
600 body, err := io.ReadAll(io.LimitReader(r.Body, maxManifestBytes+1))
601 if err != nil {
602 registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "could not read body")
603 return
604 }
605 if len(body) > maxManifestBytes {
606 registryError(w, http.StatusRequestEntityTooLarge, "MANIFEST_INVALID", "manifest too large")
607 return
608 }
609 sum := sha256.Sum256(body)
610 digest := "sha256:" + hex.EncodeToString(sum[:])
611 if isDigest && ref != digest {
612 registryError(w, http.StatusBadRequest, "DIGEST_INVALID", "reference digest does not match body")
613 return
614 }
615 var refs manifestRefs
616 if err := json.Unmarshal(body, &refs); err != nil {
617 registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "manifest is not valid JSON")
618 return
619 }
620 mediaType, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";")
621 mediaType = strings.TrimSpace(mediaType)
622 if mediaType == "" {
623 mediaType = refs.MediaType
624 }
625 if mediaType == "" {
626 registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "manifest has no media type")
627 return
628 }
629
630 // Every layer and config blob must be linked, every child manifest
631 // stored. Otherwise a client could pull a manifest whose parts 404.
632 var blobs []string
633 if refs.Config != nil {
634 blobs = append(blobs, refs.Config.Digest)
635 }
636 for _, l := range refs.Layers {
637 blobs = append(blobs, l.Digest)
638 }
639 for _, d := range blobs {
640 if !digestRe.MatchString(d) {
641 registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "unsupported digest "+d)
642 return
643 }
644 linked, err := s.DB.BlobLinked(r.Context(), repo.ID, image, d)
645 if err != nil {
646 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
647 return
648 }
649 if !linked {
650 registryError(w, http.StatusBadRequest, "MANIFEST_BLOB_UNKNOWN", "blob "+d+" not uploaded")
651 return
652 }
653 }
654 for _, c := range refs.Manifests {
655 if !digestRe.MatchString(c.Digest) {
656 registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "unsupported digest "+c.Digest)
657 return
658 }
659 child, err := s.DB.ManifestByDigest(r.Context(), repo.ID, image, c.Digest)
660 if err != nil {
661 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
662 return
663 }
664 if child == nil {
665 registryError(w, http.StatusBadRequest, "MANIFEST_BLOB_UNKNOWN", "manifest "+c.Digest+" not uploaded")
666 return
667 }
668 }
669
670 tag := ""
671 if !isDigest {
672 tag = ref
673 }
674 m := db.Manifest{Digest: digest, MediaType: mediaType}
675 s.registryMu.Lock()
676 err = s.writeBlob(digest, body)
677 if err == nil {
678 err = s.DB.PutManifest(r.Context(), repo.ID, image, m, tag, db.NowISO())
679 }
680 s.registryMu.Unlock()
681 if err != nil {
682 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
683 return
684 }
685 w.Header().Set("Location", imageBase(repo, image)+"/manifests/"+digest)
686 w.Header().Set("Docker-Content-Digest", digest)
687 w.WriteHeader(http.StatusCreated)
688}
689
690// writeBlob stores small content (a manifest) by digest.
691func (s *Server) writeBlob(digest string, body []byte) error {
692 dst := s.blobPath(digest)
693 if _, err := os.Stat(dst); err == nil {
694 return nil
695 }
696 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
697 return err
698 }
699 tmp, err := os.CreateTemp(filepath.Dir(dst), ".manifest-*")
700 if err != nil {
701 return err
702 }
703 if _, err := tmp.Write(body); err != nil {
704 tmp.Close()
705 _ = os.Remove(tmp.Name())
706 return err
707 }
708 if err := tmp.Close(); err != nil {
709 _ = os.Remove(tmp.Name())
710 return err
711 }
712 return os.Rename(tmp.Name(), dst)
713}
714
715// --- tags ---
716
717func (s *Server) registryTags(w http.ResponseWriter, r *http.Request, repo *db.Repo, image string) {
718 if r.Method != http.MethodGet && r.Method != http.MethodHead {
719 registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed")
720 return
721 }
722 tags, err := s.DB.ListTags(r.Context(), repo.ID, image)
723 if err != nil {
724 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
725 return
726 }
727 // Pagination: `last` is exclusive, `n` caps the page.
728 if last := r.URL.Query().Get("last"); last != "" {
729 for len(tags) > 0 && tags[0] <= last {
730 tags = tags[1:]
731 }
732 }
733 if n, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && n >= 0 && n < len(tags) {
734 tags = tags[:n]
735 }
736 if tags == nil {
737 tags = []string{}
738 }
739 w.Header().Set("Content-Type", "application/json")
740 _ = json.NewEncoder(w).Encode(map[string]any{
741 "name": strings.TrimPrefix(imageBase(repo, image), "/v2/"),
742 "tags": tags,
743 })
744}
745