package api import ( "errors" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/crypto" "github.com/alexei/tinyforge/internal/registry" "github.com/alexei/tinyforge/internal/store" ) // registryRequest is the expected JSON body for creating/updating a registry. type registryRequest struct { Name string `json:"name"` URL string `json:"url"` Type string `json:"type"` Token string `json:"token"` Owner string `json:"owner"` } // listRegistries handles GET /api/registries. func (s *Server) listRegistries(w http.ResponseWriter, r *http.Request) { registries, err := s.store.GetAllRegistries() if err != nil { slog.Error("failed to list registries", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } // Strip tokens from response for security. type safeRegistry struct { ID string `json:"id"` Name string `json:"name"` URL string `json:"url"` Type string `json:"type"` HasToken bool `json:"has_token"` Owner string `json:"owner"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } safe := make([]safeRegistry, len(registries)) for i, reg := range registries { safe[i] = safeRegistry{ ID: reg.ID, Name: reg.Name, URL: reg.URL, Type: reg.Type, HasToken: reg.Token != "", Owner: reg.Owner, CreatedAt: reg.CreatedAt, UpdatedAt: reg.UpdatedAt, } } respondJSON(w, http.StatusOK, safe) } // createRegistry handles POST /api/registries. func (s *Server) createRegistry(w http.ResponseWriter, r *http.Request) { var req registryRequest if !decodeJSON(w, r, &req) { return } if req.Name == "" { respondError(w, http.StatusBadRequest, "name is required") return } if req.URL == "" { respondError(w, http.StatusBadRequest, "url is required") return } if req.Type == "" { req.Type = "generic" } // Encrypt the token if provided. encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token) if err != nil { slog.Error("failed to encrypt token", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } reg, err := s.store.CreateRegistry(store.Registry{ Name: req.Name, URL: req.URL, Type: req.Type, Token: encToken, Owner: req.Owner, }) if err != nil { slog.Error("failed to create registry", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } respondJSON(w, http.StatusCreated, map[string]string{ "id": reg.ID, "name": reg.Name, }) } // updateRegistry handles PUT /api/registries/{id}. func (s *Server) updateRegistry(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") existing, err := s.store.GetRegistryByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "registry") return } slog.Error("failed to get registry", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } var req registryRequest if !decodeJSON(w, r, &req) { return } updated := existing if req.Name != "" { updated.Name = req.Name } if req.URL != "" { updated.URL = req.URL } if req.Type != "" { updated.Type = req.Type } // Owner can be set to empty string intentionally, so always update it. updated.Owner = req.Owner // Only re-encrypt if a new token is provided. if req.Token != "" { encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token) if err != nil { slog.Error("failed to encrypt token", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } updated.Token = encToken } if err := s.store.UpdateRegistry(updated); err != nil { slog.Error("failed to update registry", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } respondJSON(w, http.StatusOK, map[string]string{ "id": updated.ID, "name": updated.Name, }) } // deleteRegistry handles DELETE /api/registries/{id}. func (s *Server) deleteRegistry(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if err := s.store.DeleteRegistry(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "registry") return } slog.Error("failed to delete registry", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) } // testRegistryRequest is the expected JSON body for POST /api/registries/{id}/test. type testRegistryRequest struct { Image string `json:"image"` } // testRegistry handles POST /api/registries/{id}/test. // Creates a temp registry client and attempts to list tags. func (s *Server) testRegistry(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") reg, err := s.store.GetRegistryByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "registry") return } slog.Error("failed to get registry", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } // Body is optional — if no image provided, just test connectivity. var req testRegistryRequest if r.Body != nil && r.ContentLength > 0 { if !decodeJSON(w, r, &req) { return } } // Decrypt the token. token := reg.Token if token != "" { decrypted, err := crypto.Decrypt(s.encKey, token) if err != nil { respondError(w, http.StatusInternalServerError, "failed to decrypt registry token") return } token = decrypted } client, err := registry.NewClient(reg.Type, reg.URL, token) if err != nil { respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type) return } // If no image provided, just verify we can create a client (basic connectivity test). if req.Image == "" { respondJSON(w, http.StatusOK, map[string]any{ "message": "registry client created successfully", }) return } tags, err := client.ListTags(r.Context(), req.Image) if err != nil { respondError(w, http.StatusBadGateway, "registry test failed: "+err.Error()) return } respondJSON(w, http.StatusOK, map[string]any{ "success": true, "tags": len(tags), }) } // listRegistryTags handles GET /api/registries/{id}/tags/{image}. func (s *Server) listRegistryTags(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") image := chi.URLParam(r, "*") reg, err := s.store.GetRegistryByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "registry") return } slog.Error("failed to get registry", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } // Decrypt the token. token := reg.Token if token != "" { decrypted, err := crypto.Decrypt(s.encKey, token) if err != nil { respondError(w, http.StatusInternalServerError, "failed to decrypt registry token") return } token = decrypted } client, err := registry.NewClient(reg.Type, reg.URL, token) if err != nil { respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type) return } tags, err := client.ListTags(r.Context(), image) if err != nil { respondError(w, http.StatusBadGateway, "failed to list tags: "+err.Error()) return } respondJSON(w, http.StatusOK, tags) } // listRegistryImages handles GET /api/registries/{id}/images. // Returns all container images available in the registry for the configured owner. func (s *Server) listRegistryImages(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") reg, err := s.store.GetRegistryByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "registry") return } slog.Error("failed to get registry", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } if reg.Owner == "" { respondError(w, http.StatusBadRequest, "registry has no owner configured; set the owner in registry settings") return } // Decrypt the token. token := reg.Token if token != "" { decrypted, err := crypto.Decrypt(s.encKey, token) if err != nil { respondError(w, http.StatusInternalServerError, "failed to decrypt registry token") return } token = decrypted } client, err := registry.NewClient(reg.Type, reg.URL, token) if err != nil { respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type) return } // Support comma-separated owners (e.g., "alexei,team-org,other-user"). owners := strings.Split(reg.Owner, ",") var allImages []registry.RegistryImage for _, owner := range owners { owner = strings.TrimSpace(owner) if owner == "" { continue } images, err := client.ListImages(r.Context(), owner) if err != nil { slog.Warn("failed to list images for owner", "owner", owner, "error", err) continue } allImages = append(allImages, images...) } if allImages == nil { allImages = []registry.RegistryImage{} } respondJSON(w, http.StatusOK, allImages) }