Files
tiny-forge/internal/api/registries.go
T
alexei.dolgolyov 97d4243cfe feat(docker-watcher): phase 8 - REST API layer
All REST endpoints wired with chi router: projects, stages, instances,
deploys, registries, settings, quick deploy, webhook. Full main.go
wiring with graceful shutdown. Consistent JSON envelope responses.
Sensitive fields stripped from API responses.
2026-03-27 22:06:57 +03:00

262 lines
6.4 KiB
Go

package api
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/registry"
"github.com/alexei/docker-watcher/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"`
}
// listRegistries handles GET /api/registries.
func (s *Server) listRegistries(w http.ResponseWriter, r *http.Request) {
registries, err := s.store.GetAllRegistries()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list registries: "+err.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"`
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 != "",
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 {
respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error())
return
}
reg, err := s.store.CreateRegistry(store.Registry{
Name: req.Name,
URL: req.URL,
Type: req.Type,
Token: encToken,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create registry: "+err.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
}
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.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
}
// Only re-encrypt if a new token is provided.
if req.Token != "" {
encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error())
return
}
updated.Token = encToken
}
if err := s.store.UpdateRegistry(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update registry: "+err.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
}
respondError(w, http.StatusInternalServerError, "failed to delete registry: "+err.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
}
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
return
}
var req testRegistryRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Image == "" {
respondError(w, http.StatusBadRequest, "image is required for testing")
return
}
// Decrypt the token.
token := reg.Token
if token != "" {
decrypted, err := crypto.Decrypt(s.encKey, token)
if err != nil {
token = reg.Token // Fall back to raw token.
} else {
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(), 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
}
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
return
}
// Decrypt the token.
token := reg.Token
if token != "" {
decrypted, err := crypto.Decrypt(s.encKey, token)
if err != nil {
token = reg.Token
} else {
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)
}