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