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"` StorageEnabled bool `json:"storage_enabled"` StorageLimitMB int `json:"storage_limit_mb"` NotificationURL *string `json:"notification_url,omitempty"` } 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, StorageEnabled: req.StorageEnabled, StorageLimitMB: req.StorageLimitMB, Status: "idle", } if req.NotificationURL != nil { site.NotificationURL = *req.NotificationURL } 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 existing.StorageEnabled = req.StorageEnabled existing.StorageLimitMB = req.StorageLimitMB if req.NotificationURL != nil { existing.NotificationURL = *req.NotificationURL } // 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}) } // ── Storage Usage ────────────────────────────────────────────────── func (s *Server) getStaticSiteStorage(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 } if !site.StorageEnabled { respondJSON(w, http.StatusOK, map[string]interface{}{ "enabled": false, "used_bytes": 0, "limit_mb": 0, }) return } usage, err := s.docker.InspectSiteStorageUsage(r.Context(), site.ContainerID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to inspect storage usage") return } respondJSON(w, http.StatusOK, map[string]interface{}{ "enabled": true, "used_bytes": usage.UsedBytes, "limit_mb": site.StorageLimitMB, }) } // maskToken returns a masked version of a token string for API responses. func maskToken(token string) string { if token == "" { return "" } return "••••••••" }