Files
meshcore-analyzer/internal/users/notify.go
T
efitenandClaude Opus 5.5 0737f10a5d feat(notify): optional external node alerts feed and the node.external event (#2177) (#2178)
Closes #2177.

## What

Node notifications can mail alerts computed outside CoreScope. With
`userManagement.notifications.externalAlerts` configured, users can
choose a new event, `node.external`, which is off by default and only
offered when configured:

```json
"notifications": {
  "enabled": true,
  "externalAlerts": { "url": "http://coverage-job/alerts.json", "maxAgeHours": 48, "label": "Coverage" }
}
```

The feed:

```json
{ "generatedAt": "2026-10-11T01:35:12Z",
  "alerts": [ { "pubkey": "<64 hex>", "key": "SE", "text": "never heard to the south-east", "url": "https://example.org/coverage" } ] }
```

Mail lines: `<name>: Coverage: never heard to the south-east, <time>`
when an alert appears, `<name>: Coverage resolved (SE), <time>` when it
is gone from a fresh feed.

## How

- `cmd/server/notify_external.go`: feed types, `parseExternalFeed` (RFC
3339 `generatedAt`, age check, per-alert validation),
`fetchExternalFeed` (200 only, 1 MB cap, 10 s timeout).
- `notify_eval.go`: `evalExternal`. Subjects are `<pubkey>/<key>`; a
`<pubkey>/*` row is the per-node baseline, so the first fresh feed after
watching stores the current alerts without a mail (like `foreign.new`),
and later alerts mail at once. `External == nil` (not configured, nobody
chose it, or stale) leaves every `node.external` state as it is.
- `notify_service.go`: the feed is fetched only when configured and at
least one user chose the event. Each new reason the feed is unusable is
logged once, and so is the recovery. Mail text and link: the alert's
`url`, otherwise the node page.
- `notify_handlers.go`: `availableEvents` and the new `externalLabel`
include the event only when configured; PUT answers 400 for
`node.external` otherwise.
- `internal/users/notify.go`: event constant, `OptionalNodeNotifyEvents`
(canonical order: node, optional, admin), `RemoveWatch` also deletes the
node's `<pubkey>/...` rows.
- Account page label, OpenAPI descriptions, `config.example.json`
comment, `docs/user-guide/accounts.md` section.

Decisions on the three open questions in #2177: the generic name
`node.external`; the resolved mail names the key instead of repeating
the text, so there is **no `users.db` schema change** (a bump would also
make older binaries refuse the file); one feed.

## Invariants

- Read/write separation (#1283): the server only reads the feed over
HTTP. State rows go to `users.db` through `internal/users`, like the
other events.
- A dead, garbled or old feed never mails "resolved":
`TestEvaluateExternalStaleFeedChangesNothing` and
`TestNotifierMailsExternalAlertsAndSkipsAStaleFeed` (HTTP 502 and a 72 h
old feed in a row, then a fresh empty feed resolves).

## Performance

The evaluator adds one pass over the stored states (already read every
tick) plus, per user with the event on and per watched node, the node's
current alerts. Bounded by `maxWatchesPerUser` (50) times the alerts per
node. The feed is fetched once per tick (default 5 minutes) only when
someone chose the event, capped at 1 MB. No packet-store lock is taken
for it. Nothing changes for instances without `externalAlerts`.

## Tests

- `cmd/server`: `go test` green locally (Windows;
`TestSaveGeoFilterPreservesFileMode` skipped as it needs Unix mode
bits). New: `notify_external_test.go` (feed parsing and limits, HTTP
fetch 200/404/oversized, config resolution, evaluator
baseline/new/resolved/returning/stale/not chosen/notifications off,
notifier end to end with a fake feed, account routes).
- `internal/users`: green, 2 new tests (optional event and canonical
order, `RemoveWatch` deletes external subjects only for that node).
- Frontend: `test-all.sh` green, 1 new test in
`test-notifications-ui.js` (label from `externalLabel`, escaped).
- `scripts/check-xss-sinks.sh --file public/notifications.js`: clean.

## Not done

- No E2E test: the account page change is one label, covered by the unit
test.
- Not run against a live instance yet. The first feed in use is the
coverage job of on8ar.eu (`alerts.json`, 29 alerts at the moment).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:54:35 +02:00

469 lines
14 KiB
Go

package users
import (
"database/sql"
"errors"
"strings"
"time"
)
// Node notifications (docs/specs/2026-10-07-node-notifications-design.md):
// per-user preferences, the watch list, and the last state per (user, event,
// subject) that the server's evaluator compares against.
// Event types.
const (
NotifyNodeOffline = "node.offline"
NotifyNodeBattery = "node.battery"
NotifyNodeExternal = "node.external" // off by default; needs notifications.externalAlerts
NotifyForeignNew = "foreign.new" // admins only
NotifyObserverOffline = "observer.offline" // admins only
)
// Subject states.
const (
NotifyGood = "good"
NotifyBad = "bad"
NotifyTold = "told" // foreign.new: the admin was told about this node
)
// NotifyMailPurpose labels notification mails in mail_log; the daily limits
// count it.
const NotifyMailPurpose = "notify"
var (
// NodeNotifyEvents are open to every user and chosen by default.
NodeNotifyEvents = []string{NotifyNodeOffline, NotifyNodeBattery}
// OptionalNodeNotifyEvents are open to every user (when the instance
// configures them) and off by default.
OptionalNodeNotifyEvents = []string{NotifyNodeExternal}
// AdminNotifyEvents are open to admins only and off by default.
AdminNotifyEvents = []string{NotifyForeignNew, NotifyObserverOffline}
)
// ErrWatchLimit: the user already watches the maximum number of nodes.
var ErrWatchLimit = errors.New("users: watch limit reached")
// IsAdminNotifyEvent reports whether only admins may choose e.
func IsAdminNotifyEvent(e string) bool { return e == NotifyForeignNew || e == NotifyObserverOffline }
// ValidNotifyEvent reports whether e is a known event type.
func ValidNotifyEvent(e string) bool {
return e == NotifyNodeOffline || e == NotifyNodeBattery || e == NotifyNodeExternal || IsAdminNotifyEvent(e)
}
// NotifyPrefs is one user's notification preferences.
type NotifyPrefs struct {
UserID int64
Enabled bool
Events []string // chosen events, canonical order, no duplicates
UnsubToken string // random, only turns notifications off
UpdatedAt time.Time
}
// DefaultNotifyPrefs is what a user without a preferences row gets:
// notifications on, the node events chosen, no unsubscribe token yet.
// NotifyPrefsFor stores these values when it creates the row.
func DefaultNotifyPrefs(userID int64) NotifyPrefs {
return NotifyPrefs{UserID: userID, Enabled: true, Events: append([]string(nil), NodeNotifyEvents...)}
}
// Has reports whether the user chose event.
func (p NotifyPrefs) Has(event string) bool {
for _, e := range p.Events {
if e == event {
return true
}
}
return false
}
// NotifyWatch is one watched node of one user.
type NotifyWatch struct {
UserID int64
Pubkey string // lowercase hex
CreatedAt time.Time
}
// NotifyKey identifies one evaluated subject of one user.
type NotifyKey struct {
UserID int64
Event string
Subject string // node pubkey, observer id, or "*" (foreign.new baseline)
}
// NotifyState is the last evaluated state of a NotifyKey.
type NotifyState struct {
NotifyKey
State string // NotifyGood, NotifyBad or NotifyTold
ChangedAt time.Time
}
// canonicalEvents keeps the known events of list in canonical order, once each.
func canonicalEvents(list []string) []string {
want := make(map[string]bool, len(list))
for _, e := range list {
want[e] = true
}
out := []string{}
for _, group := range [][]string{NodeNotifyEvents, OptionalNodeNotifyEvents, AdminNotifyEvents} {
for _, e := range group {
if want[e] {
out = append(out, e)
}
}
}
return out
}
func placeholders(n int) string { return strings.TrimSuffix(strings.Repeat("?,", n), ",") }
const prefsCols = `user_id, enabled, events, unsub_token, updated_at`
func scanPrefs(row rowScanner) (*NotifyPrefs, error) {
var p NotifyPrefs
var enabled int
var events string
var updated int64
if err := row.Scan(&p.UserID, &enabled, &events, &p.UnsubToken, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
p.Enabled = enabled != 0
p.Events = canonicalEvents(strings.Split(events, ","))
p.UpdatedAt = fromUnix(updated)
return &p, nil
}
func (s *Store) getPrefs(userID int64) (*NotifyPrefs, error) {
return scanPrefs(s.db.QueryRow(`SELECT `+prefsCols+` FROM notification_prefs WHERE user_id = ?`, userID))
}
// NotifyPrefsFor returns the user's preferences, creating the default row
// (on, node events, a new unsubscribe token) on first use.
func (s *Store) NotifyPrefsFor(userID int64) (NotifyPrefs, error) {
if p, err := s.getPrefs(userID); !errors.Is(err, ErrNotFound) {
if err != nil {
return NotifyPrefs{}, err
}
return *p, nil
}
tok, _, err := NewToken()
if err != nil {
return NotifyPrefs{}, err
}
d := DefaultNotifyPrefs(userID)
if _, err := s.db.Exec(`INSERT OR IGNORE INTO notification_prefs (user_id, enabled, events, unsub_token, updated_at)
VALUES (?, ?, ?, ?, ?)`, userID, d.Enabled, strings.Join(d.Events, ","), tok, unix(s.now())); err != nil {
return NotifyPrefs{}, err
}
p, err := s.getPrefs(userID)
if err != nil {
return NotifyPrefs{}, err
}
return *p, nil
}
// StoredNotifyPrefs returns the user's stored preferences, or nil when
// none were ever created. Unlike NotifyPrefsFor it never writes.
func (s *Store) StoredNotifyPrefs(userID int64) (*NotifyPrefs, error) {
p, err := s.getPrefs(userID)
if errors.Is(err, ErrNotFound) {
return nil, nil
}
return p, err
}
// SetNotifyPrefs stores enabled and events (unknown events dropped,
// canonical order) and deletes the state rows of events no longer chosen,
// so choosing an event again starts with a silent first evaluation. The
// caller checks which events the user may choose.
func (s *Store) SetNotifyPrefs(userID int64, enabled bool, events []string) (NotifyPrefs, error) {
if _, err := s.NotifyPrefsFor(userID); err != nil {
return NotifyPrefs{}, err
}
ev := canonicalEvents(events)
en := 0
if enabled {
en = 1
}
tx, err := s.db.Begin()
if err != nil {
return NotifyPrefs{}, err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE notification_prefs SET enabled = ?, events = ?, updated_at = ? WHERE user_id = ?`,
en, strings.Join(ev, ","), unix(s.now()), userID); err != nil {
return NotifyPrefs{}, err
}
q := `DELETE FROM notification_state WHERE user_id = ?`
args := []any{userID}
if len(ev) > 0 {
q += ` AND event NOT IN (` + placeholders(len(ev)) + `)`
for _, e := range ev {
args = append(args, e)
}
}
if _, err := tx.Exec(q, args...); err != nil {
return NotifyPrefs{}, err
}
if err := tx.Commit(); err != nil {
return NotifyPrefs{}, err
}
return s.NotifyPrefsFor(userID)
}
// DisableNotifyByToken turns notifications off for the user holding the
// unsubscribe token. wasEnabled reports whether they were on.
// ErrTokenInvalid for an empty or unknown token.
func (s *Store) DisableNotifyByToken(token string) (userID int64, wasEnabled bool, err error) {
if token == "" {
return 0, false, ErrTokenInvalid
}
var en int
err = s.db.QueryRow(`SELECT user_id, enabled FROM notification_prefs WHERE unsub_token = ?`, token).Scan(&userID, &en)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, ErrTokenInvalid
}
if err != nil {
return 0, false, err
}
if en != 0 {
if _, err := s.db.Exec(`UPDATE notification_prefs SET enabled = 0, updated_at = ? WHERE user_id = ?`, unix(s.now()), userID); err != nil {
return 0, false, err
}
}
return userID, en != 0, nil
}
// AllNotifyPrefs returns every preferences row, by user id.
func (s *Store) AllNotifyPrefs() ([]NotifyPrefs, error) {
rows, err := s.db.Query(`SELECT ` + prefsCols + ` FROM notification_prefs ORDER BY user_id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NotifyPrefs
for rows.Next() {
p, err := scanPrefs(rows)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, rows.Err()
}
// AddWatches adds pubkeys (lowercase hex, checked by the caller) to the
// user's watch list in one transaction, in order, while the list holds
// fewer than max entries (max <= 0: no limit). It reports how many were
// added, how many were already watched and how many the limit left out.
func (s *Store) AddWatches(userID int64, pubkeys []string, max int) (added, already, overLimit int, err error) {
tx, err := s.db.Begin()
if err != nil {
return 0, 0, 0, err
}
defer tx.Rollback()
have := map[string]bool{}
rows, err := tx.Query(`SELECT pubkey FROM notification_watches WHERE user_id = ?`, userID)
if err != nil {
return 0, 0, 0, err
}
for rows.Next() {
var pk string
if err := rows.Scan(&pk); err != nil {
rows.Close()
return 0, 0, 0, err
}
have[pk] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, 0, 0, err
}
now := unix(s.now())
for _, pk := range pubkeys {
switch {
case have[pk]:
already++
case max > 0 && len(have) >= max:
overLimit++
default:
if _, err := tx.Exec(`INSERT INTO notification_watches (user_id, pubkey, created_at) VALUES (?, ?, ?)`, userID, pk, now); err != nil {
return 0, 0, 0, err
}
have[pk] = true
added++
}
}
if err := tx.Commit(); err != nil {
return 0, 0, 0, err
}
return added, already, overLimit, nil
}
// AddWatch watches one node. Watching an already watched node is a no-op;
// ErrWatchLimit when the list is full.
func (s *Store) AddWatch(userID int64, pubkey string, max int) error {
_, _, over, err := s.AddWatches(userID, []string{pubkey}, max)
if err != nil {
return err
}
if over > 0 {
return ErrWatchLimit
}
return nil
}
// RemoveWatch stops watching a node and deletes its node.* state rows
// (node.external subjects are "<pubkey>/<key>"). Removing a node that is
// not watched is a no-op.
func (s *Store) RemoveWatch(userID int64, pubkey string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM notification_watches WHERE user_id = ? AND pubkey = ?`, userID, pubkey); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM notification_state WHERE user_id = ? AND subject = ? AND event IN (?, ?)`,
userID, pubkey, NotifyNodeOffline, NotifyNodeBattery); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM notification_state WHERE user_id = ? AND event = ? AND substr(subject, 1, ?) = ?`,
userID, NotifyNodeExternal, len(pubkey)+1, pubkey+"/"); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) queryWatches(q string, args ...any) ([]NotifyWatch, error) {
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NotifyWatch
for rows.Next() {
var w NotifyWatch
var created int64
if err := rows.Scan(&w.UserID, &w.Pubkey, &created); err != nil {
return nil, err
}
w.CreatedAt = fromUnix(created)
out = append(out, w)
}
return out, rows.Err()
}
// WatchesFor returns the user's watches, oldest first.
func (s *Store) WatchesFor(userID int64) ([]NotifyWatch, error) {
return s.queryWatches(`SELECT user_id, pubkey, created_at FROM notification_watches WHERE user_id = ? ORDER BY created_at, pubkey`, userID)
}
// AllWatches returns every watch, by user and pubkey.
func (s *Store) AllWatches() ([]NotifyWatch, error) {
return s.queryWatches(`SELECT user_id, pubkey, created_at FROM notification_watches ORDER BY user_id, pubkey`)
}
// AllNotifyStates returns every stored state.
func (s *Store) AllNotifyStates() ([]NotifyState, error) {
rows, err := s.db.Query(`SELECT user_id, event, subject, state, changed_at FROM notification_state`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NotifyState
for rows.Next() {
var st NotifyState
var changed int64
if err := rows.Scan(&st.UserID, &st.Event, &st.Subject, &st.State, &changed); err != nil {
return nil, err
}
st.ChangedAt = fromUnix(changed)
out = append(out, st)
}
return out, rows.Err()
}
// WriteNotifyStates upserts the rows in one transaction. Rows of users
// deleted since the caller read them are skipped instead of failing the
// whole write on the foreign key.
func (s *Store) WriteNotifyStates(list []NotifyState) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT INTO notification_state (user_id, event, subject, state, changed_at)
SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM users WHERE id = ?)
ON CONFLICT (user_id, event, subject) DO UPDATE SET state = excluded.state, changed_at = excluded.changed_at`)
if err != nil {
return err
}
defer stmt.Close()
for _, st := range list {
if _, err := stmt.Exec(st.UserID, st.Event, st.Subject, st.State, unix(st.ChangedAt), st.UserID); err != nil {
return err
}
}
return tx.Commit()
}
// DeleteNotifyStates deletes the rows of keys in one transaction; keys
// without a row are ignored.
func (s *Store) DeleteNotifyStates(keys []NotifyKey) error {
if len(keys) == 0 {
return nil
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`DELETE FROM notification_state WHERE user_id = ? AND event = ? AND subject = ?`)
if err != nil {
return err
}
defer stmt.Close()
for _, k := range keys {
if _, err := stmt.Exec(k.UserID, k.Event, k.Subject); err != nil {
return err
}
}
return tx.Commit()
}
// NotifyMailCounts counts notification mails sent at or after since: in
// total (deleted accounts included) and per existing user.
func (s *Store) NotifyMailCounts(since time.Time) (total int, perUser map[int64]int, err error) {
perUser = map[int64]int{}
rows, err := s.db.Query(`SELECT user_id, COUNT(*) FROM mail_log WHERE purpose = ? AND sent_at >= ? GROUP BY user_id`,
NotifyMailPurpose, unix(since))
if err != nil {
return 0, nil, err
}
defer rows.Close()
for rows.Next() {
var uid sql.NullInt64
var n int
if err := rows.Scan(&uid, &n); err != nil {
return 0, nil, err
}
total += n
if uid.Valid {
perUser[uid.Int64] = n
}
}
return total, perUser, rows.Err()
}
// NotifyWatchStats counts watches and the users who have at least one.
func (s *Store) NotifyWatchStats() (watches, watchingUsers int, err error) {
err = s.db.QueryRow(`SELECT COUNT(*), COUNT(DISTINCT user_id) FROM notification_watches`).Scan(&watches, &watchingUsers)
return watches, watchingUsers, err
}