mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-16 01:08:03 +00:00
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:
+19
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user