Files
ghtracker/internal/web/server.go
T
lukaszraczylo 5824c4dd10 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.
2026-10-04 16:35:26 +01:00

212 lines
6.6 KiB
Go

// Package web serves the embedded UI, the JSON state, the health probe and metrics.
package web
import (
"context"
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net/http"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/lukaszraczylo/ghtracker/internal/collector"
)
const (
minManualRefreshGap = 5 * time.Minute
// repoRefreshCooldown limits each repository separately from the full-refresh gap.
repoRefreshCooldown = 60 * time.Second
indexFile = "index.html"
assetsPrefix = "/assets/"
immutableCache = "public, max-age=31536000, immutable"
contentSecurity = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"connect-src 'self'; img-src 'self' data:; font-src 'self'; form-action 'self'; base-uri 'none'"
)
// Source supplies dashboard state and accepts refresh requests.
type Source interface {
View() collector.View
Stats() collector.Stats
RequestRefresh() bool
RefreshRepo(ctx context.Context, name string) (collector.View, error)
}
type Server struct {
lastManual time.Time
lastRepo map[string]time.Time
now func() time.Time
src Source
actions *Actions
ui fs.FS
metrics http.Handler
log *slog.Logger
mu sync.Mutex
manualRefresh bool
}
// New builds the server; ui is the built frontend (index.html at its root).
func New(src Source, metrics http.Handler, ui fs.FS, manualRefresh bool, log *slog.Logger) *Server {
return &Server{src: src, ui: ui, metrics: metrics, manualRefresh: manualRefresh, log: log, now: time.Now,
lastRepo: make(map[string]time.Time)}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.index)
mux.HandleFunc("GET /assets/", s.asset)
mux.HandleFunc("GET /api/state", s.state)
mux.HandleFunc("GET /healthz", s.health)
mux.Handle("GET /metrics", s.metrics)
if s.manualRefresh {
mux.HandleFunc("POST /refresh", s.refresh)
}
if s.actions != nil {
mux.HandleFunc("POST /api/actions/{id}", s.runAction)
}
return securityHeaders(mux)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", contentSecurity)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func (s *Server) index(w http.ResponseWriter, _ *http.Request) {
b, err := fs.ReadFile(s.ui, indexFile)
if err != nil {
http.Error(w, "UI is not built: run `pnpm build` before `go build`", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(b)
}
// asset serves hashed build output under /assets/ from the embedded filesystem.
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
name := path.Clean(strings.TrimPrefix(r.URL.Path, "/"))
if !strings.HasPrefix("/"+name, assetsPrefix) {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", immutableCache)
http.ServeFileFS(w, r, s.ui, name)
}
func (s *Server) state(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
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)
}
}
// health reports 503 until the first refresh finishes, for probes.
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
if !s.src.Stats().Loaded {
http.Error(w, "loading", http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte("ok\n"))
}
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
if crossSite(r) {
http.Error(w, "cross-site refresh refused", http.StatusForbidden)
return
}
if repo := r.URL.Query().Get("repo"); repo != "" {
s.refreshRepo(w, r, repo)
return
}
s.mu.Lock()
tooSoon := s.now().Sub(s.lastManual) < minManualRefreshGap
if !tooSoon {
s.lastManual = s.now()
}
s.mu.Unlock()
if tooSoon {
http.Error(w, "refresh allowed once per "+minManualRefreshGap.String(), http.StatusTooManyRequests)
return
}
s.src.RequestRefresh()
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"`
apiState
}
// refreshRepo refreshes one repository and answers when it finishes. It has its own cooldown and
// never touches the full-refresh gap. A running full refresh or same-repo refresh gives 409.
func (s *Server) refreshRepo(w http.ResponseWriter, r *http.Request, repo string) {
key := strings.ToLower(repo)
s.mu.Lock()
prev, had := s.lastRepo[key]
wait := repoRefreshCooldown - s.now().Sub(prev)
if had && wait > 0 {
s.mu.Unlock()
w.Header().Set("Retry-After", strconv.Itoa(int((wait+time.Second-1)/time.Second)))
writeJSONError(w, http.StatusTooManyRequests, "repository refresh allowed once per "+repoRefreshCooldown.String())
return
}
s.lastRepo[key] = s.now()
s.mu.Unlock()
v, err := s.src.RefreshRepo(r.Context(), repo)
if err != nil {
// A refused or cancelled attempt spent no GitHub budget, so it must not start a cooldown.
s.mu.Lock()
if had {
s.lastRepo[key] = prev
} else {
delete(s.lastRepo, key)
}
s.mu.Unlock()
switch {
case errors.Is(err, collector.ErrUnknownRepo):
writeJSONError(w, http.StatusNotFound, "unknown repository")
case errors.Is(err, collector.ErrRefreshBusy):
writeJSONError(w, http.StatusConflict, "a refresh is already running; try again shortly")
default:
s.log.Error("refresh repo", "repo", repo, "err", err)
writeJSONError(w, http.StatusInternalServerError, "refresh failed")
}
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
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)
}
}
func writeJSONError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}