Replace the single global webhook secret with entity-scoped secrets stored
on each project and static site. Webhook-driven project autocreate is
removed — projects must exist before their URL can trigger deploys.
Also wires static-site webhooks (sync_trigger=push|tag), turning the
previously inert "push" trigger into a functional one: POST the site's
webhook URL from a Git provider and Tinyforge re-syncs on matching refs.
- Adds webhook_secret columns + unique indexes to projects and static_sites
- Per-entity GET/regenerate endpoints under /api/projects/{id}/webhook
and /api/sites/{id}/webhook (admin-only)
- Removes /api/settings/webhook-url and the global webhook panel
- Reusable WebhookPanel Svelte component on both detail pages, i18n in en/ru
- Tests for matcher (siteRefMatches, ParseImageRef) and handler (project
match/mismatch/404 and site push/manual/branch-skip)
This commit is contained in:
@@ -231,6 +231,10 @@ func (s *Server) Router() chi.Router {
|
||||
r.Put("/", s.updateProject)
|
||||
r.Delete("/", s.deleteProject)
|
||||
|
||||
// Per-project webhook URL management.
|
||||
r.Get("/webhook", s.getProjectWebhook)
|
||||
r.Post("/webhook/regenerate", s.regenerateProjectWebhook)
|
||||
|
||||
// Stage endpoints.
|
||||
r.Post("/stages", s.createStage)
|
||||
r.Put("/stages/{stage}", s.updateStage)
|
||||
@@ -293,6 +297,8 @@ func (s *Server) Router() chi.Router {
|
||||
r.Post("/deploy", s.deployStaticSite)
|
||||
r.Post("/stop", s.stopStaticSite)
|
||||
r.Post("/start", s.startStaticSite)
|
||||
r.Get("/webhook", s.getStaticSiteWebhook)
|
||||
r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook)
|
||||
r.Post("/secrets", s.createStaticSiteSecret)
|
||||
r.Put("/secrets/{sid}", s.updateStaticSiteSecret)
|
||||
r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret)
|
||||
@@ -372,8 +378,6 @@ func (s *Server) Router() chi.Router {
|
||||
|
||||
// Settings endpoints.
|
||||
r.Put("/settings", s.updateSettings)
|
||||
r.Get("/settings/webhook-url", s.getWebhookURL)
|
||||
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
|
||||
|
||||
// Docker management.
|
||||
r.Post("/docker/prune-images", s.pruneImages)
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/volume"
|
||||
"github.com/alexei/tinyforge/internal/webhook"
|
||||
)
|
||||
|
||||
// settingsRequest is the expected JSON body for updating settings.
|
||||
@@ -275,40 +274,6 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
// getWebhookURL handles GET /api/settings/webhook-url.
|
||||
func (s *Server) getWebhookURL(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := s.store.GetSettings()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
webhookPath := ""
|
||||
if settings.WebhookSecret != "" {
|
||||
webhookPath = "/api/webhook/" + settings.WebhookSecret
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"webhook_url": webhookPath,
|
||||
})
|
||||
}
|
||||
|
||||
// regenerateWebhookSecret handles POST /api/settings/regenerate.
|
||||
func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request) {
|
||||
secret, err := webhook.RegenerateWebhookSecret(s.store)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to regenerate webhook secret: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
webhookURL := "/api/webhook/" + secret
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"webhook_url": webhookURL,
|
||||
"webhook_secret": secret,
|
||||
})
|
||||
}
|
||||
|
||||
// listNpmCertificates handles GET /api/settings/npm-certificates.
|
||||
// It authenticates to NPM using the stored credentials and returns only wildcard certificates.
|
||||
func (s *Server) listNpmCertificates(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// webhookURLResponse is the common payload returned by every webhook endpoint.
|
||||
// Clients never see raw secrets except at issue/rotate time via these fields;
|
||||
// the URL shape is "/api/webhook/..." so callers can prepend their own origin.
|
||||
type webhookURLResponse struct {
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
WebhookSecret string `json:"webhook_secret"`
|
||||
}
|
||||
|
||||
// getProjectWebhook handles GET /api/projects/{id}/webhook.
|
||||
// Returns the project's webhook URL + secret, generating one lazily if the
|
||||
// project predates the per-project webhook migration.
|
||||
func (s *Server) getProjectWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
secret, err := s.store.EnsureProjectWebhookSecret(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
slog.Error("get project webhook: ensure secret", "project", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
||||
WebhookURL: "/api/webhook/" + secret,
|
||||
WebhookSecret: secret,
|
||||
})
|
||||
}
|
||||
|
||||
// regenerateProjectWebhook handles POST /api/projects/{id}/webhook/regenerate.
|
||||
// Rotates the project's webhook secret, invalidating the old URL.
|
||||
func (s *Server) regenerateProjectWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
// Verify project exists before rotating.
|
||||
if _, err := s.store.GetProjectByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
slog.Error("regenerate project webhook: lookup", "project", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
||||
return
|
||||
}
|
||||
|
||||
secret := uuid.New().String()
|
||||
if err := s.store.SetProjectWebhookSecret(id, secret); err != nil {
|
||||
slog.Error("regenerate project webhook: set secret", "project", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("project webhook secret rotated", "project", id)
|
||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
||||
WebhookURL: "/api/webhook/" + secret,
|
||||
WebhookSecret: secret,
|
||||
})
|
||||
}
|
||||
|
||||
// getStaticSiteWebhook handles GET /api/sites/{id}/webhook.
|
||||
func (s *Server) getStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
secret, err := s.store.EnsureStaticSiteWebhookSecret(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
slog.Error("get site webhook: ensure secret", "site", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
||||
WebhookURL: "/api/webhook/sites/" + secret,
|
||||
WebhookSecret: secret,
|
||||
})
|
||||
}
|
||||
|
||||
// regenerateStaticSiteWebhook handles POST /api/sites/{id}/webhook/regenerate.
|
||||
func (s *Server) regenerateStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if _, err := s.store.GetStaticSiteByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
slog.Error("regenerate site webhook: lookup", "site", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
||||
return
|
||||
}
|
||||
|
||||
secret := uuid.New().String()
|
||||
if err := s.store.SetStaticSiteWebhookSecret(id, secret); err != nil {
|
||||
slog.Error("regenerate site webhook: set secret", "site", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("static site webhook secret rotated", "site", id)
|
||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
||||
WebhookURL: "/api/webhook/sites/" + secret,
|
||||
WebhookSecret: secret,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user