0632f512e6
Build / build (push) Successful in 10m25s
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)
123 lines
3.9 KiB
Go
123 lines
3.9 KiB
Go
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,
|
|
})
|
|
}
|