Files
tiny-forge/internal/api/static_sites.go
T
alexei.dolgolyov 0405ecd9ce
Build / build (push) Successful in 10m36s
feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.

Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.

Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.

Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).

API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.

UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.

Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.

Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
2026-05-07 02:03:32 +03:00

663 lines
19 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"`
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 "••••••••"
}