Files
Hammerhead/alerts_api.go
T
VeillaxandClaude Sonnet 5.5 227c890409
CI / test (push) Successful in 4m52s
Release / release (push) Successful in 5m24s
Add Sandbar alert channel, tags for resources and sites, use Reef's secretbox
Sandbar channels raise and resolve alerts through its API, with the API key
sealed at rest and never returned to the browser. Resources and sites can be
tagged and filtered by tag; sites can be renamed. The secretbox package now
comes from Reef v0.1.9 instead of a private copy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 03:32:43 -06:00

207 lines
6.3 KiB
Go

package hammerhead
import (
"errors"
"net/http"
"net/url"
"sort"
"time"
)
// The same handlers serve an organization's channels and the server-level ones
// (for edges, which belong to nobody); only the scope differs.
type alertScope struct {
org string // "" = server level
trusted bool // may allow private webhook targets
actor string
}
type channelView struct {
AlertChannel
Preview string `json:"preview"`
}
func preview(ch AlertChannel) string {
if ch.Kind == "email" {
return ch.Target
}
if u, err := url.Parse(ch.Target); err == nil {
return u.Scheme + "://" + u.Host + "/…"
}
return "webhook"
}
type alertRow struct {
Key string `json:"key"`
Kind string `json:"kind"`
Name string `json:"name"`
Message string `json:"message"`
Since time.Time `json:"since"`
Fired bool `json:"fired"`
}
func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) {
s.mu.Lock()
chans := []channelView{}
for _, c := range s.st.Channels {
if c.OrgID == sc.org {
v := channelView{AlertChannel: *c, Preview: preview(*c)}
v.Target, v.Token = "", "" // webhook URLs often contain tokens
if c.Kind == "email" {
v.Target = c.Target
}
chans = append(chans, v)
}
}
active := []alertRow{}
for _, a := range s.alerts {
if a.OrgID == sc.org {
active = append(active, alertRow{a.Key, a.Kind, a.Name, a.Message, a.Since, a.Fired})
}
}
sort.Slice(active, func(i, j int) bool { return active[i].Since.Before(active[j].Since) })
settings := map[string]int{"debounceMin": 2, "repeatHours": 0}
if o := s.findOrg(sc.org); o != nil {
if o.AlertDebounceMin > 0 {
settings["debounceMin"] = o.AlertDebounceMin
}
settings["repeatHours"] = o.AlertRepeatHours
}
mailOK := s.st.Settings.SMTP.Host != ""
s.mu.Unlock()
writeJSON(w, 200, map[string]any{"channels": chans, "active": active, "history": s.db.queryAlertEvents(sc.org, 100), "settings": settings, "emailConfigured": mailOK})
}
func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
var in AlertChannel
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
id := r.PathValue("id")
var existing *AlertChannel
if id != "" {
for _, c := range s.st.Channels {
if c.ID == id && c.OrgID == sc.org {
existing = c
}
}
if existing == nil {
fail(w, 404, errors.New("no such channel"))
return
}
if in.Target == "" {
in.Target = existing.Target // blank keeps the saved URL/address
}
if id != "" && in.Token == "" && in.Kind == "sandbar" {
in.Token = existing.Token // blank keeps the saved API key
}
}
trusted := sc.trusted
if err := s.validateChannel(&in, trusted); err != nil {
fail(w, 400, err)
return
}
in.OrgID = sc.org
if existing != nil {
in.ID = id
*existing = in
} else {
in.ID = newID()
s.st.Channels = append(s.st.Channels, &in)
}
s.save()
s.audit(sc.org, sc.actor, "alert.channel.save", in.Name)
ok(w)
}
func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
s.st.Channels = filter(s.st.Channels, func(c *AlertChannel) bool { return !(c.ID == id && c.OrgID == sc.org) })
s.save()
ok(w)
}
func (s *Server) testChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
id := r.PathValue("id")
s.mu.Lock()
var ch *AlertChannel
for _, c := range s.st.Channels {
if c.ID == id && c.OrgID == sc.org {
cp := *c
ch = &cp
}
}
name := "server"
if o := s.findOrg(sc.org); o != nil {
name = o.Name
}
s.mu.Unlock()
if ch == nil {
fail(w, 404, errors.New("no such channel"))
return
}
now := time.Now()
if err := s.send(*ch, alertNote{Status: "test", Org: name, Kind: "test", Name: "Test alert", Message: "test", Since: now, Time: now}); err != nil {
fail(w, 400, err)
return
}
ok(w)
}
func (s *Server) alertSettings(w http.ResponseWriter, r *http.Request, sc alertScope) {
var in struct{ DebounceMin, RepeatHours int }
if !decode(w, r, &in) {
return
}
if in.DebounceMin < 1 || in.DebounceMin > 120 || in.RepeatHours < 0 || in.RepeatHours > 168 {
fail(w, 400, errors.New("Wait 1 to 120 minutes before alerting, and remind every 0 to 168 hours (0 means no reminders)."))
return
}
s.mu.Lock()
defer s.mu.Unlock()
o := s.findOrg(sc.org)
if o == nil {
fail(w, 404, errors.New("organization settings only"))
return
}
o.AlertDebounceMin, o.AlertRepeatHours = in.DebounceMin, in.RepeatHours
s.save()
ok(w)
}
// wrappers: an organization's admins manage its channels; server admins manage the server-level ones
func (s *Server) orgAlertScope(c *orgCtx) alertScope {
return alertScope{org: c.Org.ID, trusted: c.Org.AllowLocal || c.User.ServerAdmin, actor: c.Actor}
}
func (s *Server) withOrgAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
return s.withOrg(true, func(w http.ResponseWriter, r *http.Request, c *orgCtx) { fn(w, r, s.orgAlertScope(c)) })
}
func (s *Server) withServerAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
return s.withServerAdmin(func(w http.ResponseWriter, r *http.Request, p *principal) {
fn(w, r, alertScope{org: "", trusted: true, actor: p.User.Email})
})
}
func (s *Server) registerAlertRoutes(mux *http.ServeMux) {
overview := func(w http.ResponseWriter, r *http.Request, sc alertScope) { s.alertOverview(w, sc) }
mux.HandleFunc("GET /api/orgs/{org}/alerts", s.withOrgAlerts(overview))
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels", s.withOrgAlerts(s.saveChannel))
mux.HandleFunc("PUT /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.saveChannel))
mux.HandleFunc("DELETE /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.deleteChannel))
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels/{id}/test", s.withOrgAlerts(s.testChannel))
mux.HandleFunc("PUT /api/orgs/{org}/alerts/settings", s.withOrgAlerts(s.alertSettings))
mux.HandleFunc("GET /api/admin/alerts", s.withServerAlerts(overview))
mux.HandleFunc("POST /api/admin/alerts/channels", s.withServerAlerts(s.saveChannel))
mux.HandleFunc("PUT /api/admin/alerts/channels/{id}", s.withServerAlerts(s.saveChannel))
mux.HandleFunc("DELETE /api/admin/alerts/channels/{id}", s.withServerAlerts(s.deleteChannel))
mux.HandleFunc("POST /api/admin/alerts/channels/{id}/test", s.withServerAlerts(s.testChannel))
}