package web import ( "net/http" "github.com/go-chi/chi/v5" "hearthforge/internal/web/views" ) // imageRoutes registers the Images tab. Reads follow REGISTRY_PULL like the // registry itself; deletes are admin-only. func (s *Server) imageRoutes(r chi.Router) { r.Get("/{repo}/images", s.imageList) r.Group(func(r chi.Router) { r.Use(s.requireAdmin) r.Post("/{repo}/images/delete", s.deleteImage) r.Post("/{repo}/images/delete-all", s.deleteAllImages) }) } func (s *Server) imageList(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } u := User(r) if !s.Cfg.CanPullImages(repo.IsPrivate, u != nil, u != nil && u.IsAdmin) { http.Error(w, "Images are not visible to you", http.StatusForbidden) return } tags, err := s.DB.ListImageTags(r.Context(), repo.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } sizes, err := s.DB.ImageSizes(r.Context(), repo.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } views.Render(w, http.StatusOK, views.ImageList(s.Cfg, u, repo, tags, sizes)) } // deleteImage removes one tag, or the whole image path when no tag is given. // A manifest left without tags goes too. Layer blobs stay linked to the // image until the image itself is deleted. func (s *Server) deleteImage(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } image := r.FormValue("image") tag := r.FormValue("tag") if tag == "" { if err := s.purgeImages(r, repo.ID, &image); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } redirectTo(w, r, "/"+repo.Name+"/images") return } m, err := s.DB.ManifestByTag(r.Context(), repo.ID, image, tag) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if m != nil { if _, err := s.DB.DeleteTag(r.Context(), repo.ID, image, tag); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } s.registryMu.Lock() if gone, err := s.DB.DeleteUntaggedManifest(r.Context(), repo.ID, image, m.Digest); err == nil && gone { s.removeUnreferenced(r, m.Digest) } s.registryMu.Unlock() } redirectTo(w, r, "/"+repo.Name+"/images") } func (s *Server) deleteAllImages(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } if err := s.purgeImages(r, repo.ID, nil); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } redirectTo(w, r, "/"+repo.Name+"/images") } // purgeImages drops one image path, or every image when image is nil, and // removes the files nothing else uses. func (s *Server) purgeImages(r *http.Request, repoID int64, image *string) error { s.registryMu.Lock() defer s.registryMu.Unlock() digests, err := s.DB.RepoDigests(r.Context(), repoID, image) if err != nil { return err } if err := s.DB.DeleteImages(r.Context(), repoID, image); err != nil { return err } for _, d := range digests { s.removeUnreferenced(r, d) } return nil } // deleteRepoRow removes a repository row and its image files. The cascade // drops the index rows; the files nothing else uses go afterwards. Every // place that deletes a repo goes through here. func (s *Server) deleteRepoRow(r *http.Request, repoID int64) error { s.registryMu.Lock() defer s.registryMu.Unlock() digests, err := s.DB.RepoDigests(r.Context(), repoID, nil) if err != nil { return err } if err := s.DB.DeleteRepo(r.Context(), repoID); err != nil { return err } for _, d := range digests { s.removeUnreferenced(r, d) } return nil }