791cd4d6af
Build / build (push) Successful in 12m20s
Rebrand the project as Tinyforge to reflect its evolution from a Docker container watcher into a self-hosted mini CI/deployment platform. Rename covers: Go module path, Docker labels, DB/config filenames, JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend i18n, README with static sites docs, and all code comments.
345 lines
8.9 KiB
Go
345 lines
8.9 KiB
Go
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)
|
|
}
|