0f60a7a5db
Build / build (push) Successful in 10m35s
Persists every inbound webhook hit (project + site) so users can debug
"why didn't my deploy fire?" without grepping daemon logs. Surfaces a
14-day rolling history under the WebhookPanel on each project + site
detail page; refreshes every 30s while open. Daily cron prunes records
older than 14 days alongside the existing event log prune.
Schema:
- webhook_deliveries(id, target_type, target_id, target_name, received_at,
source_ip, signature_state, status_code, outcome, detail, body_size)
- indexes on (target_type,target_id,received_at) and (received_at)
Backend:
- store: WebhookDelivery model + Insert/List/Prune helpers
- webhook/handler: deferred recordDelivery() captures the final outcome
on every return path including HMAC rejects, image mismatch, no-stage,
auto_deploy=false, and successful deploys; signatureStateFor()
classifies "unconfigured" vs "missing" vs "invalid" vs "valid"
- api: GET /api/{projects,sites}/{id}/webhook/deliveries with
parseLimit() helper (default 50, max 200)
- main: daily prune cron retains the last 14 days
Frontend:
- WebhookDeliveryLog.svelte: panel with refresh button, status code +
outcome + signature badges, relative time tooltip-on-hover for
absolute time, source IP column
- Mounted below WebhookPanel on project + site detail pages
- en/ru i18n strings for outcome/signature enums and column labels
371 lines
13 KiB
Go
371 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// generateWebhookSecret returns a 256-bit hex-encoded random token. Mirrors
|
|
// the helper in internal/store; kept here to avoid an import cycle and so the
|
|
// rotation handlers don't pretend to use uuid for what is really a secret.
|
|
func generateWebhookSecret() string {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic("crypto/rand failed: " + err.Error())
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// 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"`
|
|
HasSigningSecret bool `json:"has_signing_secret"`
|
|
WebhookRequireSignature bool `json:"webhook_require_signature"`
|
|
}
|
|
|
|
// signingSecretResponse is returned when a signing secret is issued or rotated.
|
|
type signingSecretResponse struct {
|
|
SigningSecret string `json:"signing_secret"`
|
|
}
|
|
|
|
// signingToggleRequest is the body of the require-signature toggle endpoint.
|
|
type signingToggleRequest struct {
|
|
RequireSignature bool `json:"require_signature"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
project, err := s.store.GetProjectByID(id)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, webhookURLResponse{
|
|
WebhookURL: "/api/webhook/" + secret,
|
|
WebhookSecret: secret,
|
|
HasSigningSecret: project.WebhookSigningSecret != "",
|
|
WebhookRequireSignature: project.WebhookRequireSignature,
|
|
})
|
|
}
|
|
|
|
// regenerateProjectSigningSecret handles POST /api/projects/{id}/webhook/signing-secret/regenerate.
|
|
// Issues a fresh HMAC signing secret for inbound webhook verification. The
|
|
// secret is returned exactly once — the UI is responsible for letting the
|
|
// user copy it into their CI configuration.
|
|
func (s *Server) regenerateProjectSigningSecret(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if _, err := s.store.GetProjectByID(id); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
return
|
|
}
|
|
secret := generateWebhookSecret()
|
|
if err := s.store.SetProjectWebhookSigningSecret(id, secret); err != nil {
|
|
slog.Error("rotate project signing secret", "project", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to rotate signing secret")
|
|
return
|
|
}
|
|
slog.Info("project webhook signing secret rotated", "project", id)
|
|
respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret})
|
|
}
|
|
|
|
// disableProjectSigningSecret handles DELETE /api/projects/{id}/webhook/signing-secret.
|
|
// Clears the HMAC signing secret and disables enforcement.
|
|
func (s *Server) disableProjectSigningSecret(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := s.store.SetProjectWebhookSigningSecret(id, ""); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to clear signing secret")
|
|
return
|
|
}
|
|
if err := s.store.SetProjectWebhookRequireSignature(id, false); err != nil {
|
|
slog.Warn("disable project require_signature", "project", id, "error", err)
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
}
|
|
|
|
// updateProjectSigningRequirement handles PUT /api/projects/{id}/webhook/require-signature.
|
|
// Toggles whether unsigned/invalidly-signed inbound webhook requests are
|
|
// rejected with 401.
|
|
func (s *Server) updateProjectSigningRequirement(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var req signingToggleRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.RequireSignature {
|
|
project, err := s.store.GetProjectByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
return
|
|
}
|
|
if project.WebhookSigningSecret == "" {
|
|
respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement")
|
|
return
|
|
}
|
|
}
|
|
if err := s.store.SetProjectWebhookRequireSignature(id, req.RequireSignature); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to update setting")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
}
|
|
|
|
// 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 := generateWebhookSecret()
|
|
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
|
|
}
|
|
|
|
site, err := s.store.GetStaticSiteByID(id)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, webhookURLResponse{
|
|
WebhookURL: "/api/webhook/sites/" + secret,
|
|
WebhookSecret: secret,
|
|
HasSigningSecret: site.WebhookSigningSecret != "",
|
|
WebhookRequireSignature: site.WebhookRequireSignature,
|
|
})
|
|
}
|
|
|
|
// regenerateStaticSiteSigningSecret handles POST /api/sites/{id}/webhook/signing-secret/regenerate.
|
|
func (s *Server) regenerateStaticSiteSigningSecret(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
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
|
return
|
|
}
|
|
secret := generateWebhookSecret()
|
|
if err := s.store.SetStaticSiteWebhookSigningSecret(id, secret); err != nil {
|
|
slog.Error("rotate site signing secret", "site", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to rotate signing secret")
|
|
return
|
|
}
|
|
slog.Info("static site webhook signing secret rotated", "site", id)
|
|
respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret})
|
|
}
|
|
|
|
// disableStaticSiteSigningSecret handles DELETE /api/sites/{id}/webhook/signing-secret.
|
|
func (s *Server) disableStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := s.store.SetStaticSiteWebhookSigningSecret(id, ""); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "static site")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to clear signing secret")
|
|
return
|
|
}
|
|
if err := s.store.SetStaticSiteWebhookRequireSignature(id, false); err != nil {
|
|
slog.Warn("disable site require_signature", "site", id, "error", err)
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
}
|
|
|
|
// listProjectWebhookDeliveries handles GET /api/projects/{id}/webhook/deliveries.
|
|
// Returns the most recent webhook deliveries for the project so users can
|
|
// debug "why didn't my deploy fire?" without grepping daemon logs.
|
|
func (s *Server) listProjectWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if _, err := s.store.GetProjectByID(id); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
return
|
|
}
|
|
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
|
|
deliveries, err := s.store.ListWebhookDeliveriesByTarget("project", id, limit)
|
|
if err != nil {
|
|
slog.Error("list project webhook deliveries", "project", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, deliveries)
|
|
}
|
|
|
|
// listStaticSiteWebhookDeliveries handles GET /api/sites/{id}/webhook/deliveries.
|
|
func (s *Server) listStaticSiteWebhookDeliveries(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
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
|
return
|
|
}
|
|
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
|
|
deliveries, err := s.store.ListWebhookDeliveriesByTarget("site", id, limit)
|
|
if err != nil {
|
|
slog.Error("list site webhook deliveries", "site", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, deliveries)
|
|
}
|
|
|
|
// parseLimit clamps a query-string limit to [1, max], falling back to def.
|
|
func parseLimit(raw string, def, max int) int {
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n <= 0 {
|
|
return def
|
|
}
|
|
if n > max {
|
|
return max
|
|
}
|
|
return n
|
|
}
|
|
|
|
// updateStaticSiteSigningRequirement handles PUT /api/sites/{id}/webhook/require-signature.
|
|
func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var req signingToggleRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.RequireSignature {
|
|
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.WebhookSigningSecret == "" {
|
|
respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement")
|
|
return
|
|
}
|
|
}
|
|
if err := s.store.SetStaticSiteWebhookRequireSignature(id, req.RequireSignature); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "static site")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to update setting")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
}
|
|
|
|
// 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 := generateWebhookSecret()
|
|
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,
|
|
})
|
|
}
|