Files
tiny-forge/internal/api/static_sites.go
T
alexei.dolgolyov 791cd4d6af
Build / build (push) Successful in 12m20s
feat: rename Docker Watcher to Tinyforge
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.
2026-04-12 21:30:39 +03:00

614 lines
17 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/store"
)
// ── List / Get ─────────────────────────────────────────────────────────
func (s *Server) listStaticSites(w http.ResponseWriter, r *http.Request) {
sites, err := s.store.GetAllStaticSites()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list static sites")
return
}
// Mask access tokens in response.
for i := range sites {
sites[i].AccessToken = maskToken(sites[i].AccessToken)
}
respondJSON(w, http.StatusOK, sites)
}
func (s *Server) getStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
site.AccessToken = maskToken(site.AccessToken)
respondJSON(w, http.StatusOK, site)
}
// ── Create ──────────────────────────────────────────────────────────
type createStaticSiteRequest struct {
Name string `json:"name"`
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
FolderPath string `json:"folder_path"`
AccessToken string `json:"access_token"`
Domain string `json:"domain"`
Mode string `json:"mode"`
RenderMarkdown bool `json:"render_markdown"`
SyncTrigger string `json:"sync_trigger"`
TagPattern string `json:"tag_pattern"`
}
func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
var req createStaticSiteRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" || req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
respondError(w, http.StatusBadRequest, "name, gitea_url, repo_owner, and repo_name are required")
return
}
if req.Branch == "" {
req.Branch = "main"
}
if req.Mode == "" {
req.Mode = "static"
}
if req.SyncTrigger == "" {
req.SyncTrigger = "manual"
}
// Encrypt access token if provided.
encryptedToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
return
}
encryptedToken = encrypted
}
site := store.StaticSite{
Name: req.Name,
Provider: req.Provider,
GiteaURL: strings.TrimRight(req.GiteaURL, "/"),
RepoOwner: req.RepoOwner,
RepoName: req.RepoName,
Branch: req.Branch,
FolderPath: req.FolderPath,
AccessToken: encryptedToken,
Domain: req.Domain,
Mode: req.Mode,
RenderMarkdown: req.RenderMarkdown,
SyncTrigger: req.SyncTrigger,
TagPattern: req.TagPattern,
Status: "idle",
}
created, err := s.store.CreateStaticSite(site)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create static site: "+err.Error())
return
}
created.AccessToken = maskToken(created.AccessToken)
respondJSON(w, http.StatusCreated, created)
}
// ── Update ──────────────────────────────────────────────────────────
func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
var req createStaticSiteRequest
if !decodeJSON(w, r, &req) {
return
}
// Update fields.
if req.Name != "" {
existing.Name = req.Name
}
if req.Provider != "" {
existing.Provider = req.Provider
}
if req.GiteaURL != "" {
existing.GiteaURL = strings.TrimRight(req.GiteaURL, "/")
}
if req.RepoOwner != "" {
existing.RepoOwner = req.RepoOwner
}
if req.RepoName != "" {
existing.RepoName = req.RepoName
}
if req.Branch != "" {
existing.Branch = req.Branch
}
if req.FolderPath != "" {
existing.FolderPath = req.FolderPath
}
if req.Domain != "" {
existing.Domain = req.Domain
}
if req.Mode != "" {
existing.Mode = req.Mode
}
if req.SyncTrigger != "" {
existing.SyncTrigger = req.SyncTrigger
}
existing.RenderMarkdown = req.RenderMarkdown
existing.TagPattern = req.TagPattern
// Update access token only if a new one is provided.
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
return
}
existing.AccessToken = encrypted
}
if err := s.store.UpdateStaticSite(existing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update static site: "+err.Error())
return
}
existing.AccessToken = maskToken(existing.AccessToken)
respondJSON(w, http.StatusOK, existing)
}
// ── Delete ──────────────────────────────────────────────────────────
func (s *Server) deleteStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Remove container and proxy route first.
if s.staticSiteManager != nil {
if err := s.staticSiteManager.Remove(r.Context(), id); err != nil {
// Log but don't fail — still delete the DB record.
respondError(w, http.StatusInternalServerError, "failed to remove site resources: "+err.Error())
return
}
}
if err := s.store.DeleteStaticSite(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete static site")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// ── Deploy ──────────────────────────────────────────────────────────
func (s *Server) deployStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
// Trigger deploy asynchronously with a detached context
// (the HTTP request context is canceled when the response is sent).
// Manual deploys always force a full rebuild + proxy regeneration.
go func() {
ctx := context.Background()
if err := s.staticSiteManager.Deploy(ctx, id, true); err != nil {
// Error is already stored in the site status.
return
}
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "deploying"})
}
// ── Stop / Start ────────────────────────────────────────────────────
func (s *Server) stopStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
go func() {
ctx := context.Background()
_ = s.staticSiteManager.Stop(ctx, id)
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "stopping"})
}
func (s *Server) startStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
go func() {
ctx := context.Background()
_ = s.staticSiteManager.Start(ctx, id)
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "starting"})
}
// ── Test Connection ─────────────────────────────────────────────────
type testConnectionRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
}
func (s *Server) testStaticSiteConnection(w http.ResponseWriter, r *http.Request) {
var req testConnectionRequest
if !decodeJSON(w, r, &req) {
return
}
if req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
respondError(w, http.StatusBadRequest, "gitea_url, repo_owner, and repo_name are required")
return
}
// Encrypt token for the manager to decrypt (consistent handling).
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
if err := s.staticSiteManager.TestConnection(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName); err != nil {
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "connected"})
}
// ── Branches ────────────────────────────────────────────────────────
type listBranchesRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
}
func (s *Server) listStaticSiteBranches(w http.ResponseWriter, r *http.Request) {
var req listBranchesRequest
if !decodeJSON(w, r, &req) {
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
branches, err := s.staticSiteManager.ListBranches(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list branches: "+err.Error())
return
}
respondJSON(w, http.StatusOK, branches)
}
// ── Tree ────────────────────────────────────────────────────────────
type listTreeRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
}
func (s *Server) listStaticSiteTree(w http.ResponseWriter, r *http.Request) {
var req listTreeRequest
if !decodeJSON(w, r, &req) {
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
entries, err := s.staticSiteManager.ListTree(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName, req.Branch)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list tree: "+err.Error())
return
}
respondJSON(w, http.StatusOK, entries)
}
// ── Secrets ─────────────────────────────────────────────────────────
func (s *Server) listStaticSiteSecrets(w http.ResponseWriter, r *http.Request) {
siteID := chi.URLParam(r, "id")
secrets, err := s.store.GetStaticSiteSecretsBySiteID(siteID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list secrets")
return
}
// Mask encrypted values.
for i := range secrets {
if secrets[i].Encrypted {
secrets[i].Value = "••••••••"
}
}
respondJSON(w, http.StatusOK, secrets)
}
type createSecretRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
}
func (s *Server) createStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
siteID := chi.URLParam(r, "id")
var req createSecretRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Key == "" {
respondError(w, http.StatusBadRequest, "key is required")
return
}
value := req.Value
if req.Encrypted && value != "" {
encrypted, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
return
}
value = encrypted
}
secret := store.StaticSiteSecret{
SiteID: siteID,
Key: req.Key,
Value: value,
Encrypted: req.Encrypted,
}
created, err := s.store.CreateStaticSiteSecret(secret)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create secret: "+err.Error())
return
}
if created.Encrypted {
created.Value = "••••••••"
}
respondJSON(w, http.StatusCreated, created)
}
func (s *Server) updateStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
secretID := chi.URLParam(r, "sid")
existing, err := s.store.GetStaticSiteSecretByID(secretID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "secret")
return
}
respondError(w, http.StatusInternalServerError, "failed to get secret")
return
}
var req createSecretRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Key != "" {
existing.Key = req.Key
}
existing.Encrypted = req.Encrypted
if req.Value != "" {
value := req.Value
if req.Encrypted {
encrypted, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
return
}
value = encrypted
}
existing.Value = value
}
if err := s.store.UpdateStaticSiteSecret(existing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update secret: "+err.Error())
return
}
if existing.Encrypted {
existing.Value = "••••••••"
}
respondJSON(w, http.StatusOK, existing)
}
func (s *Server) deleteStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
secretID := chi.URLParam(r, "sid")
if err := s.store.DeleteStaticSiteSecret(secretID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "secret")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete secret")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": secretID})
}
// ── List Repos ──────────────────────────────────────────────────────
type listReposRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
Query string `json:"query"`
}
func (s *Server) listStaticSiteRepos(w http.ResponseWriter, r *http.Request) {
var req listReposRequest
if !decodeJSON(w, r, &req) {
return
}
if req.GiteaURL == "" {
respondError(w, http.StatusBadRequest, "gitea_url is required")
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
repos, err := s.staticSiteManager.ListRepos(r.Context(), req.Provider, req.GiteaURL, encToken, req.Query)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list repos: "+err.Error())
return
}
respondJSON(w, http.StatusOK, repos)
}
// ── Detect Provider ─────────────────────────────────────────────────
type detectProviderRequest struct {
URL string `json:"url"`
}
func (s *Server) detectStaticSiteProvider(w http.ResponseWriter, r *http.Request) {
var req detectProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if req.URL == "" {
respondError(w, http.StatusBadRequest, "url is required")
return
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
provider := s.staticSiteManager.DetectProvider(r.Context(), req.URL)
respondJSON(w, http.StatusOK, map[string]string{"provider": provider})
}
// maskToken returns a masked version of a token string for API responses.
func maskToken(token string) string {
if token == "" {
return ""
}
return "••••••••"
}