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>
207 lines
6.3 KiB
Go
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))
|
|
}
|