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.
614 lines
17 KiB
Go
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 "••••••••"
|
|
}
|