images.go
⎇
Raw
1package web
2
3import (
4 "net/http"
5
6 "github.com/go-chi/chi/v5"
7
8 "hearthforge/internal/web/views"
9)
10
11// imageRoutes registers the Images tab. Reads follow REGISTRY_PULL like the
12// registry itself; deletes are admin-only.
13func (s *Server) imageRoutes(r chi.Router) {
14 r.Get("/{repo}/images", s.imageList)
15 r.Group(func(r chi.Router) {
16 r.Use(s.requireAdmin)
17 r.Post("/{repo}/images/delete", s.deleteImage)
18 r.Post("/{repo}/images/delete-all", s.deleteAllImages)
19 })
20}
21
22func (s *Server) imageList(w http.ResponseWriter, r *http.Request) {
23 repo, ok := s.visibleRepo(w, r)
24 if !ok {
25 return
26 }
27 u := User(r)
28 if !s.Cfg.CanPullImages(repo.IsPrivate, u != nil, u != nil && u.IsAdmin) {
29 http.Error(w, "Images are not visible to you", http.StatusForbidden)
30 return
31 }
32 tags, err := s.DB.ListImageTags(r.Context(), repo.ID)
33 if err != nil {
34 http.Error(w, "Database error", http.StatusInternalServerError)
35 return
36 }
37 sizes, err := s.DB.ImageSizes(r.Context(), repo.ID)
38 if err != nil {
39 http.Error(w, "Database error", http.StatusInternalServerError)
40 return
41 }
42 views.Render(w, http.StatusOK, views.ImageList(s.Cfg, u, repo, tags, sizes))
43}
44
45// deleteImage removes one tag, or the whole image path when no tag is given.
46// A manifest left without tags goes too. Layer blobs stay linked to the
47// image until the image itself is deleted.
48func (s *Server) deleteImage(w http.ResponseWriter, r *http.Request) {
49 repo, ok := s.adminRepo(w, r)
50 if !ok {
51 return
52 }
53 image := r.FormValue("image")
54 tag := r.FormValue("tag")
55 if tag == "" {
56 if err := s.purgeImages(r, repo.ID, &image); err != nil {
57 http.Error(w, "Database error", http.StatusInternalServerError)
58 return
59 }
60 redirectTo(w, r, "/"+repo.Name+"/images")
61 return
62 }
63 m, err := s.DB.ManifestByTag(r.Context(), repo.ID, image, tag)
64 if err != nil {
65 http.Error(w, "Database error", http.StatusInternalServerError)
66 return
67 }
68 if m != nil {
69 if _, err := s.DB.DeleteTag(r.Context(), repo.ID, image, tag); err != nil {
70 http.Error(w, "Database error", http.StatusInternalServerError)
71 return
72 }
73 s.registryMu.Lock()
74 if gone, err := s.DB.DeleteUntaggedManifest(r.Context(), repo.ID, image, m.Digest); err == nil && gone {
75 s.removeUnreferenced(r, m.Digest)
76 }
77 s.registryMu.Unlock()
78 }
79 redirectTo(w, r, "/"+repo.Name+"/images")
80}
81
82func (s *Server) deleteAllImages(w http.ResponseWriter, r *http.Request) {
83 repo, ok := s.adminRepo(w, r)
84 if !ok {
85 return
86 }
87 if err := s.purgeImages(r, repo.ID, nil); err != nil {
88 http.Error(w, "Database error", http.StatusInternalServerError)
89 return
90 }
91 redirectTo(w, r, "/"+repo.Name+"/images")
92}
93
94// purgeImages drops one image path, or every image when image is nil, and
95// removes the files nothing else uses.
96func (s *Server) purgeImages(r *http.Request, repoID int64, image *string) error {
97 s.registryMu.Lock()
98 defer s.registryMu.Unlock()
99 digests, err := s.DB.RepoDigests(r.Context(), repoID, image)
100 if err != nil {
101 return err
102 }
103 if err := s.DB.DeleteImages(r.Context(), repoID, image); err != nil {
104 return err
105 }
106 for _, d := range digests {
107 s.removeUnreferenced(r, d)
108 }
109 return nil
110}
111
112// deleteRepoRow removes a repository row and its image files. The cascade
113// drops the index rows; the files nothing else uses go afterwards. Every
114// place that deletes a repo goes through here.
115func (s *Server) deleteRepoRow(r *http.Request, repoID int64) error {
116 s.registryMu.Lock()
117 defer s.registryMu.Unlock()
118 digests, err := s.DB.RepoDigests(r.Context(), repoID, nil)
119 if err != nil {
120 return err
121 }
122 if err := s.DB.DeleteRepo(r.Context(), repoID); err != nil {
123 return err
124 }
125 for _, d := range digests {
126 s.removeUnreferenced(r, d)
127 }
128 return nil
129}
130