feat(web): add opt-in webhook alert actions

An optional actions block in the config adds buttons to alerts. POST
/api/actions/{id} checks that the repository and the current alert match,
then sends the alert as JSON to the configured webhook with an optional
bearer token. The request refuses cross-site callers like POST /refresh.

GET /api/state lists the actions and merges per-alert status rows from an
optional status_url, cached for 10 seconds. Without an actions block the
API output does not change.

The ghtracker_action_requests_total counter records each result.
This commit is contained in:
2026-10-04 16:35:26 +01:00
parent 32de5d6704
commit 5824c4dd10
9 changed files with 980 additions and 14 deletions
+19 -5
View File
@@ -41,6 +41,7 @@ type Server struct {
lastRepo map[string]time.Time
now func() time.Time
src Source
actions *Actions
ui fs.FS
metrics http.Handler
log *slog.Logger
@@ -64,6 +65,9 @@ func (s *Server) Handler() http.Handler {
if s.manualRefresh {
mux.HandleFunc("POST /refresh", s.refresh)
}
if s.actions != nil {
mux.HandleFunc("POST /api/actions/{id}", s.runAction)
}
return securityHeaders(mux)
}
@@ -99,10 +103,12 @@ func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, s.ui, name)
}
func (s *Server) state(w http.ResponseWriter, _ *http.Request) {
func (s *Server) state(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(buildState(s.src.View(), s.manualRefresh)); err != nil {
st := buildState(s.src.View(), s.manualRefresh)
s.decorate(r.Context(), &st)
if err := json.NewEncoder(w).Encode(st); err != nil {
s.log.Error("encode state", "err", err)
}
}
@@ -117,8 +123,7 @@ func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
}
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
// Browsers label cross-site requests; refuse them so another page cannot spend the API budget.
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
if crossSite(r) {
http.Error(w, "cross-site refresh refused", http.StatusForbidden)
return
}
@@ -140,6 +145,13 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
}
// crossSite reports whether the browser labelled the request as cross-site, so another page
// cannot spend the API budget or fire a webhook.
func crossSite(r *http.Request) bool {
site := r.Header.Get("Sec-Fetch-Site")
return site != "" && site != "same-origin" && site != "none"
}
// repoRefreshResponse is the full state after the refresh, so the UI can swap it in, plus the repo name.
type repoRefreshResponse struct {
Repo string `json:"repo"`
@@ -185,7 +197,9 @@ func (s *Server) refreshRepo(w http.ResponseWriter, r *http.Request, repo string
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: buildState(v, s.manualRefresh)}); err != nil {
st := buildState(v, s.manualRefresh)
s.decorate(r.Context(), &st)
if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: st}); err != nil {
s.log.Error("encode repo refresh", "err", err)
}
}