Files
VeillaxandClaude Sonnet 5.5 cf4d5b3ce2 Turn the status page into a full status page generator
Components can be put in groups and given public names. Public incidents carry
posts written for customers (the internal timeline stays internal), the
components they affect, and a page for each incident plus paged history.
Scheduled maintenance has its own window, shows affected components as under
maintenance, and is announced when scheduled, started, ended or canceled.

Branding (title, logo, accent checked for contrast, contact link, footer) and a
custom domain that serves only the status page. Visitors follow by RSS, by
confirmed email, or by webhook that must answer a test request. Admin UI for
all of it, tests, README and design notes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:05:38 -06:00

415 lines
14 KiB
Go

package sandbar
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"log"
"net/http"
"net/mail"
"net/url"
"sort"
"strings"
"time"
)
// Following the status page: email (confirmed by a link), webhook (confirmed by answering a test
// request) and RSS. Every update to a public incident, and every maintenance window, goes to all
// confirmed subscribers.
// statusNote is one update, as sent to subscribers.
type statusNote struct {
Event string `json:"event"` // incident.created, incident.updated, incident.resolved, maintenance.scheduled, .rescheduled, .started, .completed, .canceled
Title string `json:"title"`
Status string `json:"status"`
Message string `json:"message"`
URL string `json:"url"`
Time int64 `json:"time"`
}
func (n statusNote) subject(page string) string {
label := map[string]string{
"incident.created": "New incident", "incident.updated": "Incident update", "incident.resolved": "Resolved",
"maintenance.scheduled": "Scheduled maintenance", "maintenance.rescheduled": "Maintenance rescheduled",
"maintenance.started": "Maintenance started", "maintenance.completed": "Maintenance complete", "maintenance.canceled": "Maintenance canceled",
}[n.Event]
return "[" + page + "] " + label + ": " + n.Title
}
type subscriber struct {
ID string `json:"id"`
Kind string `json:"kind"` // email or webhook
Target string `json:"target"`
Confirmed bool `json:"confirmed"`
Created int64 `json:"created"`
}
func (s *Server) mailEnabled() bool { return s.opts.Mailer != nil || s.opts.SMTP.Host != "" }
// ---------- links that act for a subscriber ----------
// statusSecret signs confirm and unsubscribe links, so none needs a stored token.
func (s *Server) statusSecret() []byte {
s.statusMu.Lock()
defer s.statusMu.Unlock()
if s.statusKey == nil {
var v struct{ Value string }
if s.st.get("secret", "status", &v) != nil || v.Value == "" {
v.Value = newToken()
s.st.put("secret", "status", v)
}
s.statusKey = []byte(v.Value)
}
return s.statusKey
}
func (s *Server) subToken(purpose, id string) string {
m := hmac.New(sha256.New, s.statusSecret())
m.Write([]byte(purpose + "." + id))
return id + "." + hex.EncodeToString(m.Sum(nil))[:32]
}
func (s *Server) parseSubToken(purpose, tok string) (string, bool) {
id, _, ok := strings.Cut(tok, ".")
return id, ok && hmac.Equal([]byte(tok), []byte(s.subToken(purpose, id)))
}
// ---------- storage ----------
func subKey(kind, target string) string { return hashToken(kind + ":" + strings.ToLower(target)) }
// addSubscriber stores a subscriber, or returns the one already stored for that address.
func (s *Server) addSubscriber(kind, target string, confirmed bool) (sub subscriber, existed bool, err error) {
var n int
s.st.db.QueryRow(`SELECT COUNT(*) FROM subscribers`).Scan(&n)
key := subKey(kind, target)
if got, ok := s.subscriberBy(`key=?`, key); ok {
return got, true, nil
}
if n >= 20000 {
return sub, false, errors.New("this status page cannot take more subscribers")
}
sub = subscriber{ID: newID(), Kind: kind, Target: target, Confirmed: confirmed, Created: s.now().Unix()}
_, err = s.st.db.Exec(`INSERT INTO subscribers(id,kind,target,key,confirmed,created) VALUES(?,?,?,?,?,?)`,
sub.ID, kind, s.st.seal(target), key, boolInt(confirmed), sub.Created)
return sub, false, err
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
func (s *Server) subscriberBy(where string, args ...any) (subscriber, bool) {
var sub subscriber
var sealed string
var conf int
err := s.st.db.QueryRow(`SELECT id,kind,target,confirmed,created FROM subscribers WHERE `+where, args...).Scan(&sub.ID, &sub.Kind, &sealed, &conf, &sub.Created)
if errors.Is(err, sql.ErrNoRows) || err != nil {
return sub, false
}
sub.Target, sub.Confirmed = s.st.open(sealed), conf == 1
return sub, true
}
func (s *Server) confirmedSubscribers() []subscriber {
rows, err := s.st.db.Query(`SELECT id,kind,target,created FROM subscribers WHERE confirmed=1`)
if err != nil {
return nil
}
defer rows.Close()
var out []subscriber
for rows.Next() {
var sub subscriber
var sealed string
if rows.Scan(&sub.ID, &sub.Kind, &sealed, &sub.Created) == nil {
sub.Target, sub.Confirmed = s.st.open(sealed), true
out = append(out, sub)
}
}
return out
}
// listSubscribers is for administrators. A webhook address often carries a secret, so only its host is shown.
func (s *Server) listSubscribers(w http.ResponseWriter, r *http.Request) {
out := []subscriber{}
rows, err := s.st.db.Query(`SELECT id,kind,target,confirmed,created FROM subscribers ORDER BY created DESC LIMIT 1000`)
if err != nil {
fail(w, err)
return
}
defer rows.Close()
for rows.Next() {
var sub subscriber
var sealed string
var conf int
if rows.Scan(&sub.ID, &sub.Kind, &sealed, &conf, &sub.Created) != nil {
continue
}
sub.Target, sub.Confirmed = s.st.open(sealed), conf == 1
if sub.Kind == "webhook" {
if u, err := url.Parse(sub.Target); err == nil {
sub.Target = u.Host
}
}
out = append(out, sub)
}
writeJSON(w, 200, out)
}
// ---------- sending ----------
func (s *Server) notifySubscribers(n statusNote) {
subs := s.confirmedSubscribers()
if len(subs) == 0 {
return
}
title := s.statusSettings().Title
s.notifyWG.Add(1)
go func() {
defer s.notifyWG.Done()
for _, sub := range subs {
if err := s.deliver(sub, n, title); err != nil {
log.Printf("status page subscriber %s: %v", sub.ID, err)
}
}
}()
}
func (s *Server) deliver(sub subscriber, n statusNote, pageTitle string) error {
unsub := s.absStatusURL(nil, "/unsubscribe?token="+url.QueryEscape(s.subToken("unsubscribe", sub.ID)))
switch sub.Kind {
case "email":
body := n.Message + "\n\n" + n.URL + "\n\nYou get this because you subscribed to updates from " + pageTitle + ". Unsubscribe: " + unsub
return s.Mail(sub.Target, n.subject(pageTitle), body)
case "webhook":
return s.postWebhook(sub.Target, map[string]any{"page": pageTitle, "event": n.Event, "title": n.Title, "status": n.Status, "message": n.Message,
"url": n.URL, "time": n.Time, "unsubscribe": unsub})
}
return errors.New("unknown subscriber kind")
}
func (s *Server) postWebhook(target string, payload any) error {
b, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", target, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Sandbar status page")
resp, err := s.httpClient(false).Do(req) // visitors choose these addresses, so private networks stay out of reach
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("the webhook answered %s", resp.Status)
}
return nil
}
// ---------- public endpoints ----------
// subscribeAllowed lets each address make a handful of requests an hour.
func (s *Server) subscribeAllowed(ip string) bool {
s.subMu.Lock()
defer s.subMu.Unlock()
now := s.now()
keep := s.subTry[ip][:0]
for _, t := range s.subTry[ip] {
if now.Sub(t) < time.Hour {
keep = append(keep, t)
}
}
if len(keep) >= 6 {
s.subTry[ip] = keep
return false
}
if len(s.subTry) > 5000 { // forget addresses that have gone quiet
for k, v := range s.subTry {
if len(v) == 0 || now.Sub(v[len(v)-1]) >= time.Hour {
delete(s.subTry, k)
}
}
}
s.subTry[ip] = append(keep, now)
return true
}
func (s *Server) followStatusHTTP(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Webhook string `json:"webhook"`
}
if !decode(r, &in) {
bad(w, "invalid request")
return
}
if !s.subscribeAllowed(s.realIP(r)) {
writeJSON(w, 429, map[string]string{"error": "Too many attempts. Try again in an hour."})
return
}
title := s.statusSettings().Title
switch {
case strings.TrimSpace(in.Email) != "":
if !s.mailEnabled() {
bad(w, "email updates are not set up on this page")
return
}
addr := strings.TrimSpace(in.Email)
if a, err := mail.ParseAddress(addr); err != nil || a.Address != addr || len(addr) > 254 || strings.ContainsAny(addr, "\r\n<>") {
bad(w, "enter a valid email address")
return
}
sub, existed, err := s.addSubscriber("email", addr, false)
if err != nil {
bad(w, err.Error())
return
}
// The answer is the same whether or not the address was known, so the form cannot be used to probe it.
if !(existed && sub.Confirmed) {
link := s.absStatusURL(r, "/confirm?token="+url.QueryEscape(s.subToken("confirm", sub.ID)))
body := "Confirm that you want email updates from " + title + " by opening this link:\n\n" + link + "\n\nIf you did not ask for this, ignore this email and nothing will be sent."
if err := s.Mail(addr, "["+title+"] Confirm your subscription", body); err != nil {
log.Printf("status page confirmation to %s: %v", addr, err)
writeJSON(w, 502, map[string]string{"error": "The confirmation email could not be sent. Try again later."})
return
}
}
writeJSON(w, 200, map[string]string{"message": "Check your email and open the link to finish subscribing."})
case strings.TrimSpace(in.Webhook) != "":
target := strings.TrimSpace(in.Webhook)
if u, err := url.Parse(target); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || len(target) > 500 {
bad(w, "the webhook must be an http:// or https:// address")
return
}
sub, existed, err := s.addSubscriber("webhook", target, false)
if err != nil {
bad(w, err.Error())
return
}
if existed && sub.Confirmed {
writeJSON(w, 200, map[string]string{"message": "That webhook is subscribed."})
return
}
// Answering a test request shows the address belongs to something that wants these updates.
unsub := s.absStatusURL(r, "/unsubscribe?token="+url.QueryEscape(s.subToken("unsubscribe", sub.ID)))
if err := s.postWebhook(target, map[string]any{"page": title, "event": "subscription.created", "message": "This address is now subscribed to " + title + ".", "time": s.now().Unix(), "unsubscribe": unsub}); err != nil {
s.st.db.Exec(`DELETE FROM subscribers WHERE id=?`, sub.ID)
bad(w, "that address did not accept a test request, so it was not subscribed; check the URL and try again")
return
}
s.st.db.Exec(`UPDATE subscribers SET confirmed=1 WHERE id=?`, sub.ID)
writeJSON(w, 200, map[string]string{"message": "Subscribed. A test request was sent to the webhook."})
default:
bad(w, "enter an email address or a webhook address")
}
}
func (s *Server) confirmSubscription(w http.ResponseWriter, r *http.Request) {
notice := "invalid"
if id, ok := s.parseSubToken("confirm", r.URL.Query().Get("token")); ok {
if res, err := s.st.db.Exec(`UPDATE subscribers SET confirmed=1 WHERE id=?`, id); err == nil {
if n, _ := res.RowsAffected(); n > 0 {
notice = "confirmed"
}
}
}
http.Redirect(w, r, pagePrefix(r)+"/?notice="+notice, http.StatusSeeOther)
}
// unsubscribeRedirect opens the page with a button instead of acting on a GET, since mail
// scanners follow every link in a message.
func (s *Server) unsubscribeRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, pagePrefix(r)+"/?unsubscribe="+url.QueryEscape(r.URL.Query().Get("token")), http.StatusSeeOther)
}
func (s *Server) unsubscribeHTTP(w http.ResponseWriter, r *http.Request) {
var in struct {
Token string `json:"token"`
}
decode(r, &in)
id, ok := s.parseSubToken("unsubscribe", in.Token)
if !ok {
bad(w, "that unsubscribe link is not valid")
return
}
s.st.db.Exec(`DELETE FROM subscribers WHERE id=?`, id)
writeJSON(w, 200, map[string]string{"message": "You are unsubscribed."})
}
// ---------- RSS ----------
type rssItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
GUID string `xml:"guid"`
PubDate string `xml:"pubDate"`
Desc string `xml:"description"`
t int64
}
func (s *Server) statusFeed(w http.ResponseWriter, r *http.Request) {
now := s.now()
cfg := s.statusSettings()
_, _, names := s.publicComponents()
var items []rssItem
add := func(id string, t int64, title, link, text string) {
items = append(items, rssItem{Title: title, Link: link, GUID: id, PubDate: time.Unix(t, 0).UTC().Format(time.RFC1123Z), Desc: text, t: t})
}
incs, _ := listOf[Incident](s.st, "incident")
for _, in := range incs {
if !in.Public {
continue
}
pi := s.publicIncident(in, names)
for _, u := range pi.Updates {
add(fmt.Sprintf("%s-%d", in.ID, u.Time), u.Time, statusLabel(u.Status)+": "+in.Title, s.absStatusURL(r, "/incidents/"+in.ID), u.Text)
}
}
for _, m := range s.loadMaintenance() {
if m.Canceled {
continue
}
add("maintenance-"+m.ID, m.Created, "Scheduled maintenance: "+m.Title, s.absStatusURL(r, ""), strings.TrimSpace(fmtWindow(m.Start, m.End)+". "+m.Message))
}
sort.SliceStable(items, func(i, j int) bool { return items[i].t > items[j].t })
if len(items) > 50 {
items = items[:50]
}
type channel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
LastBuildDate string `xml:"lastBuildDate"`
Items []rssItem `xml:"item"`
}
feed := struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Channel channel `xml:"channel"`
}{Version: "2.0", Channel: channel{Title: cfg.Title, Link: s.absStatusURL(r, ""), Description: firstNonEmpty(cfg.Description, "Incidents and maintenance for "+cfg.Title),
LastBuildDate: now.UTC().Format(time.RFC1123Z), Items: items}}
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
w.Header().Set("Cache-Control", "max-age=60")
w.Write([]byte(xml.Header))
enc := xml.NewEncoder(w)
enc.Indent("", " ")
enc.Encode(feed)
}
func statusLabel(status string) string {
if l := map[string]string{"investigating": "Investigating", "identified": "Identified", "monitoring": "Monitoring", "resolved": "Resolved"}[status]; l != "" {
return l
}
return "Update"
}