Add notification channels and a provider abstraction

Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-12 21:00:40 +02:00
parent 6292ad6708
commit b114251f45
42 changed files with 2200 additions and 611 deletions
+9 -6
View File
@@ -7,6 +7,8 @@ import (
"net/http"
"strconv"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
// The bootstrap user is an administrator; everybody it creates afterwards is
@@ -96,8 +98,9 @@ func TestAdmin_MemberIsRefusedAdministration(t *testing.T) {
map[string]string{"password": "hunter2-hunter2"}},
{"mint a key for the admin", http.MethodPost, "/api/users/1/api-keys",
map[string]string{"name": "borrowed"}},
{"retarget the admin's notifications", http.MethodPut, "/api/users/1/notify",
map[string]string{"ntfy_topic": "attacker-topic"}},
{"add a channel to the admin's notifications", http.MethodPost, "/api/users/1/channels",
map[string]string{"type": "ntfy", "address": "attacker-topic"}},
{"read the admin's channels", http.MethodGet, "/api/users/1/channels", nil},
}
for _, c := range cases {
@@ -111,14 +114,14 @@ func TestAdmin_MemberIsRefusedAdministration(t *testing.T) {
// Being refused other people's accounts must not cost a user their own.
func TestAdmin_MemberKeepsTheirOwnAccount(t *testing.T) {
s := newTS(t)
s := newTS(t, api.NotifyConfig{Providers: newFakeNtfy(t).providers()})
memberID, call := member(t, s, "member")
self := "/api/users/" + id64(memberID)
resp := call(http.MethodPut, self+"/notify", map[string]string{"ntfy_topic": "terdut-member"})
resp := call(http.MethodPost, self+"/channels", map[string]string{"type": "ntfy", "address": "terdut-member"})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("own notify target: %d", resp.StatusCode)
if resp.StatusCode != http.StatusCreated {
t.Errorf("own notification channel: %d", resp.StatusCode)
}
resp = call(http.MethodPut, self+"/password", map[string]string{"password": "correct-horse-battery"})
+4
View File
@@ -345,6 +345,10 @@ func handleMe(db *sql.DB) http.HandlerFunc {
serverError(w, r, err)
return
}
if user.Channels, err = loadChannels(r.Context(), db, caller.ID, true); err != nil {
serverError(w, r, err)
return
}
var hash sql.NullString
var dismissed *int64
if err := db.QueryRowContext(r.Context(),
+5 -1
View File
@@ -163,7 +163,11 @@ func TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser(t *testing.T) {
method, path string
}{
{http.MethodGet, "/api/users/" + bUserID + "/teams"},
{http.MethodPut, "/api/users/" + bUserID + "/notify"},
{http.MethodGet, "/api/users/" + bUserID + "/channels"},
{http.MethodPost, "/api/users/" + bUserID + "/channels"},
{http.MethodPatch, "/api/users/" + bUserID + "/channels/1"},
{http.MethodDelete, "/api/users/" + bUserID + "/channels/1"},
{http.MethodPost, "/api/users/" + bUserID + "/channels/1/test"},
{http.MethodPut, "/api/users/" + bUserID + "/password"},
{http.MethodGet, "/api/users/" + bUserID + "/api-keys"},
{http.MethodPost, "/api/users/" + bUserID + "/api-keys"},
+390
View File
@@ -0,0 +1,390 @@
package api
import (
"context"
"database/sql"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
// maxChannelsPerUser bounds how many channels one account can hold. Nobody needs
// more than a handful, and every enabled one is a delivery per page.
const maxChannelsPerUser = 10
// loadChannels reads a user's channels. withAddress is false for anyone but the
// owner: an address can be a secret (an ntfy topic lets anyone holding it
// publish to it), and an administrator has no need of it to run the install.
func loadChannels(ctx context.Context, q querier, userID int64, withAddress bool) ([]models.NotificationChannel, error) {
rows, err := q.QueryContext(ctx, `
SELECT id, type, address, enabled, created_at
FROM notification_channels WHERE user_id = $1 ORDER BY id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.NotificationChannel{}
for rows.Next() {
var c models.NotificationChannel
var ts int64
if err := rows.Scan(&c.ID, &c.Type, &c.Address, &c.Enabled, &ts); err != nil {
return nil, err
}
c.CreatedAt = time.Unix(ts, 0).UTC()
if !withAddress {
c.Address = ""
}
out = append(out, c)
}
return out, rows.Err()
}
// hasReachableChannel reports whether a page to this user would go anywhere: an
// enabled channel of a type this server can deliver. It is the one definition of
// "pageable" the team and escalation pages share with the notifier, so they
// cannot promise a page that the notifier would skip.
func hasReachableChannel(ctx context.Context, q querier, providers *notify.Registry, userID int64) (bool, error) {
rows, err := q.QueryContext(ctx,
"SELECT type FROM notification_channels WHERE user_id = $1 AND enabled", userID)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var typ string
if err := rows.Scan(&typ); err != nil {
return false, err
}
if providers.Has(typ) {
return true, nil
}
}
return false, rows.Err()
}
// validateTarget checks a fallback target. Both halves empty is "none", which is
// fine; one half alone is a mistake; and the type must be a provider this server
// has, since a fallback nobody can deliver would look configured and page
// nobody.
func validateTarget(providers *notify.Registry, t notify.Target) (notify.Target, error) {
if t.IsZero() {
return notify.Target{}, nil
}
if t.Type == "" || t.Address == "" {
return notify.Target{}, errors.New("type and address go together: set both, or neither")
}
p, ok := providers.Get(t.Type)
if !ok {
return notify.Target{}, errors.New("this server has no " + strconv.Quote(t.Type) + " provider")
}
address, err := p.Validate(t.Address)
if err != nil {
return notify.Target{}, err
}
return notify.Target{Type: t.Type, Address: address}, nil
}
// providerTypes lists the registered provider types, as a query parameter for
// "channels this server can deliver". Never nil: a nil slice is NULL to Postgres,
// and `= ANY(NULL)` is not false.
func providerTypes(providers *notify.Registry) []string {
types := []string{}
for _, p := range providers.List() {
types = append(types, p.Type())
}
return types
}
// providerView is what the UI needs to offer a channel type.
type providerView struct {
Type string `json:"type"`
Label string `json:"label"`
AddressLabel string `json:"address_label"`
AddressHint string `json:"address_hint"`
}
// handleListProviders says which kinds of channel this server can deliver, so
// the UI offers only those. Readable by anyone signed in: it names no secret.
func handleListProviders(providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := []providerView{}
for _, p := range providers.List() {
out = append(out, providerView{
Type: p.Type(), Label: p.Label(),
AddressLabel: p.AddressLabel(), AddressHint: p.AddressHint(),
})
}
respond(w, http.StatusOK, out)
}
}
// channelOwner parses {id} and checks the caller may act on that user's
// channels: themselves, or an administrator.
func channelOwner(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return 0, false
}
if !requireSelfOrAdmin(w, r, id) {
return 0, false
}
return id, true
}
func isOwner(r *http.Request, userID int64) bool {
caller, ok := userFromContext(r.Context())
return ok && caller.ID == userID
}
// handleListChannels returns a user's channels. The owner sees the addresses; an
// administrator sees the types and whether each is on.
func handleListChannels(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := channelOwner(w, r)
if !ok {
return
}
channels, err := loadChannels(r.Context(), db, id, isOwner(r, id))
if err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, channels)
}
}
// handleCreateChannel adds a channel to a user. The same address twice on one
// account is a 409 rather than two pages for one phone.
func handleCreateChannel(db *sql.DB, providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := channelOwner(w, r)
if !ok {
return
}
var req struct {
Type string `json:"type"`
Address string `json:"address"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
p, ok := providers.Get(req.Type)
if !ok {
respond(w, http.StatusBadRequest,
errResp("this server has no "+strconv.Quote(req.Type)+" provider"))
return
}
address, err := p.Validate(req.Address)
if err != nil {
respond(w, http.StatusBadRequest, errResp(err.Error()))
return
}
var count int
if err := db.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM notification_channels WHERE user_id = $1", id).Scan(&count); err != nil {
serverError(w, r, err)
return
}
if count >= maxChannelsPerUser {
respond(w, http.StatusConflict,
errResp("an account can have at most "+strconv.Itoa(maxChannelsPerUser)+" notification channels"))
return
}
var channelID int64
err = db.QueryRowContext(r.Context(), `
INSERT INTO notification_channels (user_id, type, address)
VALUES ($1, $2, $3) RETURNING id`, id, p.Type(), address).Scan(&channelID)
if err != nil {
switch {
case isUniqueViolation(err):
respond(w, http.StatusConflict, errResp("that channel already exists"))
case isForeignKeyViolation(err):
respond(w, http.StatusNotFound, errResp("user not found"))
default:
serverError(w, r, err)
}
return
}
respondChannel(w, r, db, id, channelID, http.StatusCreated)
}
}
// handleUpdateChannel turns a channel on or off, or changes its address. Off is
// the mute button for one channel: it stays in the list, and pages skip it.
func handleUpdateChannel(db *sql.DB, providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := channelOwner(w, r)
if !ok {
return
}
channelID, err := strconv.ParseInt(chi.URLParam(r, "channelID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid channel id"))
return
}
var req struct {
Enabled *bool `json:"enabled"`
Address *string `json:"address"`
}
if err := decodeJSON(r, &req); err != nil || (req.Enabled == nil && req.Address == nil) {
respond(w, http.StatusBadRequest, errResp("enabled or address is required"))
return
}
var typ string
if err := db.QueryRowContext(r.Context(),
"SELECT type FROM notification_channels WHERE id = $1 AND user_id = $2",
channelID, id).Scan(&typ); errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("channel not found"))
return
} else if err != nil {
serverError(w, r, err)
return
}
if req.Address != nil {
p, ok := providers.Get(typ)
if !ok {
respond(w, http.StatusConflict,
errResp("this server no longer has a "+strconv.Quote(typ)+" provider, so its address cannot be checked"))
return
}
address, err := p.Validate(*req.Address)
if err != nil {
respond(w, http.StatusBadRequest, errResp(err.Error()))
return
}
if _, err := db.ExecContext(r.Context(),
"UPDATE notification_channels SET address = $1 WHERE id = $2", address, channelID); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("that channel already exists"))
return
}
serverError(w, r, err)
return
}
}
if req.Enabled != nil {
if _, err := db.ExecContext(r.Context(),
"UPDATE notification_channels SET enabled = $1 WHERE id = $2", *req.Enabled, channelID); err != nil {
serverError(w, r, err)
return
}
}
respondChannel(w, r, db, id, channelID, http.StatusOK)
}
}
// handleDeleteChannel removes a channel. Notifications already queued for it
// keep their address snapshot and are still delivered; later reminders skip it.
func handleDeleteChannel(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := channelOwner(w, r)
if !ok {
return
}
channelID, err := strconv.ParseInt(chi.URLParam(r, "channelID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid channel id"))
return
}
res, err := db.ExecContext(r.Context(),
"DELETE FROM notification_channels WHERE id = $1 AND user_id = $2", channelID, id)
if err != nil {
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("channel not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// respondChannel writes one channel back, with the address only to its owner.
func respondChannel(w http.ResponseWriter, r *http.Request, db *sql.DB, userID, channelID int64, status int) {
channels, err := loadChannels(r.Context(), db, userID, isOwner(r, userID))
if err != nil {
serverError(w, r, err)
return
}
for _, c := range channels {
if c.ID == channelID {
respond(w, status, c)
return
}
}
respond(w, http.StatusNotFound, errResp("channel not found"))
}
// handleTestChannel sends one test message through a channel.
//
// The point of the first-run checklist's notification step is not that an
// address has been typed but that a phone buzzes, and only the person holding it
// can tell whether it did. Sent directly rather than through the outbox: the
// outbox row requires an incident, and this deliberately belongs to no incident.
//
// Owner only, not an administrator: a test is a page, and nobody should be woken
// by somebody else's click.
func handleTestChannel(db *sql.DB, cfg NotifyConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !isOwner(r, id) {
respond(w, http.StatusForbidden, errResp("a test can only be sent to your own channel"))
return
}
channelID, err := strconv.ParseInt(chi.URLParam(r, "channelID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid channel id"))
return
}
var typ, address string
if err := db.QueryRowContext(r.Context(),
"SELECT type, address FROM notification_channels WHERE id = $1 AND user_id = $2",
channelID, id).Scan(&typ, &address); errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("channel not found"))
return
} else if err != nil {
serverError(w, r, err)
return
}
p, ok := cfg.Providers.Get(typ)
if !ok {
respond(w, http.StatusServiceUnavailable,
errResp("this server has no "+strconv.Quote(typ)+" provider configured, so it can send nothing there"))
return
}
if err := p.Send(r.Context(), notify.Message{
Address: address,
Kind: notify.KindTest,
Title: "terdut test",
Body: "If this arrived, your notifications work.",
}); err != nil {
// The failure is the useful part here: a wrong address, a credential
// the provider rejects, or a provider that is down all look the same
// from the phone, which is silence.
respond(w, http.StatusBadGateway, errResp(p.Label()+" rejected the test: "+err.Error()))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+529
View File
@@ -0,0 +1,529 @@
package api_test
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
// ---------------------------------------------------------------------------
// A second provider
// ---------------------------------------------------------------------------
// fakeMail is a provider that cannot render action buttons, which is what the
// notifier's capability handling is for. Registering it next to ntfy is also the
// proof that nothing in the notifier still assumes there is only one.
type fakeMail struct {
mu sync.Mutex
got []notify.Message
fail error
}
func (m *fakeMail) Type() string { return "mail" }
func (m *fakeMail) Label() string { return "Mail" }
func (m *fakeMail) AddressLabel() string { return "Email address" }
func (m *fakeMail) AddressHint() string { return "Where to send pages." }
func (m *fakeMail) Capabilities() notify.Capabilities {
return notify.Capabilities{ActionButtons: false}
}
func (m *fakeMail) Validate(address string) (string, error) {
address = strings.TrimSpace(address)
if !strings.Contains(address, "@") {
return "", errors.New("that does not look like an email address")
}
return address, nil
}
func (m *fakeMail) Send(_ context.Context, msg notify.Message) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.fail != nil {
return m.fail
}
m.got = append(m.got, msg)
return nil
}
func (m *fakeMail) messages() []notify.Message {
m.mu.Lock()
defer m.mu.Unlock()
return append([]notify.Message(nil), m.got...)
}
func (m *fakeMail) failWith(err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.fail = err
}
// twoProviderTS is notifyTS with ntfy and the fake mail provider both
// registered, the admin on call, and a channel of each kind on the admin.
func twoProviderTS(t *testing.T, cfg api.NotifyConfig) (*ts, *fakeNtfy, *fakeMail) {
t.Helper()
f := newFakeNtfy(t)
mail := &fakeMail{}
cfg.Providers = notify.NewRegistry(notify.NewNtfy(f.URL, ""), mail)
s := newTS(t, cfg)
putOnCall(t, s, 1)
addNtfyChannel(t, s, 1, "terdut-admin")
addChannel(t, s, 1, "mail", "admin@example.com")
return s, f, mail
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(b)
}
func addChannel(t *testing.T, s *ts, userID int, typ, address string) {
t.Helper()
resp := s.req(t, http.MethodPost, fmt.Sprintf("/api/users/%d/channels", userID),
map[string]any{"type": typ, "address": address})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("add %s channel returned %d", typ, resp.StatusCode)
}
}
// ---------------------------------------------------------------------------
// Fan-out
// ---------------------------------------------------------------------------
// A page goes to every enabled channel the person has, each in its own format.
func TestChannels_PageFansOutToEveryEnabledChannel(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
fireCritical(t, s)
s.sweepNotify(t)
if got := len(f.messages()); got != 1 {
t.Fatalf("expected 1 ntfy push, got %d", got)
}
if got := len(mail.messages()); got != 1 {
t.Fatalf("expected 1 mail message, got %d", got)
}
if to := mail.messages()[0].Address; to != "admin@example.com" {
t.Errorf("mail went to %q", to)
}
// One event per delivery, naming the channel type but never the address.
events := notifyEvents(t, s, 1)
if len(events) != 2 {
t.Fatalf("expected an event per channel, got %v", events)
}
seen := map[string]bool{}
for _, e := range events {
detail, _ := e["detail"].(string)
seen[detail] = true
if strings.Contains(detail, "terdut-admin") || strings.Contains(detail, "example.com") {
t.Errorf("the timeline must not carry an address, got %q", detail)
}
}
if !seen["triggered via ntfy"] || !seen["triggered via mail"] {
t.Errorf("expected one event per channel type, got %v", seen)
}
}
// A provider that cannot render a button still gets a way to acknowledge,
// as text; the one that can gets the button.
func TestChannels_AckIsAButtonOrAnInlineLinkByCapability(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
fireCritical(t, s)
s.sweepNotify(t)
if m := f.messages()[0]; len(m.Actions) != 1 || m.Actions[0].Label != "Acknowledge" {
t.Errorf("ntfy should carry the Acknowledge button, got %+v", m.Actions)
}
m := mail.messages()[0]
if m.AckURL != "" {
t.Errorf("a provider without action buttons must not be handed an AckURL, got %q", m.AckURL)
}
if !strings.Contains(m.Body, "Acknowledge: https://terdut.example.com/incidents/1") {
t.Errorf("expected the acknowledge link in the body, got %q", m.Body)
}
if m.ClickURL != "https://terdut.example.com/incidents/1" {
t.Errorf("expected the incident link as click URL, got %q", m.ClickURL)
}
// Only the button needs a token; minting one for text nobody can use is waste.
var tokens int
if err := s.db.QueryRow("SELECT COUNT(*) FROM incident_ack_tokens").Scan(&tokens); err != nil {
t.Fatal(err)
}
if tokens != 1 {
t.Errorf("expected one ack token (for the ntfy button), got %d", tokens)
}
}
// One channel failing must not hold back the other, and its failure is recorded
// with the channel it happened on.
func TestChannels_OneFailingChannelDoesNotBlockTheOther(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
mail.failWith(errors.New("smtp is down"))
fireCritical(t, s)
s.sweepNotify(t)
if got := len(f.messages()); got != 1 {
t.Fatalf("ntfy should have delivered, got %d", got)
}
var lastError string
if err := s.db.QueryRow(
"SELECT last_error FROM notifications WHERE channel_type = 'mail'").Scan(&lastError); err != nil {
t.Fatalf("read the mail row: %v", err)
}
if !strings.Contains(lastError, "smtp is down") {
t.Errorf("expected the failure kept on the row, got %q", lastError)
}
// Back up: the retry (made due) lands without re-sending the ntfy page.
mail.failWith(nil)
s.exec(t, "UPDATE notifications SET send_after = 0 WHERE sent_at IS NULL")
s.sweepNotify(t)
if got := len(mail.messages()); got != 1 {
t.Errorf("expected the retry to deliver the mail, got %d", got)
}
if got := len(f.messages()); got != 1 {
t.Errorf("ntfy must not be paged twice by the other channel's retry, got %d", got)
}
}
// Reminders and the all-clear go to every channel the original page went to.
func TestChannels_RemindersAndAllClearReachEveryChannel(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{
PublicURL: "https://terdut.example.com",
RepeatEvery: 15 * time.Minute,
})
fireCritical(t, s)
s.sweepNotify(t)
s.ageNotifications(t, 20*time.Minute)
s.sweepNotify(t)
if got := len(f.messages()); got != 2 {
t.Errorf("expected a reminder on ntfy, got %d message(s)", got)
}
if got := len(mail.messages()); got != 2 {
t.Errorf("expected a reminder on mail, got %d message(s)", got)
}
// The incident resolves by itself: the all-clear follows the last batch.
postWebhook(t, s, []map[string]any{
amAlert("fp-notify", "DiskFull", "resolved", "2026-05-20T10:00:00Z", "2026-05-20T10:30:00Z",
map[string]string{"severity": "critical"}),
}, "{}:{alertname=\"DiskFull\"}")
s.sweepNotify(t)
if got := len(f.messages()); got != 3 {
t.Errorf("expected the all-clear on ntfy, got %d message(s)", got)
}
if got := len(mail.messages()); got != 3 {
t.Errorf("expected the all-clear on mail, got %d message(s)", got)
}
}
// Turning a channel off is the off switch for it: pages skip it, including the
// reminders of an incident that already paged it.
func TestChannels_DisabledChannelIsSkipped(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{
PublicURL: "https://terdut.example.com",
RepeatEvery: 15 * time.Minute,
})
fireCritical(t, s)
s.sweepNotify(t)
var mailChannel int
if err := s.db.QueryRow(
"SELECT id FROM notification_channels WHERE type = 'mail'").Scan(&mailChannel); err != nil {
t.Fatal(err)
}
resp := s.req(t, http.MethodPatch, fmt.Sprintf("/api/users/1/channels/%d", mailChannel),
map[string]any{"enabled": false})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("disable returned %d", resp.StatusCode)
}
s.ageNotifications(t, 20*time.Minute)
s.sweepNotify(t)
if got := len(f.messages()); got != 2 {
t.Errorf("ntfy should still be reminded, got %d message(s)", got)
}
if got := len(mail.messages()); got != 1 {
t.Errorf("a disabled channel must not be reminded, got %d message(s)", got)
}
// And a new incident does not page it at all.
postWebhook(t, s, []map[string]any{
amAlert("fp-second", "OtherAlert", "firing", "2026-05-20T11:00:00Z", zeroTime,
map[string]string{"severity": "critical"}),
}, "{}:{alertname=\"OtherAlert\"}")
s.sweepNotify(t)
if got := len(mail.messages()); got != 1 {
t.Errorf("a disabled channel must not be paged, got %d message(s)", got)
}
}
// A channel whose provider this server no longer has is skipped at enqueue rather
// than queued to fail eight times.
func TestChannels_UnconfiguredProviderIsSkipped(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
s.exec(t, "INSERT INTO notification_channels (user_id, type, address) VALUES (1, 'mail', 'admin@example.com')")
fireCritical(t, s)
if got := s.countNotifications(t, ""); got != 1 {
t.Errorf("expected only the ntfy row queued, got %d", got)
}
s.sweepNotify(t)
if got := len(f.messages()); got != 1 {
t.Errorf("expected the ntfy page, got %d", got)
}
}
// ---------------------------------------------------------------------------
// The channel API
// ---------------------------------------------------------------------------
func TestChannelsAPI_CreateValidatesTypeAndAddress(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
cases := []struct {
name string
body map[string]any
want int
}{
{"unknown type", map[string]any{"type": "carrier-pigeon", "address": "x"}, http.StatusBadRequest},
{"address the provider rejects", map[string]any{"type": "mail", "address": "not-an-address"}, http.StatusBadRequest},
{"empty topic", map[string]any{"type": "ntfy", "address": " "}, http.StatusBadRequest},
{"topic with a slash", map[string]any{"type": "ntfy", "address": "a/b"}, http.StatusBadRequest},
{"duplicate", map[string]any{"type": "ntfy", "address": "terdut-admin"}, http.StatusConflict},
{"fine", map[string]any{"type": "ntfy", "address": "another-topic"}, http.StatusCreated},
}
for _, c := range cases {
resp := s.req(t, http.MethodPost, "/api/users/1/channels", c.body)
resp.Body.Close()
if resp.StatusCode != c.want {
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
}
}
}
func TestChannelsAPI_CapsChannelsPerAccount(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
for i := 0; i < 8; i++ { // the two from setup make ten
addNtfyChannel(t, s, 1, fmt.Sprintf("topic-%d", i))
}
resp := s.req(t, http.MethodPost, "/api/users/1/channels",
map[string]any{"type": "ntfy", "address": "one-too-many"})
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("expected 409 at the cap, got %d", resp.StatusCode)
}
}
func TestChannelsAPI_ListUpdateDelete(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
var channels []struct {
ID int64 `json:"id"`
Type string `json:"type"`
Address string `json:"address"`
Enabled bool `json:"enabled"`
}
decode(t, s.req(t, http.MethodGet, "/api/users/1/channels", nil), &channels)
if len(channels) != 2 || channels[0].Type != "ntfy" || channels[0].Address != "terdut-admin" || !channels[0].Enabled {
t.Fatalf("expected the two channels in creation order, got %+v", channels)
}
path := fmt.Sprintf("/api/users/1/channels/%d", channels[0].ID)
resp := s.req(t, http.MethodPatch, path, map[string]any{"address": "renamed-topic", "enabled": false})
var updated struct {
Address string `json:"address"`
Enabled bool `json:"enabled"`
}
decode(t, resp, &updated)
if updated.Address != "renamed-topic" || updated.Enabled {
t.Errorf("expected the update applied, got %+v", updated)
}
if resp := s.req(t, http.MethodPatch, path, map[string]any{}); resp.StatusCode != http.StatusBadRequest {
t.Errorf("an empty patch should be a 400, got %d", resp.StatusCode)
}
resp = s.req(t, http.MethodDelete, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("delete returned %d", resp.StatusCode)
}
resp = s.req(t, http.MethodDelete, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("deleting twice should be a 404, got %d", resp.StatusCode)
}
}
// Somebody else's channels are not yours to read or change, and an administrator
// manages them without ever seeing where they go.
func TestChannelsAPI_Authorization(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
memberID, asMember := member(t, s, "member")
memberPath := "/api/users/" + id64(memberID) + "/channels"
// The member owns a channel; the admin can see that it exists, not its address.
resp := asMember(http.MethodPost, memberPath, map[string]any{"type": "ntfy", "address": "member-secret"})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("a member adds their own channel: %d", resp.StatusCode)
}
var own []struct {
Address string `json:"address"`
}
decode(t, asMember(http.MethodGet, memberPath, nil), &own)
if len(own) != 1 || own[0].Address != "member-secret" {
t.Errorf("the owner should see their address, got %+v", own)
}
var seenByAdmin []struct {
Type string `json:"type"`
Address string `json:"address"`
}
decode(t, s.req(t, http.MethodGet, memberPath, nil), &seenByAdmin)
if len(seenByAdmin) != 1 || seenByAdmin[0].Type != "ntfy" || seenByAdmin[0].Address != "" {
t.Errorf("an administrator should see the channel but not its address, got %+v", seenByAdmin)
}
// A member cannot touch the admin's.
for _, c := range []struct{ method, path string }{
{http.MethodGet, "/api/users/1/channels"},
{http.MethodPost, "/api/users/1/channels"},
{http.MethodPatch, "/api/users/1/channels/1"},
{http.MethodDelete, "/api/users/1/channels/1"},
{http.MethodPost, "/api/users/1/channels/1/test"},
} {
resp := asMember(c.method, c.path, map[string]any{"type": "ntfy", "address": "x", "enabled": false})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s: expected 403, got %d", c.method, c.path, resp.StatusCode)
}
}
// The administrator may manage the member's channels but not page them.
var list []struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodGet, memberPath, nil), &list)
resp = s.req(t, http.MethodPost, fmt.Sprintf("%s/%d/test", memberPath, list[0].ID), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("an administrator must not be able to test someone else's channel, got %d", resp.StatusCode)
}
}
func TestChannelsAPI_TestSendsThroughTheProvider(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{})
var channels []struct {
ID int64 `json:"id"`
Type string `json:"type"`
}
decode(t, s.req(t, http.MethodGet, "/api/users/1/channels", nil), &channels)
for _, c := range channels {
resp := s.req(t, http.MethodPost, fmt.Sprintf("/api/users/1/channels/%d/test", c.ID), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("test of %s returned %d", c.Type, resp.StatusCode)
}
}
if msgs := f.messages(); len(msgs) != 1 || msgs[0].Topic != "terdut-admin" || msgs[0].Title != "terdut test" {
t.Errorf("expected a test push on the admin's topic, got %+v", msgs)
}
if msgs := mail.messages(); len(msgs) != 1 || msgs[0].Kind != notify.KindTest {
t.Errorf("expected a test message on mail, got %+v", msgs)
}
// The provider's complaint is the answer: it is why nothing arrived.
mail.failWith(errors.New("relay denied"))
resp := s.req(t, http.MethodPost, fmt.Sprintf("/api/users/1/channels/%d/test", channels[1].ID), nil)
body := readBody(t, resp)
if resp.StatusCode != http.StatusBadGateway || !strings.Contains(body, "relay denied") {
t.Errorf("expected a 502 carrying the reason, got %d %q", resp.StatusCode, body)
}
}
func TestChannelsAPI_ProvidersListsOnlyWhatIsConfigured(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
var providers []struct {
Type string `json:"type"`
Label string `json:"label"`
AddressLabel string `json:"address_label"`
AddressHint string `json:"address_hint"`
}
decode(t, s.req(t, http.MethodGet, "/api/notify/providers", nil), &providers)
if len(providers) != 2 || providers[0].Type != "mail" || providers[1].Type != "ntfy" {
t.Fatalf("expected mail and ntfy in type order, got %+v", providers)
}
if providers[1].AddressLabel != "Topic" || providers[1].AddressHint == "" {
t.Errorf("a provider should describe its address field, got %+v", providers[1])
}
none := newTS(t)
providers = nil
decode(t, none.req(t, http.MethodGet, "/api/notify/providers", nil), &providers)
if len(providers) != 0 {
t.Errorf("a server with no provider should list none, got %+v", providers)
}
}
// ---------------------------------------------------------------------------
// Fallback target
// ---------------------------------------------------------------------------
func TestChannelsAPI_FallbackMustBeADeliverableTarget(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
put := func(fallback map[string]string) int {
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", map[string]any{
"repeat_count": 0, "fallback": fallback, "levels": []any{},
})
resp.Body.Close()
return resp.StatusCode
}
if got := put(map[string]string{"type": "ntfy", "address": "team-fallback"}); got != http.StatusOK {
t.Errorf("a configured provider should be accepted, got %d", got)
}
if got := put(map[string]string{"type": "", "address": ""}); got != http.StatusOK {
t.Errorf("no fallback should be accepted, got %d", got)
}
if got := put(map[string]string{"type": "ntfy"}); got != http.StatusBadRequest {
t.Errorf("a type without an address should be rejected, got %d", got)
}
if got := put(map[string]string{"type": "carrier-pigeon", "address": "x"}); got != http.StatusBadRequest {
t.Errorf("a provider this server lacks should be rejected, got %d", got)
}
if got := put(map[string]string{"type": "mail", "address": "nope"}); got != http.StatusBadRequest {
t.Errorf("an address the provider rejects should be rejected, got %d", got)
}
}
+2 -2
View File
@@ -29,12 +29,12 @@ func deadmanTS(t *testing.T, cfg api.DeadmanConfig) (*ts, *fakeNtfy) {
t.Helper()
f := newFakeNtfy(t)
s := newDeadmanTS(t, cfg, api.NotifyConfig{
BaseURL: f.URL,
Providers: f.providers(),
PublicURL: "https://terdut.example.com",
})
putOnCall(t, s, 1)
setTopic(t, s, 1, "terdut-admin")
addNtfyChannel(t, s, 1, "terdut-admin")
return s, f
}
+71 -47
View File
@@ -9,6 +9,8 @@ import (
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
// evEscalated records a rung of the ladder on the incident's timeline: which
@@ -19,10 +21,10 @@ const evEscalated = "escalated"
// levels with a few targets each — and every use needs all of it, so there is
// no point reading it a level at a time.
type escalationPolicy struct {
teamID int64
repeatCount int64
fallbackTopic string
levels []escalationLevel
teamID int64
repeatCount int64
fallback notify.Target
levels []escalationLevel
}
type escalationLevel struct {
@@ -39,7 +41,7 @@ type escalationTarget struct {
// configured reports whether this team has anything to escalate through. A
// policy row with no levels is the same as no policy: the team gets the
// pre-escalation behaviour, which is reminders on the assignee's topic.
// pre-escalation behaviour, which is reminders to the assignee's channels.
func (p *escalationPolicy) configured() bool { return p != nil && len(p.levels) > 0 }
// level returns the level at a 1-based position.
@@ -58,8 +60,8 @@ func (p *escalationPolicy) level(pos int64) (escalationLevel, bool) {
func loadEscalationPolicy(ctx context.Context, q querier, teamID int64) (*escalationPolicy, error) {
p := &escalationPolicy{teamID: teamID}
err := q.QueryRowContext(ctx,
"SELECT repeat_count, fallback_topic FROM escalation_policies WHERE team_id = $1",
teamID).Scan(&p.repeatCount, &p.fallbackTopic)
"SELECT repeat_count, fallback_type, fallback_address FROM escalation_policies WHERE team_id = $1",
teamID).Scan(&p.repeatCount, &p.fallback.Type, &p.fallback.Address)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -196,7 +198,7 @@ func advanceEscalation(ctx context.Context, db *sql.DB, cfg NotifyConfig, policy
if round < policy.repeatCount {
next, nextRound = 1, round+1
} else {
if err := escalationExhausted(ctx, tx, policy, incidentID, now); err != nil {
if err := escalationExhausted(ctx, tx, cfg, policy, incidentID, now); err != nil {
return err
}
return tx.Commit()
@@ -236,18 +238,23 @@ func advanceEscalation(ctx context.Context, db *sql.DB, cfg NotifyConfig, policy
return tx.Commit()
}
// escalationExhausted is the end of the line: the fallback topic, once, and a
// escalationExhausted is the end of the line: the fallback target, once, and a
// timeline entry saying the ladder is finished. The incident stays triggered —
// escalation running out is not the same as somebody answering.
func escalationExhausted(ctx context.Context, tx *sql.Tx, policy *escalationPolicy, incidentID int64, now time.Time) error {
func escalationExhausted(ctx context.Context, tx *sql.Tx, cfg NotifyConfig, policy *escalationPolicy, incidentID int64, now time.Time) error {
detail := "escalation exhausted"
if policy.fallbackTopic != "" {
if err := enqueueNotification(ctx, tx, incidentID, nil, policy.fallbackTopic, notifyEscalated); err != nil {
// The address is left out of the detail: it can be a shared secret, and the
// timeline is readable by every API key.
switch recipients := fallbackRecipients(cfg, policy.fallback); {
case len(recipients) > 0:
if err := enqueueNotifications(ctx, tx, incidentID, recipients, notifyEscalated); err != nil {
return err
}
detail += ": paged " + policy.fallbackTopic
} else {
detail += ": no fallback topic configured"
detail += ": paged the team fallback (" + policy.fallback.Type + ")"
case !policy.fallback.IsZero():
detail += ": the team fallback cannot be delivered (" + policy.fallback.Type + " is not configured)"
default:
detail += ": no fallback configured"
}
// Level 0 again, so the sweep stops considering it. The round counter is
@@ -267,6 +274,7 @@ func escalationExhausted(ctx context.Context, tx *sql.Tx, policy *escalationPoli
// phone, not as whoever was paged first.
func pageLevel(ctx context.Context, tx *sql.Tx, cfg NotifyConfig, policy *escalationPolicy, incidentID int64, level escalationLevel) ([]string, error) {
var paged []string
var recipients []recipient
seen := map[int64]bool{}
for _, t := range level.targets {
@@ -286,24 +294,31 @@ func pageLevel(ctx context.Context, tx *sql.Tx, cfg NotifyConfig, policy *escala
}
seen[*userID] = true
var topic *string
var username string
if err := tx.QueryRowContext(ctx,
"SELECT ntfy_topic, username FROM users WHERE id = $1 AND disabled_at IS NULL",
*userID).Scan(&topic, &username); err != nil {
"SELECT username FROM users WHERE id = $1 AND disabled_at IS NULL",
*userID).Scan(&username); err != nil {
// A disabled or deleted account is not an error in the middle of an
// escalation: it is a target that cannot be woken, and the next
// level is the answer to that.
continue
}
if topic == nil || *topic == "" {
continue
}
if err := enqueueNotification(ctx, tx, incidentID, userID, *topic, notifyEscalated); err != nil {
rs, err := userRecipients(ctx, tx, cfg, *userID)
if err != nil {
return nil, err
}
if len(rs) == 0 {
continue
}
recipients = append(recipients, rs...)
paged = append(paged, username)
}
// One call for the whole level, so its rows share a created_at and are
// reminded and resolved as the one batch they are.
if err := enqueueNotifications(ctx, tx, incidentID, recipients, notifyEscalated); err != nil {
return nil, err
}
return paged, nil
}
@@ -332,7 +347,7 @@ func stopEscalation(ctx context.Context, q querier, incidentID int64) error {
}
// handleGetEscalation returns a team's ladder.
func handleGetEscalation(db *sql.DB) http.HandlerFunc {
func handleGetEscalation(db *sql.DB, providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
@@ -347,7 +362,7 @@ func handleGetEscalation(db *sql.DB) http.HandlerFunc {
serverError(w, r, err)
return
}
view, err := escalationStatus(r.Context(), db, teamID, policy)
view, err := escalationStatus(r.Context(), db, providers, teamID, policy)
if err != nil {
serverError(w, r, err)
return
@@ -395,10 +410,10 @@ type escalationLevelView struct {
}
type escalationView struct {
TeamID int64 `json:"team_id"`
RepeatCount int64 `json:"repeat_count"`
FallbackTopic string `json:"fallback_topic"`
Levels []escalationLevelView `json:"levels"`
TeamID int64 `json:"team_id"`
RepeatCount int64 `json:"repeat_count"`
Fallback notify.Target `json:"fallback"`
Levels []escalationLevelView `json:"levels"`
// LastEscalatedAt is when an incident of this team last moved up the ladder,
// or ran off the end of it, and LastEscalatedIncidentID which one. Absent
@@ -410,10 +425,10 @@ type escalationView struct {
// escalationStatus is a team's ladder together with what it would do right now
// and what it has been doing. The resolution follows pageLevel's rules, so the
// page cannot promise a page that the notifier would skip.
func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *escalationPolicy) (escalationView, error) {
func escalationStatus(ctx context.Context, db *sql.DB, providers *notify.Registry, teamID int64, policy *escalationPolicy) (escalationView, error) {
base := escalationResponse(policy, teamID)
out := escalationView{
TeamID: teamID, RepeatCount: base.RepeatCount, FallbackTopic: base.FallbackTopic,
TeamID: teamID, RepeatCount: base.RepeatCount, Fallback: base.Fallback,
Levels: []escalationLevelView{},
}
if !policy.configured() {
@@ -427,7 +442,7 @@ func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *esc
type account struct {
username string
topic bool
channel bool
disabled bool
}
accounts := map[int64]account{}
@@ -436,14 +451,17 @@ func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *esc
return a, nil
}
var a account
var topic *string
var disabledAt *int64
if err := db.QueryRowContext(ctx,
"SELECT username, ntfy_topic, disabled_at FROM users WHERE id = $1", id).
Scan(&a.username, &topic, &disabledAt); err != nil {
"SELECT username, disabled_at FROM users WHERE id = $1", id).
Scan(&a.username, &disabledAt); err != nil {
return a, err
}
a.topic = topic != nil && *topic != ""
reachable, err := hasReachableChannel(ctx, db, providers, id)
if err != nil {
return a, err
}
a.channel = reachable
a.disabled = disabledAt != nil
accounts[id] = a
return a, nil
@@ -497,8 +515,8 @@ func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *esc
view.Problem = "account not found"
case a.disabled:
view.Username, view.Problem = a.username, "account is disabled"
case !a.topic:
view.Username, view.Problem = a.username, "has no ntfy topic"
case !a.channel:
view.Username, view.Problem = a.username, "has no notification channel"
default:
view.Username, view.Reachable = a.username, true
}
@@ -551,10 +569,10 @@ type escalationTargetJSON struct {
}
type escalationJSON struct {
TeamID int64 `json:"team_id"`
RepeatCount int64 `json:"repeat_count"`
FallbackTopic string `json:"fallback_topic"`
Levels []escalationLevelJSON `json:"levels"`
TeamID int64 `json:"team_id"`
RepeatCount int64 `json:"repeat_count"`
Fallback notify.Target `json:"fallback"`
Levels []escalationLevelJSON `json:"levels"`
}
func escalationResponse(p *escalationPolicy, teamID int64) escalationJSON {
@@ -563,7 +581,7 @@ func escalationResponse(p *escalationPolicy, teamID int64) escalationJSON {
return out
}
out.RepeatCount = p.repeatCount
out.FallbackTopic = p.fallbackTopic
out.Fallback = p.fallback
for _, l := range p.levels {
level := escalationLevelJSON{
Position: l.position,
@@ -585,7 +603,7 @@ func escalationResponse(p *escalationPolicy, teamID int64) escalationJSON {
// whole ladder makes the order the client's to decide and the server's to
// store, and makes an edit atomic — there is no moment where level 2 exists
// twice.
func handleSetEscalation(db *sql.DB) http.HandlerFunc {
func handleSetEscalation(db *sql.DB, providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
@@ -604,6 +622,11 @@ func handleSetEscalation(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("repeat_count must be between 0 and 10"))
return
}
fallback, err := validateTarget(providers, req.Fallback)
if err != nil {
respond(w, http.StatusBadRequest, errResp("fallback: "+err.Error()))
return
}
for i, l := range req.Levels {
for j, t := range l.Targets {
if t.Username == "" {
@@ -664,13 +687,14 @@ func handleSetEscalation(db *sql.DB) http.HandlerFunc {
defer tx.Rollback() //nolint:errcheck
if _, err := tx.ExecContext(r.Context(), `
INSERT INTO escalation_policies (team_id, repeat_count, fallback_topic, updated_at)
VALUES ($1, $2, $3, `+nowEpoch+`)
INSERT INTO escalation_policies (team_id, repeat_count, fallback_type, fallback_address, updated_at)
VALUES ($1, $2, $3, $4, `+nowEpoch+`)
ON CONFLICT (team_id) DO UPDATE SET
repeat_count = excluded.repeat_count,
fallback_topic = excluded.fallback_topic,
fallback_type = excluded.fallback_type,
fallback_address = excluded.fallback_address,
updated_at = excluded.updated_at`,
teamID, req.RepeatCount, req.FallbackTopic); err != nil {
teamID, req.RepeatCount, fallback.Type, fallback.Address); err != nil {
serverError(w, r, err)
return
}
+18 -8
View File
@@ -9,8 +9,8 @@ import (
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
// teamUser creates a user in the default team with an ntfy topic, so they can
// actually be paged.
// teamUser creates a user in the default team with an ntfy channel, so they can
// actually be paged. An empty topic leaves them with none.
func teamUser(t *testing.T, s *ts, username, topic string) int64 {
t.Helper()
var user struct {
@@ -21,7 +21,9 @@ func teamUser(t *testing.T, s *ts, username, topic string) int64 {
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
map[string]any{"user_id": user.ID, "role": "member"})
resp.Body.Close()
setTopic(t, s, int(user.ID), topic)
if topic != "" {
addNtfyChannel(t, s, int(user.ID), topic)
}
return user.ID
}
@@ -30,13 +32,21 @@ func teamUser(t *testing.T, s *ts, username, topic string) int64 {
// man's switch tests use on received_at: the sweeper reads a stored timestamp,
// so moving the timestamp is moving the clock.
// fallbackTarget is the JSON for a team's fallback: an ntfy topic, or none.
func fallbackTarget(topic string) map[string]string {
if topic == "" {
return map[string]string{"type": "", "address": ""}
}
return map[string]string{"type": "ntfy", "address": topic}
}
// ladder configures the default team with two levels: the rota first, then a
// named person, then the fallback topic.
func ladder(t *testing.T, s *ts, secondUserID int64, repeat int64, fallback string) {
t.Helper()
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", map[string]any{
"repeat_count": repeat,
"fallback_topic": fallback,
"repeat_count": repeat,
"fallback": fallbackTarget(fallback),
"levels": []map[string]any{
{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "oncall"}}},
{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "user", "user_id": secondUserID}}},
@@ -442,14 +452,14 @@ func TestEscalation_StatusFlagsUnreachableLevels(t *testing.T) {
// Nobody on call today, and the named person loses their topic.
s.exec(t, "DELETE FROM schedule_entries")
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
s.exec(t, "DELETE FROM notification_channels WHERE user_id = $1", silent)
v := readLadder(t, s)
if v.Levels[0].Status != "unreachable" || v.Levels[0].Targets[0].Problem != "nobody is on call today" {
t.Errorf("an empty rota should make level 1 unreachable, got %+v", v.Levels[0])
}
if v.Levels[1].Status != "unreachable" || v.Levels[1].Targets[0].Problem != "has no ntfy topic" {
t.Errorf("a person with no topic should make level 2 unreachable, got %+v", v.Levels[1])
if v.Levels[1].Status != "unreachable" || v.Levels[1].Targets[0].Problem != "has no notification channel" {
t.Errorf("a person with no channel should make level 2 unreachable, got %+v", v.Levels[1])
}
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)
+7
View File
@@ -63,6 +63,13 @@ func isUniqueViolation(err error) bool {
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
}
// isForeignKeyViolation reports whether err is a reference to a row that does not
// exist, which callers turn into 404 rather than 500.
func isForeignKeyViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.ForeignKeyViolation
}
func respond(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
+6 -6
View File
@@ -40,23 +40,23 @@ func TestMembers_StatusReflectsRotaAndPageability(t *testing.T) {
s, _ := notifyTS(t, testNotify()) // admin is on call today, with a topic
teamUser(t, s, "reachable", "terdut-reachable")
silent := teamUser(t, s, "silent", "terdut-silent")
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
s.exec(t, "DELETE FROM notification_channels WHERE user_id = $1", silent)
got := readMembers(t, s)
if m := got["admin"]; m.Status != "oncall" || !m.OnCall || !m.Pageable {
t.Errorf("the person on call should read on call, got %+v", m)
}
if m := got["reachable"]; m.Status != "reachable" || m.OnCall {
t.Errorf("a member with a topic who is off the rota is reachable, got %+v", m)
t.Errorf("a member with a channel who is off the rota is reachable, got %+v", m)
}
if m := got["silent"]; m.Status != "unpageable" || m.Problem != "has no ntfy topic" {
t.Errorf("no topic means they cannot be paged, got %+v", m)
if m := got["silent"]; m.Status != "unpageable" || m.Problem != "has no notification channel" {
t.Errorf("no channel means they cannot be paged, got %+v", m)
}
// Being on call does not rescue an account that cannot be woken.
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE username = 'admin'")
s.exec(t, "DELETE FROM notification_channels WHERE user_id = (SELECT id FROM users WHERE username = 'admin')")
if m := readMembers(t, s)["admin"]; m.Status != "unpageable" || !m.OnCall {
t.Errorf("an on-call person with no topic is the red case, got %+v", m)
t.Errorf("an on-call person with no channel is the red case, got %+v", m)
}
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)
+2 -2
View File
@@ -123,8 +123,8 @@ func AdminOnly(next http.Handler) http.Handler {
}
// requireSelfOrAdmin guards the endpoints that are self-service for your own
// account and administration for anybody else's: your password, your ntfy
// topic, your API keys. Reports whether the request may proceed, and answers it
// account and administration for anybody else's: your password, your notification
// channels, your API keys. Reports whether the request may proceed, and answers it
// if not.
//
// An API key is not an escalation: it carries exactly the rights of the user it
+217 -209
View File
@@ -1,18 +1,16 @@
package api
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
const (
@@ -20,8 +18,8 @@ const (
// 15 minute tick is far too coarse for something that has to wake a person.
notifyInterval = 30 * time.Second
// notifyRetryBase and notifyRetryMax bound the delivery backoff. ntfy being
// briefly unreachable should not lose the page.
// notifyRetryBase and notifyRetryMax bound the delivery backoff. A provider
// being briefly unreachable should not lose the page.
notifyRetryBase = 30 * time.Second
notifyRetryMax = 15 * time.Minute
@@ -48,43 +46,41 @@ const notifierLockKey int64 = 7265_0002
// Notification kinds, recording why a push was sent.
const (
notifyTriggered = "triggered"
notifyReminder = "reminder"
notifyResolved = "resolved"
notifyTriggered = notify.KindTriggered
notifyReminder = notify.KindReminder
notifyResolved = notify.KindResolved
// notifyEscalated is a page that went out because nobody answered the last
// one. Told apart from a reminder because it goes to somebody else.
notifyEscalated = "escalated"
notifyEscalated = notify.KindEscalated
)
// Timeline event types the notifier writes, so an incident's history says who
// was paged and whether the page landed. Written from the delivery result
// rather than at enqueue: a queued notification is an intention, and claiming
// somebody was told before ntfy accepted it would be a lie the timeline keeps.
// somebody was told before the provider accepted it would be a lie the timeline
// keeps.
//
// The topic is deliberately absent from both. It is a shared secret with the
// ntfy server — anyone holding it can publish to it — and the timeline is
// readable by every API key.
// The channel's address is deliberately absent from both; only its type is
// named. An address can be a shared secret (an ntfy topic lets anyone holding it
// publish to it) and the timeline is readable by every API key.
const (
eventNotified = "notified"
eventNotifyFailed = "notify_failed"
)
// NotifyConfig is everything the notifier needs to reach ntfy and to build URLs
// a phone can follow back to this server.
// NotifyConfig is everything the notifier needs to reach people and to build
// URLs they can follow back to this server.
type NotifyConfig struct {
// BaseURL is the ntfy server. Empty disables notifications entirely: no
// goroutine, and nothing is ever enqueued.
BaseURL string
// Providers are the ways this server can deliver a notification. Empty
// disables notifications entirely: no goroutine, and nothing is ever enqueued.
Providers *notify.Registry
// Token is an optional bearer token for an access-controlled ntfy.
Token string
// FallbackTopic receives incidents that open with nobody on call. Those
// notifications carry no Acknowledge button — there is no user to attribute
// the acknowledgement to, and putting one on a shared topic would let any
// subscriber acknowledge as somebody else.
FallbackTopic string
// Fallback receives incidents that open with nobody on call who can be
// reached. Those notifications carry no Acknowledge button — there is no user
// to attribute the acknowledgement to, and putting one on a shared target
// would let any recipient acknowledge as somebody else.
Fallback notify.Target
// PublicURL is the base URL a phone uses to reach this server, for the
// notification's click target and its Acknowledge action. Without it a
@@ -97,11 +93,7 @@ type NotifyConfig struct {
}
// enabled reports whether notifications are configured at all.
func (c NotifyConfig) enabled() bool { return c.BaseURL != "" }
// notifyClient is shared: a page is small and infrequent, and the timeout is
// what keeps a hung ntfy from stalling the delivery pass.
var notifyClient = &http.Client{Timeout: 10 * time.Second}
func (c NotifyConfig) enabled() bool { return !c.Providers.Empty() }
// StartNotifier delivers queued notifications until ctx is cancelled, starting
// with an immediate pass so a restart flushes whatever the last one left behind.
@@ -111,10 +103,14 @@ var notifyClient = &http.Client{Timeout: 10 * time.Second}
// delivers; the rest skip that tick rather than racing the same pass.
func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
if !cfg.enabled() {
log.Print("notifier: disabled (no ntfy URL configured)")
log.Print("notifier: disabled (no notification provider configured)")
return
}
log.Printf("notifier: publishing to %s", cfg.BaseURL)
var types []string
for _, p := range cfg.Providers.List() {
types = append(types, p.Type())
}
log.Printf("notifier: delivering through %s", strings.Join(types, ", "))
ticker := time.NewTicker(notifyInterval)
defer ticker.Stop()
@@ -156,8 +152,13 @@ func NotifySweep(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
// why there is no separate reminder cap.
//
// The previous notification must have actually been sent before another is
// queued, so an ntfy outage produces a retry backlog rather than a reminder
// backlog that all lands at once when it comes back.
// queued, so a provider outage produces a retry backlog rather than a reminder
// backlog that all lands at once when it comes back. "Previous notification"
// is the incident's latest batch: everything one fan-out queued shares a
// created_at, and the batch counts as sent once any of its rows has landed.
//
// A channel the user has since disabled or removed is not reminded: that is the
// off switch for a channel, and a reminder that ignored it would defeat it.
func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
// cfg.RepeatEvery is what the server started with; the settings table is
// what it runs on. Read per tick, so an administrator lengthening the
@@ -168,28 +169,30 @@ func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
}
now := time.Now()
type due struct {
incidentID int64
userID *int64
topic string
}
rows, err := db.QueryContext(ctx, `
SELECT n.incident_id, n.user_id, n.topic
SELECT n.incident_id, n.user_id, n.channel_id, n.channel_type, n.address
FROM notifications n
JOIN incidents i ON i.id = n.incident_id
WHERE n.id = (SELECT MAX(id) FROM notifications WHERE incident_id = n.incident_id)
AND n.sent_at IS NOT NULL
LEFT JOIN notification_channels c ON c.id = n.channel_id
WHERE n.created_at = (SELECT MAX(created_at) FROM notifications WHERE incident_id = n.incident_id)
AND EXISTS (SELECT 1 FROM notifications s
WHERE s.incident_id = n.incident_id
AND s.created_at = n.created_at
AND s.sent_at IS NOT NULL)
AND n.created_at <= $1
AND i.resolved_at IS NULL
AND i.archived_at IS NULL
AND i.status = 'triggered'
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $2)
-- A shared fallback target has no user and no channel row; everything
-- else must still point at an enabled channel.
AND (n.user_id IS NULL OR c.enabled)
-- A team with an escalation ladder gets escalation instead. Both
-- would mean two pages for one silence, which is how people learn to
-- mute a tool.
AND NOT EXISTS (
SELECT 1 FROM escalation_levels el WHERE el.team_id = i.team_id)`,
SELECT 1 FROM escalation_levels el WHERE el.team_id = i.team_id)
ORDER BY n.id`,
now.Add(-repeat).Unix(), now.Unix())
if err != nil {
log.Printf("notifier: find reminders: %v", err)
@@ -199,15 +202,20 @@ func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
// Collected before inserting, rather than written while walking the cursor:
// the inserts below are what this query selects on, and a cursor reading its
// own writes is a hazard whatever the pool size.
var pending []due
byIncident := map[int64][]recipient{}
var order []int64
for rows.Next() {
var d due
if err := rows.Scan(&d.incidentID, &d.userID, &d.topic); err != nil {
var incidentID int64
var r recipient
if err := rows.Scan(&incidentID, &r.userID, &r.channelID, &r.typ, &r.address); err != nil {
rows.Close()
log.Printf("notifier: scan reminder: %v", err)
return
}
pending = append(pending, d)
if _, seen := byIncident[incidentID]; !seen {
order = append(order, incidentID)
}
byIncident[incidentID] = append(byIncident[incidentID], r)
}
if err := rows.Err(); err != nil {
rows.Close()
@@ -216,13 +224,15 @@ func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
}
rows.Close()
for _, d := range pending {
if err := enqueueNotification(ctx, db, d.incidentID, d.userID, d.topic, notifyReminder); err != nil {
log.Printf("notifier: queue reminder for incident %d: %v", d.incidentID, err)
// One batch per incident, so the reminder is itself a batch the next one
// can be measured from.
for _, id := range order {
if err := enqueueNotifications(ctx, db, id, byIncident[id], notifyReminder); err != nil {
log.Printf("notifier: queue reminder for incident %d: %v", id, err)
}
}
if len(pending) > 0 {
log.Printf("notifier: queued %d reminder(s)", len(pending))
if len(order) > 0 {
log.Printf("notifier: queued reminders for %d incident(s)", len(order))
}
}
@@ -231,7 +241,8 @@ type outboxRow struct {
id int64
incidentID int64
userID *int64
topic string
channel string // channel type; the provider that delivers it
address string
kind string
attempts int
}
@@ -258,7 +269,8 @@ func deliverPending(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
}
// Logged, not returned: the page has already gone out, and treating a
// failed timeline write as a failed delivery would send it again.
if err := logEvent(ctx, db, n.incidentID, eventNotified, n.userID, nil, nil, &n.kind); err != nil {
detail := deliveryDetail(n)
if err := logEvent(ctx, db, n.incidentID, eventNotified, n.userID, nil, nil, &detail); err != nil {
log.Printf("notifier: log delivery of %d: %v", n.id, err)
}
sent++
@@ -272,7 +284,7 @@ func deliverPending(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
// caller writes, for the same single-connection reason as staleAlertIDs.
func pendingNotifications(ctx context.Context, db *sql.DB) ([]outboxRow, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, incident_id, user_id, topic, kind, attempts
SELECT id, incident_id, user_id, channel_type, address, kind, attempts
FROM notifications
WHERE sent_at IS NULL
AND send_after <= $1
@@ -287,7 +299,7 @@ func pendingNotifications(ctx context.Context, db *sql.DB) ([]outboxRow, error)
var batch []outboxRow
for rows.Next() {
var n outboxRow
if err := rows.Scan(&n.id, &n.incidentID, &n.userID, &n.topic, &n.kind, &n.attempts); err != nil {
if err := rows.Scan(&n.id, &n.incidentID, &n.userID, &n.channel, &n.address, &n.kind, &n.attempts); err != nil {
return nil, err
}
batch = append(batch, n)
@@ -312,12 +324,18 @@ func markFailed(ctx context.Context, db *sql.DB, n outboxRow, cause error) {
if n.attempts+1 < notifyMaxAttempts {
return
}
detail := fmt.Sprintf("%s: %s", n.kind, cause)
detail := fmt.Sprintf("%s: %s", deliveryDetail(n), cause)
if err := logEvent(ctx, db, n.incidentID, eventNotifyFailed, n.userID, nil, nil, &detail); err != nil {
log.Printf("notifier: log failure of %d: %v", n.id, err)
}
}
// deliveryDetail is what a timeline event says about a delivery: the kind and
// the channel type, "triggered via ntfy". Never the address.
func deliveryDetail(n outboxRow) string {
return n.kind + " via " + n.channel
}
// retryDelay doubles the wait per attempt, up to notifyRetryMax.
func retryDelay(attempts int) time.Duration {
d := notifyRetryBase << attempts
@@ -328,10 +346,17 @@ func retryDelay(attempts int) time.Duration {
}
// deliver renders one notification against the incident's *current* state and
// publishes it. Rendering happens here rather than at enqueue time so a message
// that waited in the queue while its incident escalated goes out at the
// severity the incident has now.
// hands it to the provider its channel names. Rendering happens here rather than
// at enqueue time so a message that waited in the queue while its incident
// escalated goes out at the severity the incident has now.
func deliver(ctx context.Context, db *sql.DB, cfg NotifyConfig, n outboxRow) error {
// Looked up first: a row for a provider this server no longer has can never
// succeed, and there is no point rendering it.
provider, ok := cfg.Providers.Get(n.channel)
if !ok {
return fmt.Errorf("no %q provider is configured", n.channel)
}
inc, err := fetchIncident(ctx, db, n.incidentID)
if err != nil {
return fmt.Errorf("load incident: %w", err)
@@ -354,59 +379,43 @@ func deliver(ctx context.Context, db *sql.DB, cfg NotifyConfig, n outboxRow) err
if n.kind == notifyTriggered {
if sim, err := similarIncidents(ctx, db, n.incidentID, 1); err == nil && len(sim) > 0 && len(sim[0].ResolutionNotes) > 0 {
notes := sim[0].ResolutionNotes
msg.Message += "\nLast time: " + shorten(derefString(notes[len(notes)-1].Detail), 160)
msg.Body += "\nLast time: " + shorten(derefString(notes[len(notes)-1].Detail), 160)
}
}
// An Acknowledge button needs both a user to attribute the acknowledgement
// to and a URL the phone can reach. Minted per delivery, so every push
// carries its own short-lived token rather than reusing one.
// An Acknowledge control needs both a user to attribute the acknowledgement
// to and a URL the recipient can reach.
if n.kind != notifyResolved && n.userID != nil && cfg.PublicURL != "" {
raw, err := issueAckToken(ctx, db, n.incidentID, *n.userID)
if err != nil {
return fmt.Errorf("issue ack token: %w", err)
base := strings.TrimSuffix(cfg.PublicURL, "/")
if provider.Capabilities().ActionButtons {
// Minted per delivery, so every push carries its own short-lived
// token rather than reusing one.
raw, err := issueAckToken(ctx, db, n.incidentID, *n.userID)
if err != nil {
return fmt.Errorf("issue ack token: %w", err)
}
msg.AckURL = base + "/api/notify/ack/" + raw
} else {
// No button to put it on, so the way to acknowledge travels as text.
// The token URL is POST-only and a link to it would do nothing, and
// minting one nobody can use is waste: this is the incident page,
// which has the Acknowledge control.
msg.Body += fmt.Sprintf("\nAcknowledge: %s/incidents/%d", base, n.incidentID)
}
msg.Actions = append(msg.Actions, ntfyAction{
Action: "http",
Label: "Acknowledge",
URL: strings.TrimSuffix(cfg.PublicURL, "/") + "/api/notify/ack/" + raw,
Method: "POST",
Clear: true,
})
}
return publish(ctx, cfg, msg)
return provider.Send(ctx, msg)
}
// ntfyMessage is ntfy's JSON publish format. Using it rather than the X-Actions
// header avoids that header's comma and quote escaping rules, which are easy to
// break with a title that happens to contain a comma.
type ntfyMessage struct {
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Actions []ntfyAction `json:"actions,omitempty"`
}
type ntfyAction struct {
Action string `json:"action"`
Label string `json:"label"`
URL string `json:"url"`
Method string `json:"method,omitempty"`
Clear bool `json:"clear,omitempty"`
}
// renderNotification builds the message body for one queued notification.
func renderNotification(inc models.Incident, n outboxRow, firing int, cfg NotifyConfig) ntfyMessage {
msg := ntfyMessage{Topic: n.topic}
// renderNotification builds the provider-neutral message for one queued
// notification.
func renderNotification(inc models.Incident, n outboxRow, firing int, cfg NotifyConfig) notify.Message {
msg := notify.Message{Address: n.address, Kind: n.kind}
if cfg.PublicURL != "" {
// The web UI's page for the incident, so tapping the notification
// opens something a browser can use.
msg.Click = fmt.Sprintf("%s/incidents/%d",
msg.ClickURL = fmt.Sprintf("%s/incidents/%d",
strings.TrimSuffix(cfg.PublicURL, "/"), inc.ID)
}
@@ -415,10 +424,8 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
switch n.kind {
case notifyResolved:
msg.Title = "Resolved: " + title
msg.Message = "All alerts stopped firing after " +
msg.Body = "All alerts stopped firing after " +
humanDuration(time.Since(inc.TriggeredAt))
msg.Priority = ntfyPriorityLow
msg.Tags = []string{"white_check_mark"}
return msg
case notifyReminder:
@@ -440,9 +447,8 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
parts = append(parts, "open "+humanDuration(time.Since(inc.TriggeredAt)))
}
msg.Message = strings.Join(parts, " · ")
msg.Priority = ntfyPriority(severity)
msg.Tags = []string{severityTag(severity)}
msg.Body = strings.Join(parts, " · ")
msg.Level = severityRank(severity)
return msg
}
@@ -488,137 +494,139 @@ func titleWithoutLabel(title, key, value string) string {
return m[1] + " (" + strings.Join(rest, ", ") + ")"
}
// ntfy's priority scale. Max is the one that overrides the phone's quiet
// settings, which is the whole point of paging on critical.
const (
ntfyPriorityLow = 2
ntfyPriorityDefault = 3
ntfyPriorityHigh = 4
ntfyPriorityMax = 5
)
// ntfyPriority maps an incident's severity onto ntfy's scale, following the
// same ordering severityRank uses. An unrecognised severity gets the default
// rather than being silenced.
func ntfyPriority(severity string) int {
switch severityRank(severity) {
case 4:
return ntfyPriorityMax
case 3:
return ntfyPriorityHigh
case 2:
return ntfyPriorityDefault
case 1:
return ntfyPriorityLow
default:
return ntfyPriorityDefault
}
// recipient is one place a notification goes: a user's channel, or the shared
// fallback target. channelID and userID are nil for the fallback, which belongs
// to nobody. A batch may hold several users (an escalation level with more than
// one target), which is why the user is here and not on the batch.
type recipient struct {
userID *int64
channelID *int64
typ string
address string
}
func severityTag(severity string) string {
switch severityRank(severity) {
case 4:
return "rotating_light"
case 3:
return "red_circle"
case 2:
return "warning"
case 1:
return "information_source"
default:
return "bell"
}
}
// publish POSTs one message to ntfy.
func publish(ctx context.Context, cfg NotifyConfig, msg ntfyMessage) error {
body, err := json.Marshal(msg)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimSuffix(cfg.BaseURL, "/"), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+cfg.Token)
}
resp, err := notifyClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ntfy returned %s", resp.Status)
// enqueueNotifications queues one row per recipient for a single notification.
//
// The rows share one created_at. That is what makes them a batch: the reminder
// and all-clear paths find "who was told last" as the rows carrying an
// incident's newest created_at, so a fan-out to three channels is reminded and
// resolved as one unit rather than as whichever row happened to land last.
func enqueueNotifications(ctx context.Context, q querier, incidentID int64, recipients []recipient, kind string) error {
now := time.Now().Unix()
for _, r := range recipients {
if _, err := q.ExecContext(ctx, `
INSERT INTO notifications (incident_id, user_id, channel_id, channel_type, address, kind, created_at, send_after)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`,
incidentID, r.userID, r.channelID, r.typ, r.address, kind, now); err != nil {
return err
}
}
return nil
}
// enqueueNotification adds one row to the outbox, due immediately.
func enqueueNotification(ctx context.Context, q querier, incidentID int64, userID *int64, topic, kind string) error {
now := time.Now().Unix()
_, err := q.ExecContext(ctx, `
INSERT INTO notifications (incident_id, user_id, topic, kind, created_at, send_after)
VALUES ($1, $2, $3, $4, $5, $6)`, incidentID, userID, topic, kind, now, now)
return err
// userRecipients is the channels a notification to this user should go to: the
// ones they have enabled, of a type this server can deliver. A channel whose
// provider is not configured is skipped rather than queued, since it could only
// fail eight times and then sit in the table.
func userRecipients(ctx context.Context, q querier, cfg NotifyConfig, userID int64) ([]recipient, error) {
uid := userID
rows, err := q.QueryContext(ctx, `
SELECT id, type, address FROM notification_channels
WHERE user_id = $1 AND enabled
ORDER BY id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []recipient
for rows.Next() {
var id int64
var r recipient
if err := rows.Scan(&id, &r.typ, &r.address); err != nil {
return nil, err
}
if !cfg.Providers.Has(r.typ) {
continue
}
r.channelID, r.userID = &id, &uid
out = append(out, r)
}
return out, rows.Err()
}
// notifyTarget decides where a newly opened incident's notification goes.
//
// The on-call user's own topic when they have one, otherwise the fallback
// topic with no user attached. Deliberately not "the fallback topic, attributed
// to the on-call user": the fallback is shared, and an Acknowledge button on a
// shared topic would let any subscriber acknowledge as somebody else.
func notifyTarget(ctx context.Context, q querier, cfg NotifyConfig, onCall *int64) (topic string, userID *int64) {
// The on-call user's own channels when they have any, otherwise the fallback
// target with no user attached. Deliberately not "the fallback, attributed to
// the on-call user": the fallback is shared, and an Acknowledge button on a
// shared target would let any recipient acknowledge as somebody else.
func notifyTarget(ctx context.Context, q querier, cfg NotifyConfig, onCall *int64) []recipient {
if onCall != nil {
var t *string
err := q.QueryRowContext(ctx,
"SELECT ntfy_topic FROM users WHERE id = $1", *onCall).Scan(&t)
if err == nil && t != nil && *t != "" {
return *t, onCall
if rs, err := userRecipients(ctx, q, cfg, *onCall); err == nil && len(rs) > 0 {
return rs
}
}
return cfg.FallbackTopic, nil
return fallbackRecipients(cfg, cfg.Fallback)
}
// fallbackRecipients turns a fallback target into the zero or one recipients it
// stands for. A target whose provider this server does not have is nobody.
func fallbackRecipients(cfg NotifyConfig, t notify.Target) []recipient {
if t.Address == "" || !cfg.Providers.Has(t.Type) {
return nil
}
return []recipient{{typ: t.Type, address: t.Address}}
}
// enqueueOpened queues the notification for a freshly opened incident. It is the
// only enqueue point that has to resolve a topic from scratch; every later
// only enqueue point that has to resolve recipients from scratch; every later
// notification for the incident reuses what this one chose.
func enqueueOpened(ctx context.Context, q querier, cfg NotifyConfig, incidentID int64, onCall *int64) error {
if !cfg.enabled() {
return nil
}
topic, userID := notifyTarget(ctx, q, cfg, onCall)
if topic == "" {
// Nobody on call has a topic and there is no fallback: there is nowhere
recipients := notifyTarget(ctx, q, cfg, onCall)
if len(recipients) == 0 {
// Nobody on call has a channel and there is no fallback: there is nowhere
// to send this, and queueing it would only accumulate undeliverable rows.
return nil
}
return enqueueNotification(ctx, q, incidentID, userID, topic, notifyTriggered)
return enqueueNotifications(ctx, q, incidentID, recipients, notifyTriggered)
}
// enqueueResolved queues the all-clear, reusing the topic the incident's last
// notification went to. That needs no configuration to reach this function, and
// it gives the right rule for free: you only hear that something resolved if you
// were told it started.
// enqueueResolved queues the all-clear, reusing the recipients of the incident's
// last batch. That needs no configuration to reach this function, and it gives
// the right rule for free: you only hear that something resolved if you were
// told it started. A channel since disabled or removed is not told.
func enqueueResolved(ctx context.Context, q querier, incidentID int64) error {
var topic string
var userID *int64
err := q.QueryRowContext(ctx, `
SELECT topic, user_id FROM notifications
WHERE incident_id = $1 ORDER BY id DESC LIMIT 1`, incidentID).Scan(&topic, &userID)
if err == sql.ErrNoRows {
return nil
}
rows, err := q.QueryContext(ctx, `
SELECT n.user_id, n.channel_id, n.channel_type, n.address
FROM notifications n
LEFT JOIN notification_channels c ON c.id = n.channel_id
WHERE n.incident_id = $1
AND n.created_at = (SELECT MAX(created_at) FROM notifications WHERE incident_id = $1)
AND (n.user_id IS NULL OR c.enabled)
ORDER BY n.id`, incidentID)
if err != nil {
return err
}
return enqueueNotification(ctx, q, incidentID, userID, topic, notifyResolved)
var recipients []recipient
for rows.Next() {
var r recipient
if err := rows.Scan(&r.userID, &r.channelID, &r.typ, &r.address); err != nil {
rows.Close()
return err
}
recipients = append(recipients, r)
}
err = rows.Err()
rows.Close()
if err != nil || len(recipients) == 0 {
return err
}
return enqueueNotifications(ctx, q, incidentID, recipients, notifyResolved)
}
// humanDuration renders an age the way a person reads it at 3am: coarse, and
+1 -1
View File
@@ -70,7 +70,7 @@ func handleNotifyAck(db *sql.DB) http.HandlerFunc {
// already acknowledged (e.g. from the web UI, or an earlier tap of
// the same button) — either way nothing the responder did wrong, so
// report the actual state rather than assuming "resolved", and let
// ntfy show a success toast rather than a failure.
// the provider show a success toast rather than a failure.
inc, err := fetchIncident(r.Context(), db, incidentID)
if err != nil {
serverError(w, r, err)
+34 -21
View File
@@ -13,6 +13,7 @@ import (
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
// ---------------------------------------------------------------------------
@@ -91,6 +92,15 @@ func (f *fakeNtfy) forget() {
f.got = nil
}
// providers registers the fake as the install's one ntfy provider.
func (f *fakeNtfy) providers() *notify.Registry {
return notify.NewRegistry(notify.NewNtfy(f.URL, ""))
}
func ntfyTarget(topic string) notify.Target {
return notify.Target{Type: notify.TypeNtfy, Address: topic}
}
func (f *fakeNtfy) failWith(status int) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -106,11 +116,11 @@ func (f *fakeNtfy) failWith(status int) {
func notifyTS(t *testing.T, cfg api.NotifyConfig) (*ts, *fakeNtfy) {
t.Helper()
f := newFakeNtfy(t)
cfg.BaseURL = f.URL
cfg.Providers = f.providers()
s := newTS(t, cfg)
putOnCall(t, s, 1)
setTopic(t, s, 1, "terdut-admin")
addNtfyChannel(t, s, 1, "terdut-admin")
return s, f
}
@@ -125,13 +135,14 @@ func putOnCall(t *testing.T, s *ts, userID int) {
}
}
func setTopic(t *testing.T, s *ts, userID int, topic string) {
// addNtfyChannel gives a user an ntfy channel, the way the Account page does.
func addNtfyChannel(t *testing.T, s *ts, userID int, topic string) {
t.Helper()
resp := s.req(t, http.MethodPut,
fmt.Sprintf("/api/users/%d/notify", userID), map[string]any{"ntfy_topic": topic})
resp := s.req(t, http.MethodPost, fmt.Sprintf("/api/users/%d/channels", userID),
map[string]any{"type": "ntfy", "address": topic})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("set notify topic returned %d", resp.StatusCode)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("add notification channel returned %d", resp.StatusCode)
}
}
@@ -339,8 +350,8 @@ func TestNotify_DeliveryIsRecordedOnTheTimeline(t *testing.T) {
if e["type"] != "notified" {
t.Errorf("expected a notified event, got %v", e["type"])
}
if e["detail"] != "triggered" {
t.Errorf("expected the kind in detail, got %v", e["detail"])
if e["detail"] != "triggered via ntfy" {
t.Errorf("expected the kind and the channel type in detail, got %v", e["detail"])
}
if e["username"] != "admin" {
t.Errorf("expected the paged user attached, got %v", e["username"])
@@ -370,7 +381,7 @@ func TestNotify_TimelineRecordsOneEventPerDelivery(t *testing.T) {
if len(events) != 2 {
t.Fatalf("expected one event per delivery, got %v", events)
}
if events[0]["detail"] != "triggered" || events[1]["detail"] != "reminder" {
if events[0]["detail"] != "triggered via ntfy" || events[1]["detail"] != "reminder via ntfy" {
t.Errorf("expected triggered then reminder, got %v and %v",
events[0]["detail"], events[1]["detail"])
}
@@ -392,7 +403,7 @@ func TestNotify_AllClearIsRecordedOnTheTimeline(t *testing.T) {
if len(events) != 2 {
t.Fatalf("expected the all-clear recorded, got %v", events)
}
if events[1]["detail"] != "resolved" {
if events[1]["detail"] != "resolved via ntfy" {
t.Errorf("expected a resolved event, got %v", events[1]["detail"])
}
}
@@ -402,9 +413,9 @@ func TestNotify_AllClearIsRecordedOnTheTimeline(t *testing.T) {
func TestNotify_FallbackDeliveryHasNoUser(t *testing.T) {
f := newFakeNtfy(t)
s := newTS(t, api.NotifyConfig{
BaseURL: f.URL,
FallbackTopic: "terdut-oncall",
PublicURL: "https://terdut.example.com",
Providers: f.providers(),
Fallback: ntfyTarget("terdut-oncall"),
PublicURL: "https://terdut.example.com",
})
fireCritical(t, s)
@@ -441,7 +452,7 @@ func TestNotify_ExhaustedRetriesAreRecordedOnce(t *testing.T) {
t.Errorf("expected notify_failed, got %v", events[0]["type"])
}
detail, _ := events[0]["detail"].(string)
if !strings.HasPrefix(detail, "triggered: ") || !strings.Contains(detail, "500") {
if !strings.HasPrefix(detail, "triggered via ntfy: ") || !strings.Contains(detail, "500") {
t.Errorf("expected the kind and the reason in %q", detail)
}
}
@@ -743,12 +754,12 @@ func TestNotify_ManualResolveSendsNothing(t *testing.T) {
// With nobody on call the page goes to the shared fallback, and carries no
// Acknowledge button — there is no user to attribute the acknowledgement to.
func TestNotify_FallbackTopicHasNoAckButton(t *testing.T) {
func TestNotify_FallbackHasNoAckButton(t *testing.T) {
f := newFakeNtfy(t)
s := newTS(t, api.NotifyConfig{
BaseURL: f.URL,
FallbackTopic: "terdut-oncall",
PublicURL: "https://terdut.example.com",
Providers: f.providers(),
Fallback: ntfyTarget("terdut-oncall"),
PublicURL: "https://terdut.example.com",
})
fireCritical(t, s)
@@ -770,7 +781,7 @@ func TestNotify_FallbackTopicHasNoAckButton(t *testing.T) {
// only pile up rows that can never be delivered.
func TestNotify_NoTargetQueuesNothing(t *testing.T) {
f := newFakeNtfy(t)
s := newTS(t, api.NotifyConfig{BaseURL: f.URL})
s := newTS(t, api.NotifyConfig{Providers: f.providers()})
fireCritical(t, s)
@@ -787,7 +798,9 @@ func TestNotify_NoTargetQueuesNothing(t *testing.T) {
func TestNotify_DisabledQueuesNothing(t *testing.T) {
s := newTS(t)
putOnCall(t, s, 1)
setTopic(t, s, 1, "terdut-admin")
// Inserted rather than added through the API, which refuses a type this
// server has no provider for.
s.exec(t, "INSERT INTO notification_channels (user_id, type, address) VALUES (1, 'ntfy', 'terdut-admin')")
fireCritical(t, s)
+14 -8
View File
@@ -95,9 +95,9 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
r.Post("/api/oidc/device/deny", handleDeviceDecision(db, false))
}
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
// Proves the topic works, which is the only part of "notifications are
// set up" that the person holding the phone can confirm.
r.Post("/api/me/notify/test", handleTestNotification(notify, db))
// Which kinds of notification channel this server can deliver, so the
// UI offers only those.
r.Get("/api/notify/providers", handleListProviders(notify.Providers))
// Readable by anyone signed in: the queue's assignment control and the
// on-call schedule both need to name people.
@@ -107,7 +107,13 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
// which rule applies depends on the {id} in the path.
r.Get("/api/users/{id}/teams", handleUserTeams(db))
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
r.Get("/api/users/{id}/channels", handleListChannels(db))
r.Post("/api/users/{id}/channels", handleCreateChannel(db, notify.Providers))
r.Patch("/api/users/{id}/channels/{channelID}", handleUpdateChannel(db, notify.Providers))
r.Delete("/api/users/{id}/channels/{channelID}", handleDeleteChannel(db))
// Proves the channel works, which is the only part of "notifications are
// set up" that the person holding the phone can confirm.
r.Post("/api/users/{id}/channels/{channelID}/test", handleTestChannel(db, notify))
r.Put("/api/users/{id}/password", handleSetPassword(db))
r.Get("/api/users/{id}/api-keys", handleListAPIKeys(db))
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
@@ -130,7 +136,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
// an administrator from outside; this is a different
// question, so it is a different endpoint.
r.Get("/api/admin/teams/{teamID}", handleAdminGetTeam(db))
r.Get("/api/admin/settings", handleGetSettings(db, cfg))
r.Get("/api/admin/settings", handleGetSettings(db, cfg, notify.Providers))
r.Put("/api/admin/settings", handleSetSettings(db))
})
@@ -175,7 +181,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
r.With(opMode).Post("/api/teams", handleCreateTeam(db))
r.With(opMode).Put("/api/teams/{teamID}", handleRenameTeam(db))
r.With(opMode).Delete("/api/teams/{teamID}", handleDeleteTeam(db))
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db, notify.Providers))
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
@@ -192,8 +198,8 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
// A team's escalation ladder: who is paged when nobody answers.
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
r.With(opMode).Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db, notify.Providers))
r.With(opMode).Put("/api/teams/{teamID}/escalation", handleSetEscalation(db, notify.Providers))
// A team's own dead man's switches: which of its alerts are heartbeats,
// and how long a silence has to last before somebody is paged.
+1 -1
View File
@@ -141,7 +141,7 @@ func TestServiceAccount_TeamScopeIsBoundToItsOwnTeam(t *testing.T) {
teamB := createTeamAs(t, s, instanceKey, "team-b")
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
policy := map[string]any{"repeat_count": 0, "fallback_topic": "", "levels": []any{}}
policy := map[string]any{"repeat_count": 0, "fallback": map[string]string{"type": "", "address": ""}, "levels": []any{}}
resp := s.reqAs(t, keyA, http.MethodPut, "/api/teams/"+id64(teamA)+"/escalation", policy)
if resp.StatusCode != http.StatusOK {
+8 -6
View File
@@ -11,6 +11,7 @@ import (
"git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
"github.com/go-chi/chi/v5"
)
@@ -111,7 +112,7 @@ type settingValue struct {
MaxSeconds int64 `json:"max_seconds"`
}
func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
func handleGetSettings(db *sql.DB, cfg config.Config, providers *notify.Registry) http.HandlerFunc {
settings := NewSettings(db)
return func(w http.ResponseWriter, r *http.Request) {
out := settingsResponse{
@@ -128,11 +129,12 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
// Never the ntfy token or the DSN: both are credentials, and an
// admin page that renders them turns a browser tab into a place
// they leak from.
"ntfy_url": cfg.NtfyURL,
"ntfy_configured": strconv.FormatBool(cfg.NtfyURL != ""),
"ntfy_token_set": strconv.FormatBool(cfg.NtfyToken != ""),
"public_url": cfg.PublicURL,
"listen_address": cfg.Addr,
"notification_providers": strings.Join(providerTypes(providers), ", "),
"ntfy_url": cfg.NtfyURL,
"ntfy_configured": strconv.FormatBool(cfg.NtfyURL != ""),
"ntfy_token_set": strconv.FormatBool(cfg.NtfyToken != ""),
"public_url": cfg.PublicURL,
"listen_address": cfg.Addr,
},
}
for key, b := range settingBounds {
-47
View File
@@ -427,53 +427,6 @@ func handleRevokeInvite(db *sql.DB) http.HandlerFunc {
// Onboarding
// ---------------------------------------------------------------------------
// handleTestNotification publishes one push to the caller's own topic.
//
// The point of the first-run checklist's notification step is not that a topic
// string has been typed but that a phone buzzes, and only the person holding it
// can tell whether it did. Published directly rather than through the outbox:
// the outbox row requires an incident, and this deliberately belongs to no
// incident.
func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.BaseURL == "" {
respond(w, http.StatusServiceUnavailable,
errResp("this server has no ntfy configured, so it can send nothing"))
return
}
caller, ok := userFromContext(r.Context())
if !ok {
respond(w, http.StatusForbidden, errResp("this endpoint is for human accounts only"))
return
}
var topic *string
if err := db.QueryRowContext(r.Context(),
"SELECT ntfy_topic FROM users WHERE id = $1", caller.ID).Scan(&topic); err != nil {
serverError(w, r, err)
return
}
if topic == nil || *topic == "" {
respond(w, http.StatusBadRequest, errResp("set a notification topic first"))
return
}
if err := publish(r.Context(), cfg, ntfyMessage{
Topic: *topic,
Title: "terdut test",
Message: "If this arrived, your notifications work.",
Tags: []string{"white_check_mark"},
}); err != nil {
// The failure is the useful part here: a wrong topic, a token the
// ntfy server rejects, or an ntfy that is down all look the same
// from the phone, which is silence.
respond(w, http.StatusBadGateway, errResp("ntfy rejected the test: "+err.Error()))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleDismissOnboarding hides the first-run checklist, or brings it back.
// Stored per user rather than in the browser: somebody who finishes setting up
// on a laptop should not be nagged again on their phone.
+8 -7
View File
@@ -10,6 +10,7 @@ import (
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
// signup posts to the unauthenticated sign-up endpoint, the way the form does,
@@ -301,18 +302,18 @@ func TestSignup_InviteCreatedByAServiceAccountLeavesCreatedByNull(t *testing.T)
}
// Neither of these has a real user_id to act on behalf of; both must 403 a
// service account explicitly rather than 500 (handleTestNotification, which
// used to query ntfy_topic for user id 0) or silently no-op (handleDismissOnboarding,
// service account explicitly rather than 500 (handleTestChannel, which once
// queried the notification target of user id 0) or silently no-op (handleDismissOnboarding,
// which used to UPDATE ... WHERE id = 0, affecting nothing and still
// returning 204).
func TestServiceAccount_HumanOnlyEndpointsRefuseExplicitly(t *testing.T) {
// BaseURL set (even to a fake, unreachable address) so handleTestNotification
// reaches its AsHuman() check instead of short-circuiting on "ntfy not
// configured" first — this test is about the human-only check, not ntfy.
s := newTS(t, api.NotifyConfig{BaseURL: "http://ntfy.invalid"})
// A provider set (even one pointing at an unreachable address) so the test
// endpoint reaches its owner check instead of short-circuiting on "no
// provider configured" first — this test is about the human-only check.
s := newTS(t, api.NotifyConfig{Providers: notify.NewRegistry(notify.NewNtfy("http://ntfy.invalid", ""))})
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
if resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/me/notify/test", nil); resp.StatusCode != http.StatusForbidden {
if resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/users/1/channels/1/test", nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("expected 403 for a service account testing notifications, got %d", resp.StatusCode)
} else {
resp.Body.Close()
+12 -9
View File
@@ -10,6 +10,7 @@ import (
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
"github.com/go-chi/chi/v5"
)
@@ -283,8 +284,9 @@ type memberStatus struct {
// NextShift is the first day after today the rota has them (YYYY-MM-DD).
NextShift *string `json:"next_shift,omitempty"`
// Pageable is whether they have an ntfy topic and an enabled account — the
// conditions pageLevel and the notifier skip on. Never the topic itself.
// Pageable is whether they have an enabled channel of a type this server can
// deliver, and an enabled account — the conditions pageLevel and the notifier
// skip on. Never the channel's address.
Pageable bool `json:"pageable"`
Problem string `json:"problem,omitempty"`
@@ -294,7 +296,7 @@ type memberStatus struct {
// handleListTeamMembers names everybody in a team, with their status. Visible to
// any member: you can see who else is on the rota you are on.
func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
func handleListTeamMembers(db *sql.DB, providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
@@ -306,7 +308,8 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
rows, err := db.QueryContext(r.Context(), `
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source,
u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '',
EXISTS (SELECT 1 FROM notification_channels c
WHERE c.user_id = u.id AND c.enabled AND c.type = ANY($3::text[])),
u.disabled_at IS NOT NULL,
GREATEST(
COALESCE((SELECT MAX(last_seen_at) FROM sessions WHERE user_id = u.id), 0),
@@ -318,7 +321,7 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
FROM team_members m
JOIN users u ON u.id = m.user_id
WHERE m.team_id = $1
ORDER BY u.username`, teamID, todayUTC())
ORDER BY u.username`, teamID, todayUTC(), providerTypes(providers))
if err != nil {
serverError(w, r, err)
return
@@ -329,9 +332,9 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var m memberStatus
var joined, lastActive int64
var hasTopic, disabled bool
var hasChannel, disabled bool
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source,
&hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
&hasChannel, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
serverError(w, r, err)
return
}
@@ -343,8 +346,8 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
switch {
case disabled:
m.Problem = "account is disabled"
case !hasTopic:
m.Problem = "has no ntfy topic"
case !hasChannel:
m.Problem = "has no notification channel"
}
m.Pageable = m.Problem == ""
switch {
+34 -7
View File
@@ -459,18 +459,38 @@ func TestTeams_OutsiderSeesNothing(t *testing.T) {
// ---------------------------------------------------------------------------
// Everybody signed in can list users to name them, but only an admin (or the
// row's owner) sees an email or an ntfy topic, which is a publish secret.
func TestListUsers_RedactsEmailAndTopicForOthers(t *testing.T) {
// row's owner) sees an email, and only the owner sees a channel's address, which
// can be a publish secret. An admin sees that a channel exists, not where it goes.
func TestListUsers_RedactsEmailAndChannelsForOthers(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
_ = newTeam(t, s, "blue")
s.exec(t, "UPDATE users SET ntfy_topic = 'secret-topic'")
s.exec(t, `INSERT INTO notification_channels (user_id, type, address)
SELECT id, 'ntfy', 'secret-topic' FROM users`)
channelAddresses := func(u map[string]any) []string {
var out []string
list, _ := u["channels"].([]any)
for _, c := range list {
addr, _ := c.(map[string]any)["address"].(string) // omitted when blank
out = append(out, addr)
}
return out
}
var asAdmin []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/users", nil), &asAdmin)
for _, u := range asAdmin {
if u["email"] == "" || u["ntfy_topic"] != "secret-topic" {
t.Errorf("admin should see everything, got %v", u)
if u["email"] == "" {
t.Errorf("admin should see every email, got %v", u)
}
addrs := channelAddresses(u)
if len(addrs) != 1 {
t.Errorf("admin should see that every user has a channel, got %v", u)
continue
}
if own := u["username"] == "admin"; own != (addrs[0] != "") {
t.Errorf("admin should see only their own channel's address, got %v", u)
}
}
@@ -480,8 +500,15 @@ func TestListUsers_RedactsEmailAndTopicForOthers(t *testing.T) {
}
for _, u := range asMember {
own := u["username"] == "red-user"
if own != (u["email"] != "") || own != (u["ntfy_topic"] != nil) {
t.Errorf("only red-user's own row should keep email and topic, got %v", u)
if own != (u["email"] != "") {
t.Errorf("only red-user's own row should keep its email, got %v", u)
}
addrs := channelAddresses(u)
if own && (len(addrs) != 1 || addrs[0] != "secret-topic") {
t.Errorf("red-user should see their own channel and address, got %v", u)
}
if !own && len(addrs) != 0 {
t.Errorf("a member must not see anybody else's channels, got %v", u)
}
}
}
+22 -56
View File
@@ -8,7 +8,6 @@ import (
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
@@ -109,15 +108,16 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
// handleListUsers is readable by anyone signed in, because the assignment
// control and the schedule need to name people. What it returns about other
// people is therefore only what naming them takes: email and ntfy_topic are
// blanked unless the caller is an admin or the row is their own. The topic in
// particular is a publish secret.
// people is therefore only what naming them takes: email is blanked unless the
// caller is an admin or the row is their own, and channels are listed only for
// the caller themselves (with addresses) and for an admin (without, since an
// address can be a secret).
func handleListUsers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller, _ := userFromContext(r.Context())
seeAll := caller.IsAdmin
rows, err := db.QueryContext(r.Context(),
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users ORDER BY id")
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users ORDER BY id")
if err != nil {
serverError(w, r, err)
return
@@ -129,7 +129,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
var u models.User
var ts int64
var disabled *int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
serverError(w, r, err)
return
}
@@ -137,7 +137,6 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
u.DisabledAt = unixPtr(disabled)
if !seeAll && u.ID != caller.ID {
u.Email = ""
u.NtfyTopic = nil
}
users = append(users, u)
}
@@ -145,6 +144,20 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
serverError(w, r, err)
return
}
rows.Close()
// After the user cursor is closed: the pool may be a single connection.
for i := range users {
if !seeAll && users[i].ID != caller.ID {
continue
}
channels, err := loadChannels(r.Context(), db, users[i].ID, users[i].ID == caller.ID)
if err != nil {
serverError(w, r, err)
return
}
users[i].Channels = channels
}
respond(w, http.StatusOK, users)
}
}
@@ -180,53 +193,6 @@ func handleCreateUser(db *sql.DB) http.HandlerFunc {
}
}
// handleSetNotifyTarget points a user's push notifications at an ntfy topic, or
// clears it with an empty string. The topic is a shared secret with the ntfy
// server — anyone who knows it can publish to it — so pick an unguessable one
// unless your ntfy enforces access control.
func handleSetNotifyTarget(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, id) {
return
}
var req struct {
NtfyTopic string `json:"ntfy_topic"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
var topic *string
if t := strings.TrimSpace(req.NtfyTopic); t != "" {
topic = &t
}
res, err := db.ExecContext(r.Context(),
"UPDATE users SET ntfy_topic = $1 WHERE id = $2", topic, id)
if err != nil {
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
user, err := fetchUser(r.Context(), db, id)
if err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, user)
}
}
func handleDeleteUser(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
@@ -431,8 +397,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var ts int64
var disabled *int64
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled)
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled)
if err != nil {
return u, err
}