mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 02:40:07 +00:00
feat: mail notifications for watched nodes (user management part E) (#2140)
Part E of #2128: a logged-in user watches nodes and gets one mail when a watched node goes offline, comes back, or reports a low battery. Admins can add instance events: a new foreign node, and an observer going offline or back. This covers the per-user mail part of #775. Stacked on #2139 (part D, which builds on #2138). Review the commits after that branch. ## The situation A sysop learns that a repeater went silent, or that its battery is running down, only by opening CoreScope. #775 asks for notifications. Accounts (part A) now give a verified address to mail and a mailer with delivery status. ## What this PR adds **Events.** They use the instance thresholds, so a mail does not disagree with the node page: - `node.offline`: silent for the role's `healthThresholds` window. Last heard comes from the packet store, and repeaters and rooms count relayed traffic (#1598). - `node.battery`: advert telemetry below `batteryThresholds.lowMv`, recovered at `lowMv + 100`. - Admins only: `foreign.new` (once per node) and `observer.offline`. **Store.** Schema v5 with `notification_prefs`, `notification_watches` and `notification_state`. **Server** (only with `userManagement.notifications.enabled`) - A notifier next to the janitor, every `intervalMinutes` (5). The evaluator is a pure function of watches, prefs, stored states and node snapshots. - No burst after a restart or a new watch: a subject's first evaluation stores its state without mailing. The loop waits for the startup load. While ingest is stale (newest packet older than 30 minutes) the offline checks pause, and after recovery they wait one silent window. - One mail per user per check. Limits: 20 per user and 100 per instance per rolling 24 hours, 50 watches per user. A change over a limit is recorded and never mailed later. - Every mail has a one-click unsubscribe (`List-Unsubscribe` and `List-Unsubscribe-Post`). The GET only redirects to a confirm page, so link scanners cannot unsubscribe anyone. Node names are cleaned of control and bidi characters before they go into a mail. - Routes: `GET`/`PUT /api/account/notifications`, `PUT`/`DELETE /api/account/notifications/watches/{pubkey}`, `POST /api/account/notifications/watch-my-nodes` (copies the synced "my nodes" list), `GET`/`POST /api/notifications/unsubscribe`. `mailer.Message` gains `Headers`. **Frontend.** A "Notify me" toggle on the node pages, a Notifications section on the account page (`#/account?section=notifications`), an unsubscribe page, and the notification mail count on the admin overview. Spec: [`docs/specs/2026-10-07-node-notifications-design.md`](https://github.com/efiten/CoreScope/blob/feat/notifications/docs/specs/2026-10-07-node-notifications-design.md). ## Performance Each check reads watches, prefs and states from `users.db` (one query each), the watched nodes from the analyzer DB in chunks of 500, and last-heard times from the packet store. | Measurement | Result | |---|---| | One check on our staging instance, 130,000 packets in memory, 1 watcher | 6.9 ms, of which 0.93 ms holding the store read lock | | Evaluator benchmark, 100 users with 50 watches each, 2,000 nodes | 1.75 ms | No work is added to ingest, broadcast or any request path. ## Verification - `internal/users` and `internal/mailer` with `-race`; the `cmd/server` suite plus `-race` on the notifier tests; 74 new Go tests. The same Windows-only failure as noted in #2138 applies. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 26 of 26 steps locally. With notifications off the new steps fail, so they do test the flag. - On our staging and production instance since 7 October 2026. ## Not in this PR - Other channels (Discord, Telegram, webhooks). Detection is separate from delivery, so one can be added. - The topology, RF and anomaly alerts of #775. - If the whole server starts after a feed outage that already ended, the grace window is unknown and a watcher can get one wrong offline mail. The user guide says so. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
abbe6846ff
commit
de364a8bb6
@@ -529,7 +529,8 @@ jobs:
|
||||
"adminEmails": ["admin@e2e.test"],
|
||||
"publicBaseUrl": "http://localhost:13582",
|
||||
"mail": { "provider": "fake", "fromEmail": "noreply@e2e.test" },
|
||||
"channelProposals": { "enabled": true }
|
||||
"channelProposals": { "enabled": true },
|
||||
"notifications": { "enabled": true }
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
@@ -30,6 +30,15 @@ type guessedAccountJSON struct {
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// adminNotifyJSON: notification figures, counts only (watch lists are
|
||||
// private). Present only with notifications on.
|
||||
type adminNotifyJSON struct {
|
||||
MailsLast24h int `json:"mailsLast24h"`
|
||||
MaxMailsPerDay int `json:"maxMailsPerDay"`
|
||||
Watches int `json:"watches"`
|
||||
WatchingUsers int `json:"watchingUsers"`
|
||||
}
|
||||
|
||||
type adminStatsJSON struct {
|
||||
Total int `json:"total"`
|
||||
Active int `json:"active"`
|
||||
@@ -47,6 +56,7 @@ type adminStatsJSON struct {
|
||||
FailedLogins24h int `json:"failedLogins24h"`
|
||||
Mail7d mailCountsJSON `json:"mail7d"`
|
||||
Guessing []guessedAccountJSON `json:"guessing"`
|
||||
Notify *adminNotifyJSON `json:"notify,omitempty"`
|
||||
}
|
||||
|
||||
func adminStatsFrom(st users.Stats) adminStatsJSON {
|
||||
@@ -72,5 +82,21 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, _ *http.Request, _ *use
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, adminStatsFrom(st))
|
||||
out := adminStatsFrom(st)
|
||||
if s.auth.notify != nil {
|
||||
total, _, err := s.auth.st.NotifyMailCounts(s.auth.notify.now().Add(-24 * time.Hour))
|
||||
if err != nil {
|
||||
log.Printf("[users] admin stats notification mails: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
watches, watching, err := s.auth.st.NotifyWatchStats()
|
||||
if err != nil {
|
||||
log.Printf("[users] admin stats watches: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
out.Notify = &adminNotifyJSON{MailsLast24h: total, MaxMailsPerDay: s.auth.set.notify.maxMailsPerDay, Watches: watches, WatchingUsers: watching}
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
+22
-1
@@ -4,7 +4,9 @@ package main
|
||||
|
||||
// E2E-only hooks, compiled only with -tags e2etest (CI's corescope-server-e2e):
|
||||
// the "fake" mail provider and GET /__e2e/last-mail, which returns the newest
|
||||
// fake mail so Playwright can follow its link. Never part of a release build.
|
||||
// fake mail so Playwright can follow its link, and GET /__e2e/unsubscribe-link,
|
||||
// which returns a user's notification unsubscribe link. Never part of a
|
||||
// release build.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -19,6 +21,10 @@ type e2eMail struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type e2eLink struct {
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
fakeMailerAllowed = true
|
||||
e2eRoutes = func(s *Server, r *mux.Router) {
|
||||
@@ -35,5 +41,20 @@ func init() {
|
||||
}
|
||||
writeJSON(w, e2eMail{To: m.To, Subject: m.Subject, Text: m.Text})
|
||||
}).Methods("GET")
|
||||
// The unsubscribe link a notification mail would carry, so the E2E
|
||||
// can follow it without waiting for a real state change.
|
||||
r.HandleFunc("/__e2e/unsubscribe-link", func(w http.ResponseWriter, req *http.Request) {
|
||||
u, err := s.auth.st.GetByEmail(req.URL.Query().Get("email"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "no such user")
|
||||
return
|
||||
}
|
||||
p, err := s.auth.st.NotifyPrefsFor(u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, e2eLink{Link: s.auth.link("unsubscribe", p.UnsubToken)})
|
||||
}).Methods("GET")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,3 +15,18 @@ func TestE2ELastMailReturnsNewestFakeMail(t *testing.T) {
|
||||
t.Fatalf("unexpected mail: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2EUnsubscribeLink(t *testing.T) {
|
||||
f := newAuthFixture(t)
|
||||
expectStatus(t, f.do("GET", "/__e2e/unsubscribe-link?email=nobody@example.test", nil), 404)
|
||||
c := f.registerAndActivate(t, "e2e@example.test", "E2E", "correct horse battery")
|
||||
w := f.do("GET", "/__e2e/unsubscribe-link?email=e2e@example.test", nil)
|
||||
expectStatus(t, w, 200)
|
||||
p, err := f.st.NotifyPrefsFor(c.me.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := decode[e2eLink](t, w); l.Link != testBase+"/#/account/unsubscribe?token="+p.UnsubToken {
|
||||
t.Fatalf("link = %q", l.Link)
|
||||
}
|
||||
}
|
||||
|
||||
+41
-4
@@ -8,17 +8,24 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/meshcore-analyzer/mailer"
|
||||
"github.com/meshcore-analyzer/users"
|
||||
)
|
||||
|
||||
// mailLine is one linked line of a mail: a list item in HTML, the text and
|
||||
// the URL on their own lines in plain text.
|
||||
type mailLine struct{ text, url string }
|
||||
|
||||
type mailContent struct {
|
||||
subject string
|
||||
greeting string
|
||||
paragraphs []string
|
||||
lines []mailLine // after the paragraphs
|
||||
actionLabel string
|
||||
actionURL string
|
||||
footer *mailLine // replaces the default footer when set
|
||||
}
|
||||
|
||||
// link builds {publicBaseUrl}/#/account/{page}?token=… (never from the
|
||||
@@ -27,9 +34,21 @@ func (a *authService) link(page, token string) string {
|
||||
return a.set.baseURL.String() + "/#/account/" + page + "?token=" + url.QueryEscape(token)
|
||||
}
|
||||
|
||||
// mailSafeText makes a name chosen by someone else safe for one line of a
|
||||
// mail: control characters (line breaks and tabs included) and bidi
|
||||
// overrides and isolates (U+202A-U+202E, U+2066-U+2069) become a space,
|
||||
// runs of spaces collapse and the ends are trimmed. The HTML part escapes
|
||||
// the result as usual.
|
||||
func mailSafeText(s string) string {
|
||||
return strings.Join(strings.Fields(strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) || (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, s)), " ")
|
||||
}
|
||||
|
||||
func (a *authService) render(to, toName, tag string, c mailContent) mailer.Message {
|
||||
footer := "You received this because this address was used on " + a.set.baseURL.Host +
|
||||
". If that was not you, you can ignore this mail."
|
||||
var text, h strings.Builder
|
||||
text.WriteString(c.greeting + "\n\n")
|
||||
h.WriteString("<p>" + html.EscapeString(c.greeting) + "</p>")
|
||||
@@ -37,12 +56,30 @@ func (a *authService) render(to, toName, tag string, c mailContent) mailer.Messa
|
||||
text.WriteString(p + "\n\n")
|
||||
h.WriteString("<p>" + html.EscapeString(p) + "</p>")
|
||||
}
|
||||
if len(c.lines) > 0 {
|
||||
h.WriteString("<ul>")
|
||||
for _, l := range c.lines {
|
||||
l.text = mailSafeText(l.text)
|
||||
text.WriteString("- " + l.text + "\n " + l.url + "\n")
|
||||
h.WriteString(`<li><a href="` + html.EscapeString(l.url) + `">` + html.EscapeString(l.text) + `</a></li>`)
|
||||
}
|
||||
h.WriteString("</ul>")
|
||||
text.WriteString("\n")
|
||||
}
|
||||
if c.actionURL != "" {
|
||||
text.WriteString(c.actionLabel + ":\n" + c.actionURL + "\n\n")
|
||||
h.WriteString(`<p><a href="` + html.EscapeString(c.actionURL) + `">` + html.EscapeString(c.actionLabel) + `</a></p>`)
|
||||
}
|
||||
text.WriteString(footer + "\n")
|
||||
h.WriteString(`<p style="color:#666;font-size:12px">` + html.EscapeString(footer) + `</p>`)
|
||||
if c.footer != nil {
|
||||
text.WriteString(c.footer.text + "\n" + c.footer.url + "\n")
|
||||
h.WriteString(`<p style="color:#666;font-size:12px">` + html.EscapeString(c.footer.text) + ` <a href="` +
|
||||
html.EscapeString(c.footer.url) + `">` + html.EscapeString(c.footer.url) + `</a></p>`)
|
||||
} else {
|
||||
footer := "You received this because this address was used on " + a.set.baseURL.Host +
|
||||
". If that was not you, you can ignore this mail."
|
||||
text.WriteString(footer + "\n")
|
||||
h.WriteString(`<p style="color:#666;font-size:12px">` + html.EscapeString(footer) + `</p>`)
|
||||
}
|
||||
return mailer.Message{To: to, ToName: toName, Subject: "[" + a.set.fromName + "] " + c.subject,
|
||||
HTML: h.String(), Text: text.String(), Tag: tag}
|
||||
}
|
||||
|
||||
@@ -78,3 +78,17 @@ func TestLogStartupShowsAbsoluteUsersDBPath(t *testing.T) {
|
||||
t.Fatalf("log line = %q, want the absolute path %q", buf.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderLinesAndFooter(t *testing.T) {
|
||||
a, _ := newTestAuthService(t)
|
||||
m := a.render("x@example.org", "X", "notify", mailContent{subject: "S", greeting: "Hi",
|
||||
lines: []mailLine{{text: "<b>n</b>: offline", url: "https://e.org/#/nodes/ab"}},
|
||||
footer: &mailLine{text: "Stop these mails:", url: "https://e.org/#/account/unsubscribe?token=t"}})
|
||||
if !strings.Contains(m.HTML, `<ul><li><a href="https://e.org/#/nodes/ab"><b>n</b>: offline</a></li></ul>`) ||
|
||||
!strings.Contains(m.Text, "- <b>n</b>: offline\n https://e.org/#/nodes/ab\n") ||
|
||||
!strings.Contains(m.HTML, `Stop these mails: <a href="https://e.org/#/account/unsubscribe?token=t">`) ||
|
||||
!strings.Contains(m.Text, "Stop these mails:\nhttps://e.org/#/account/unsubscribe?token=t\n") ||
|
||||
strings.Contains(m.Text, "If that was not you") {
|
||||
t.Fatalf("rendered = %q / %q", m.HTML, m.Text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ func (s *Server) registerAuthRoutes(r *mux.Router) {
|
||||
if s.auth.set.proposals.enabled {
|
||||
s.registerProposalRoutes(r)
|
||||
}
|
||||
if s.auth.notify != nil {
|
||||
s.registerNotifyRoutes(r)
|
||||
}
|
||||
// The webhook exists only when a secret is configured.
|
||||
if s.auth.set.webhookSecret != "" {
|
||||
r.HandleFunc("/api/mail/brevo/webhook", s.handleBrevoWebhook).Methods("POST")
|
||||
|
||||
@@ -32,10 +32,14 @@ type authService struct {
|
||||
approvedMu sync.Mutex
|
||||
approved atomic.Pointer[[]string]
|
||||
|
||||
// notify evaluates node notifications; nil unless
|
||||
// userManagement.notifications.enabled.
|
||||
notify *notifier
|
||||
|
||||
warnIndistinct sync.Once
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup // the janitor
|
||||
wg sync.WaitGroup // the janitor and the notifier
|
||||
auditWG sync.WaitGroup // in-flight auditAsync writes
|
||||
}
|
||||
|
||||
@@ -81,6 +85,16 @@ func (s *Server) initUserManagement(measurementDBPath string) error {
|
||||
defer s.auth.wg.Done()
|
||||
s.auth.janitor(time.Hour)
|
||||
}()
|
||||
if set.notify.enabled {
|
||||
s.auth.notify = newNotifier(s.auth, serverNotifySource{s: s}, time.Now)
|
||||
s.auth.wg.Add(1)
|
||||
go func() {
|
||||
defer s.auth.wg.Done()
|
||||
s.auth.notify.loop(s.auth.stop, set.notify.interval)
|
||||
}()
|
||||
log.Printf("[notify] node notifications enabled: every %s, at most %d mails per user and %d in total per 24 hours",
|
||||
set.notify.interval, set.notify.perUserPerDay, set.notify.maxMailsPerDay)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/users"
|
||||
)
|
||||
|
||||
// Node notification evaluator (docs/specs/2026-10-07-node-notifications-design.md).
|
||||
// evaluateNotifications is a pure function: everything it reads is in
|
||||
// notifyInput, the time included, so it is tested without a clock or a
|
||||
// database. The notifier (notify_service.go) gathers the input and acts on
|
||||
// the result.
|
||||
|
||||
// notifyNode is one analyzer node row the evaluator needs.
|
||||
type notifyNode struct {
|
||||
Pubkey string // lowercase hex
|
||||
Name string
|
||||
Role string // as stored; compared lowercase
|
||||
LastSeen string // nodes.last_seen, "" when NULL
|
||||
BatteryMv *int // latest advert telemetry; nil when the node reports none
|
||||
Foreign bool // nodes.foreign_advert = 1
|
||||
}
|
||||
|
||||
// notifyObserver is one active (not soft-deleted) observer.
|
||||
type notifyObserver struct {
|
||||
ID string
|
||||
Name string
|
||||
LastSeen string // observers.last_seen, "" when NULL
|
||||
}
|
||||
|
||||
// notifyInput is everything one evaluation reads.
|
||||
type notifyInput struct {
|
||||
Now time.Time
|
||||
Health HealthThresholds // resolved, as Config.GetHealthThresholds returns it
|
||||
LowMv int // Config.LowBatteryMv
|
||||
Accounts map[int64]users.User
|
||||
Prefs []users.NotifyPrefs
|
||||
Watches []users.NotifyWatch
|
||||
States []users.NotifyState
|
||||
Nodes map[string]notifyNode // watched nodes, plus every foreign node when an admin chose foreign.new
|
||||
Heard map[string]time.Time // newest packet involving the node in the packet store (node page "Last Heard")
|
||||
Relayed map[string]time.Time // last relay hop, used for repeaters and rooms only (#1598)
|
||||
Observers []notifyObserver // nil unless an admin chose observer.offline
|
||||
// IngestStale: the newest packet in the store is old, so a silent node
|
||||
// and a silent feed look the same. node.offline and observer.offline
|
||||
// are then not compared (states unchanged, no changes).
|
||||
IngestStale bool
|
||||
// IngestResumedAt: when the last stale period ended (zero: none since
|
||||
// startup). For a node or observer whose stored state is good, evidence
|
||||
// older than this counts as heard at this time, so nothing goes offline
|
||||
// until a full silent window has passed after the feed came back.
|
||||
IngestResumedAt time.Time
|
||||
}
|
||||
|
||||
// notifyChange is one transition for one user.
|
||||
type notifyChange struct {
|
||||
Event string
|
||||
Subject string // node pubkey or observer id
|
||||
Name string // display name at evaluation time
|
||||
To string // users.NotifyGood, users.NotifyBad or users.NotifyTold
|
||||
BatteryMv *int // node.battery only
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// notifyResult: Changes per user, ordered by event then name; States are
|
||||
// the rows to write (new or changed), disabled users included; Drop are the
|
||||
// rows to delete (admin-event rows of users who are no longer admin).
|
||||
type notifyResult struct {
|
||||
Changes map[int64][]notifyChange
|
||||
States []users.NotifyState
|
||||
Drop []users.NotifyKey
|
||||
}
|
||||
|
||||
const (
|
||||
// batteryHysteresisMv: a low battery is good again only at lowMv + 100.
|
||||
batteryHysteresisMv = 100
|
||||
// foreignBaselineSubject marks that an admin's foreign.new baseline
|
||||
// exists: the first evaluation after opting in stores every current
|
||||
// foreign node as told without mailing; afterwards a foreign node
|
||||
// without a row is new.
|
||||
foreignBaselineSubject = "*"
|
||||
)
|
||||
|
||||
var notifyEventRank = map[string]int{
|
||||
users.NotifyNodeOffline: 0, users.NotifyNodeBattery: 1, users.NotifyForeignNew: 2, users.NotifyObserverOffline: 3,
|
||||
}
|
||||
|
||||
// notifyInfra reports the roles whose relay activity counts as heard
|
||||
// (GetHealthMs and roles.js use the same two).
|
||||
func notifyInfra(role string) bool {
|
||||
r := strings.ToLower(role)
|
||||
return r == "repeater" || r == "room"
|
||||
}
|
||||
|
||||
// nodeLabel is the node's name made safe for a mail line (mailSafeText),
|
||||
// or the first 8 hex characters of its key.
|
||||
func nodeLabel(n notifyNode, known bool, pk string) string {
|
||||
if name := mailSafeText(n.Name); known && name != "" {
|
||||
return name
|
||||
}
|
||||
if len(pk) > 8 {
|
||||
return pk[:8]
|
||||
}
|
||||
return pk
|
||||
}
|
||||
|
||||
// nodeOnlineState is good when the node was heard within its role's silent
|
||||
// window, like roles.js getNodeStatus: the latest of last_seen, the packet
|
||||
// store's last heard and, for repeaters and rooms, the last relay hop. A
|
||||
// node missing from the analyzer database is bad. While prev is good, the
|
||||
// latest evidence is never older than in.IngestResumedAt (the outage grace).
|
||||
func nodeOnlineState(pk string, n notifyNode, known bool, prev string, in *notifyInput) string {
|
||||
if !known {
|
||||
return users.NotifyBad
|
||||
}
|
||||
last, _ := parseRelayTS(n.LastSeen)
|
||||
if t, ok := in.Heard[pk]; ok && t.After(last) {
|
||||
last = t
|
||||
}
|
||||
if notifyInfra(n.Role) {
|
||||
if t, ok := in.Relayed[pk]; ok && t.After(last) {
|
||||
last = t
|
||||
}
|
||||
}
|
||||
if prev == users.NotifyGood && last.Before(in.IngestResumedAt) {
|
||||
last = in.IngestResumedAt
|
||||
}
|
||||
_, silentMs := in.Health.GetHealthMs(strings.ToLower(n.Role))
|
||||
if last.IsZero() || in.Now.Sub(last) >= time.Duration(silentMs)*time.Millisecond {
|
||||
return users.NotifyBad
|
||||
}
|
||||
return users.NotifyGood
|
||||
}
|
||||
|
||||
// batteryState applies the low-battery threshold with hysteresis.
|
||||
func batteryState(mv, lowMv int, prev string) string {
|
||||
if prev == users.NotifyBad {
|
||||
if mv >= lowMv+batteryHysteresisMv {
|
||||
return users.NotifyGood
|
||||
}
|
||||
return users.NotifyBad
|
||||
}
|
||||
if mv < lowMv {
|
||||
return users.NotifyBad
|
||||
}
|
||||
return users.NotifyGood
|
||||
}
|
||||
|
||||
// observerState: bad when last_seen is older than observerStaleMinutes,
|
||||
// good again within observerOnlineMinutes. While prev is good, last_seen
|
||||
// is never older than resumed (the outage grace, zero for none).
|
||||
func observerState(o notifyObserver, h HealthThresholds, now, resumed time.Time, prev string) string {
|
||||
t, ok := parseRelayTS(o.LastSeen)
|
||||
if prev == users.NotifyGood && !resumed.IsZero() && (!ok || t.Before(resumed)) {
|
||||
t, ok = resumed, true
|
||||
}
|
||||
if !ok {
|
||||
return users.NotifyBad
|
||||
}
|
||||
age := now.Sub(t)
|
||||
if prev == users.NotifyBad {
|
||||
if age <= time.Duration(h.ObserverOnlineMinutes)*time.Minute {
|
||||
return users.NotifyGood
|
||||
}
|
||||
return users.NotifyBad
|
||||
}
|
||||
if age > time.Duration(h.ObserverStaleMinutes)*time.Minute {
|
||||
return users.NotifyBad
|
||||
}
|
||||
return users.NotifyGood
|
||||
}
|
||||
|
||||
// evaluateNotifications computes every chosen (user, event, subject) state
|
||||
// and compares it with the stored one. A subject without a stored state is
|
||||
// stored without a change (a restart or a new watch never mails); a stored
|
||||
// state that differs is a change. Users missing from Accounts are skipped.
|
||||
// A user with watches but no preferences row gets the defaults (enabled,
|
||||
// node events). A disabled user's states are kept current without changes,
|
||||
// so turning notifications on again never mails what happened while off.
|
||||
// Admin events are evaluated only for admins; a non-admin's admin-event
|
||||
// rows are dropped, like opting out, so a re-promotion starts silently.
|
||||
func evaluateNotifications(in notifyInput) notifyResult {
|
||||
prev := make(map[users.NotifyKey]string, len(in.States))
|
||||
for _, s := range in.States {
|
||||
prev[s.NotifyKey] = s.State
|
||||
}
|
||||
watched := map[int64][]string{}
|
||||
for _, w := range in.Watches {
|
||||
watched[w.UserID] = append(watched[w.UserID], w.Pubkey)
|
||||
}
|
||||
var foreign []string
|
||||
for pk, n := range in.Nodes {
|
||||
if n.Foreign {
|
||||
foreign = append(foreign, pk)
|
||||
}
|
||||
}
|
||||
sort.Strings(foreign)
|
||||
|
||||
res := notifyResult{Changes: map[int64][]notifyChange{}}
|
||||
for _, s := range in.States {
|
||||
if u, ok := in.Accounts[s.UserID]; ok && u.Role != users.RoleAdmin && users.IsAdminNotifyEvent(s.Event) {
|
||||
res.Drop = append(res.Drop, s.NotifyKey)
|
||||
}
|
||||
}
|
||||
prefs := make([]users.NotifyPrefs, 0, len(in.Prefs)+len(watched))
|
||||
hasPrefs := make(map[int64]bool, len(in.Prefs))
|
||||
for _, p := range in.Prefs {
|
||||
prefs = append(prefs, p)
|
||||
hasPrefs[p.UserID] = true
|
||||
}
|
||||
var defaulted []int64
|
||||
for uid := range watched {
|
||||
if !hasPrefs[uid] {
|
||||
defaulted = append(defaulted, uid)
|
||||
}
|
||||
}
|
||||
sort.Slice(defaulted, func(i, j int) bool { return defaulted[i] < defaulted[j] })
|
||||
for _, uid := range defaulted {
|
||||
prefs = append(prefs, users.DefaultNotifyPrefs(uid))
|
||||
}
|
||||
mailing := false // Enabled of the user being evaluated
|
||||
store := func(k users.NotifyKey, state string) {
|
||||
res.States = append(res.States, users.NotifyState{NotifyKey: k, State: state, ChangedAt: in.Now})
|
||||
}
|
||||
compare := func(k users.NotifyKey, name, state string, mv *int) {
|
||||
old, had := prev[k]
|
||||
if had && old == state {
|
||||
return
|
||||
}
|
||||
store(k, state)
|
||||
if had && mailing {
|
||||
res.Changes[k.UserID] = append(res.Changes[k.UserID],
|
||||
notifyChange{Event: k.Event, Subject: k.Subject, Name: name, To: state, BatteryMv: mv, At: in.Now})
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range prefs {
|
||||
u, ok := in.Accounts[p.UserID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mailing = p.Enabled
|
||||
for _, pk := range watched[p.UserID] {
|
||||
n, known := in.Nodes[pk]
|
||||
name := nodeLabel(n, known, pk)
|
||||
if p.Has(users.NotifyNodeOffline) && !in.IngestStale {
|
||||
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyNodeOffline, Subject: pk}
|
||||
compare(k, name, nodeOnlineState(pk, n, known, prev[k], &in), nil)
|
||||
}
|
||||
if p.Has(users.NotifyNodeBattery) && known && n.BatteryMv != nil {
|
||||
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyNodeBattery, Subject: pk}
|
||||
compare(k, name, batteryState(*n.BatteryMv, in.LowMv, prev[k]), n.BatteryMv)
|
||||
}
|
||||
}
|
||||
if u.Role != users.RoleAdmin {
|
||||
continue
|
||||
}
|
||||
if p.Has(users.NotifyForeignNew) {
|
||||
base := users.NotifyKey{UserID: p.UserID, Event: users.NotifyForeignNew, Subject: foreignBaselineSubject}
|
||||
_, baselined := prev[base]
|
||||
if !baselined {
|
||||
store(base, users.NotifyGood)
|
||||
}
|
||||
for _, pk := range foreign {
|
||||
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyForeignNew, Subject: pk}
|
||||
if _, told := prev[k]; told {
|
||||
continue
|
||||
}
|
||||
store(k, users.NotifyTold)
|
||||
if baselined && mailing {
|
||||
res.Changes[p.UserID] = append(res.Changes[p.UserID], notifyChange{Event: users.NotifyForeignNew, Subject: pk,
|
||||
Name: nodeLabel(in.Nodes[pk], true, pk), To: users.NotifyTold, At: in.Now})
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.Has(users.NotifyObserverOffline) && !in.IngestStale {
|
||||
for _, o := range in.Observers {
|
||||
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyObserverOffline, Subject: o.ID}
|
||||
name := mailSafeText(o.Name)
|
||||
if name == "" {
|
||||
name = o.ID
|
||||
}
|
||||
compare(k, name, observerState(o, in.Health, in.Now, in.IngestResumedAt, prev[k]), nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, list := range res.Changes {
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
if a, b := notifyEventRank[list[i].Event], notifyEventRank[list[j].Event]; a != b {
|
||||
return a < b
|
||||
}
|
||||
if list[i].Name != list[j].Name {
|
||||
return list[i].Name < list[j].Name
|
||||
}
|
||||
return list[i].Subject < list[j].Subject
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/users"
|
||||
)
|
||||
|
||||
var evNow = time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
const (
|
||||
evUser = int64(1)
|
||||
evAdmin = int64(2)
|
||||
evAdmin2 = int64(3)
|
||||
)
|
||||
|
||||
var (
|
||||
evPkA = strings.Repeat("a", 64)
|
||||
evPkB = strings.Repeat("b", 64)
|
||||
evPkF = strings.Repeat("f", 64)
|
||||
evPkG = strings.Repeat("e", 64)
|
||||
)
|
||||
|
||||
func evAgo(d time.Duration) string { return evNow.Add(-d).Format(time.RFC3339) }
|
||||
func evMv(v int) *int { return &v }
|
||||
|
||||
// evInput: one user watching evPkA with the default node events, two
|
||||
// admins without prefs, no stored states.
|
||||
func evInput() notifyInput {
|
||||
return notifyInput{
|
||||
Now: evNow, Health: (&Config{}).GetHealthThresholds(), LowMv: 3300,
|
||||
Accounts: map[int64]users.User{
|
||||
evUser: {ID: evUser, Role: users.RoleUser, Status: users.StatusActive},
|
||||
evAdmin: {ID: evAdmin, Role: users.RoleAdmin, Status: users.StatusActive},
|
||||
evAdmin2: {ID: evAdmin2, Role: users.RoleAdmin, Status: users.StatusActive},
|
||||
},
|
||||
Prefs: []users.NotifyPrefs{{UserID: evUser, Enabled: true, Events: []string{users.NotifyNodeOffline, users.NotifyNodeBattery}}},
|
||||
Watches: []users.NotifyWatch{{UserID: evUser, Pubkey: evPkA}},
|
||||
Nodes: map[string]notifyNode{},
|
||||
Heard: map[string]time.Time{},
|
||||
Relayed: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
func evStateRow(uid int64, event, subject, state string) users.NotifyState {
|
||||
return users.NotifyState{NotifyKey: users.NotifyKey{UserID: uid, Event: event, Subject: subject}, State: state, ChangedAt: evNow.Add(-time.Hour)}
|
||||
}
|
||||
|
||||
func evState(res notifyResult, uid int64, event, subject string) (string, bool) {
|
||||
for _, s := range res.States {
|
||||
if s.UserID == uid && s.Event == event && s.Subject == subject {
|
||||
return s.State, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestEvaluateFirstEvaluationStoresWithoutChanges(t *testing.T) {
|
||||
in := evInput()
|
||||
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evUser, Pubkey: evPkB})
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
|
||||
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(time.Hour), BatteryMv: evMv(3100)}
|
||||
res := evaluateNotifications(in)
|
||||
if len(res.Changes) != 0 {
|
||||
t.Fatalf("changes on the first evaluation: %+v", res.Changes)
|
||||
}
|
||||
for _, c := range []struct{ event, subject, want string }{
|
||||
{users.NotifyNodeOffline, evPkA, users.NotifyBad},
|
||||
{users.NotifyNodeOffline, evPkB, users.NotifyGood},
|
||||
{users.NotifyNodeBattery, evPkB, users.NotifyBad},
|
||||
} {
|
||||
if got, ok := evState(res, evUser, c.event, c.subject); !ok || got != c.want {
|
||||
t.Errorf("%s %s = %q (stored %v); want %q", c.event, c.subject[:4], got, ok, c.want)
|
||||
}
|
||||
}
|
||||
if _, ok := evState(res, evUser, users.NotifyNodeBattery, evPkA); ok {
|
||||
t.Error("battery state stored for a node without telemetry")
|
||||
}
|
||||
for _, s := range res.States {
|
||||
if !s.ChangedAt.Equal(evNow) {
|
||||
t.Errorf("ChangedAt = %v; want the input's Now", s.ChangedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateOfflineAndBack(t *testing.T) {
|
||||
in := evInput()
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(24 * time.Hour)}
|
||||
in.States = []users.NotifyState{evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood)}
|
||||
res := evaluateNotifications(in)
|
||||
ch := res.Changes[evUser]
|
||||
if len(ch) != 1 || ch[0].Event != users.NotifyNodeOffline || ch[0].Subject != evPkA || ch[0].To != users.NotifyBad ||
|
||||
ch[0].Name != "Alpha" || !ch[0].At.Equal(evNow) {
|
||||
t.Fatalf("changes = %+v; want one offline change for Alpha", ch)
|
||||
}
|
||||
// One minute inside the 24 h companion window: still online, nothing to write.
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(24*time.Hour - time.Minute)}
|
||||
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
|
||||
t.Fatalf("unchanged online node produced %+v", res)
|
||||
}
|
||||
// Stored bad, advert 30 h old, but the packet store heard it an hour ago.
|
||||
in.States = []users.NotifyState{evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyBad)}
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(30 * time.Hour)}
|
||||
in.Heard[evPkA] = evNow.Add(-time.Hour)
|
||||
if ch := evaluateNotifications(in).Changes[evUser]; len(ch) != 1 || ch[0].To != users.NotifyGood {
|
||||
t.Fatalf("changes = %+v; want one back-online change", ch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRelayAwareInfra(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, role, lastSeen string
|
||||
relayed time.Duration
|
||||
want string
|
||||
}{
|
||||
{"repeater relaying", "repeater", evAgo(80 * time.Hour), time.Hour, users.NotifyGood},
|
||||
{"room relaying, role case", "Room", evAgo(80 * time.Hour), time.Hour, users.NotifyGood},
|
||||
{"repeater silent exactly 72 h", "repeater", evAgo(72 * time.Hour), 0, users.NotifyBad},
|
||||
{"repeater 71 h", "repeater", evAgo(71 * time.Hour), 0, users.NotifyGood},
|
||||
{"companion: relay time ignored", "companion", evAgo(30 * time.Hour), time.Hour, users.NotifyBad},
|
||||
{"no timestamp at all", "repeater", "", 0, users.NotifyBad},
|
||||
}
|
||||
for _, c := range cases {
|
||||
in := evInput()
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Role: c.role, LastSeen: c.lastSeen}
|
||||
if c.relayed > 0 {
|
||||
in.Relayed[evPkA] = evNow.Add(-c.relayed)
|
||||
}
|
||||
if got, _ := evState(evaluateNotifications(in), evUser, users.NotifyNodeOffline, evPkA); got != c.want {
|
||||
t.Errorf("%s: %q; want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateBatteryHysteresis(t *testing.T) {
|
||||
cases := []struct {
|
||||
prev string
|
||||
mv int
|
||||
want string
|
||||
change bool
|
||||
}{
|
||||
{"", 3299, users.NotifyBad, false},
|
||||
{"", 3300, users.NotifyGood, false},
|
||||
{"", 3350, users.NotifyGood, false},
|
||||
{users.NotifyGood, 3299, users.NotifyBad, true},
|
||||
{users.NotifyGood, 3300, users.NotifyGood, false},
|
||||
{users.NotifyBad, 3399, users.NotifyBad, false},
|
||||
{users.NotifyBad, 3400, users.NotifyGood, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
in := evInput()
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(time.Hour), BatteryMv: evMv(c.mv)}
|
||||
in.States = []users.NotifyState{evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood)}
|
||||
if c.prev != "" {
|
||||
in.States = append(in.States, evStateRow(evUser, users.NotifyNodeBattery, evPkA, c.prev))
|
||||
}
|
||||
res := evaluateNotifications(in)
|
||||
got, stored := evState(res, evUser, users.NotifyNodeBattery, evPkA)
|
||||
if !stored {
|
||||
got = c.prev
|
||||
}
|
||||
ch := res.Changes[evUser]
|
||||
if got != c.want || (len(ch) == 1) != c.change {
|
||||
t.Errorf("prev %q, %d mV: state %q, changes %+v; want %q, change %v", c.prev, c.mv, got, ch, c.want, c.change)
|
||||
continue
|
||||
}
|
||||
if c.change && (ch[0].Event != users.NotifyNodeBattery || ch[0].BatteryMv == nil || *ch[0].BatteryMv != c.mv) {
|
||||
t.Errorf("prev %q, %d mV: change = %+v", c.prev, c.mv, ch[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAbsentNodeIsOfflineOnce(t *testing.T) {
|
||||
in := evInput() // evPkA is watched but not in the analyzer database
|
||||
in.States = []users.NotifyState{
|
||||
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
|
||||
evStateRow(evUser, users.NotifyNodeBattery, evPkA, users.NotifyBad),
|
||||
}
|
||||
ch := evaluateNotifications(in).Changes[evUser]
|
||||
if len(ch) != 1 || ch[0].Event != users.NotifyNodeOffline || ch[0].To != users.NotifyBad || ch[0].Name != evPkA[:8] {
|
||||
t.Fatalf("changes = %+v; want one offline change named by the key prefix", ch)
|
||||
}
|
||||
in.States[0] = evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyBad)
|
||||
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
|
||||
t.Fatalf("absent node evaluated again: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateForeignOncePerAdmin(t *testing.T) {
|
||||
in := evInput()
|
||||
in.Prefs[0].Events = append(in.Prefs[0].Events, users.NotifyForeignNew) // a non-admin with an admin event
|
||||
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyForeignNew}})
|
||||
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true, LastSeen: evAgo(time.Hour)}
|
||||
res := evaluateNotifications(in)
|
||||
if len(res.Changes[evAdmin]) != 0 {
|
||||
t.Fatalf("first evaluation mailed: %+v", res.Changes[evAdmin])
|
||||
}
|
||||
if s, ok := evState(res, evAdmin, users.NotifyForeignNew, foreignBaselineSubject); !ok || s != users.NotifyGood {
|
||||
t.Fatalf("baseline row = %q, %v", s, ok)
|
||||
}
|
||||
if s, ok := evState(res, evAdmin, users.NotifyForeignNew, evPkF); !ok || s != users.NotifyTold {
|
||||
t.Fatalf("existing foreign node = %q, %v; want told", s, ok)
|
||||
}
|
||||
if _, ok := evState(res, evUser, users.NotifyForeignNew, evPkF); ok {
|
||||
t.Fatal("non-admin evaluated for foreign.new")
|
||||
}
|
||||
|
||||
// Next tick: a second foreign node appears; a second admin opts in now.
|
||||
in.States = res.States
|
||||
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin2, Enabled: true, Events: []string{users.NotifyForeignNew}})
|
||||
in.Nodes[evPkG] = notifyNode{Pubkey: evPkG, Name: "Golf", Foreign: true}
|
||||
res = evaluateNotifications(in)
|
||||
ch := res.Changes[evAdmin]
|
||||
if len(ch) != 1 || ch[0].Subject != evPkG || ch[0].To != users.NotifyTold || ch[0].Name != "Golf" {
|
||||
t.Fatalf("admin changes = %+v; want Golf once", ch)
|
||||
}
|
||||
if len(res.Changes[evAdmin2]) != 0 {
|
||||
t.Fatalf("second admin's first evaluation mailed: %+v", res.Changes[evAdmin2])
|
||||
}
|
||||
|
||||
in.States = append(in.States, res.States...)
|
||||
if res := evaluateNotifications(in); len(res.Changes) != 0 {
|
||||
t.Fatalf("foreign nodes mailed twice: %+v", res.Changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateObserverOfflineAndBack(t *testing.T) {
|
||||
cases := []struct {
|
||||
prev string
|
||||
age time.Duration
|
||||
want string
|
||||
change bool
|
||||
}{
|
||||
{"", 2 * time.Hour, users.NotifyGood, false},
|
||||
{"", 25 * time.Hour, users.NotifyBad, false},
|
||||
{users.NotifyGood, 24 * time.Hour, users.NotifyGood, false},
|
||||
{users.NotifyGood, 24*time.Hour + time.Minute, users.NotifyBad, true},
|
||||
{users.NotifyBad, 2 * time.Hour, users.NotifyBad, false},
|
||||
{users.NotifyBad, 60 * time.Minute, users.NotifyGood, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
in := evInput()
|
||||
in.Prefs = []users.NotifyPrefs{{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyObserverOffline}}}
|
||||
in.Watches = nil
|
||||
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(c.age)}}
|
||||
if c.prev != "" {
|
||||
in.States = []users.NotifyState{evStateRow(evAdmin, users.NotifyObserverOffline, "OBS1", c.prev)}
|
||||
}
|
||||
res := evaluateNotifications(in)
|
||||
got, stored := evState(res, evAdmin, users.NotifyObserverOffline, "OBS1")
|
||||
if !stored {
|
||||
got = c.prev
|
||||
}
|
||||
ch := res.Changes[evAdmin]
|
||||
if got != c.want || (len(ch) == 1) != c.change || (c.change && ch[0].Name != "Roof") {
|
||||
t.Errorf("prev %q, age %v: state %q, changes %+v; want %q, change %v", c.prev, c.age, got, ch, c.want, c.change)
|
||||
}
|
||||
}
|
||||
in := evInput()
|
||||
in.Prefs = []users.NotifyPrefs{{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyObserverOffline}}}
|
||||
in.Observers = []notifyObserver{{ID: "OBS2"}}
|
||||
if s, _ := evState(evaluateNotifications(in), evAdmin, users.NotifyObserverOffline, "OBS2"); s != users.NotifyBad {
|
||||
t.Fatalf("observer without last_seen = %q; want bad", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateOnlyChosenEventsAndKnownAccounts(t *testing.T) {
|
||||
in := evInput()
|
||||
in.Prefs[0].Events = []string{users.NotifyNodeBattery, users.NotifyObserverOffline} // observer.offline: the user is no admin
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(4000)}
|
||||
in.Observers = []notifyObserver{{ID: "OBS1", LastSeen: evAgo(time.Hour)}}
|
||||
res := evaluateNotifications(in)
|
||||
if len(res.States) != 1 || res.States[0].Event != users.NotifyNodeBattery {
|
||||
t.Fatalf("states = %+v; want only node.battery", res.States)
|
||||
}
|
||||
in.Accounts = map[int64]users.User{} // the user was deleted after the read
|
||||
if res := evaluateNotifications(in); len(res.States) != 0 {
|
||||
t.Fatalf("deleted user evaluated: %+v", res.States)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateChangesAreOrderedPerUser(t *testing.T) {
|
||||
in := evInput()
|
||||
in.Watches = []users.NotifyWatch{{UserID: evUser, Pubkey: evPkB}, {UserID: evUser, Pubkey: evPkA}}
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(3000)}
|
||||
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
|
||||
in.States = []users.NotifyState{
|
||||
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
|
||||
evStateRow(evUser, users.NotifyNodeOffline, evPkB, users.NotifyGood),
|
||||
evStateRow(evUser, users.NotifyNodeBattery, evPkA, users.NotifyGood),
|
||||
}
|
||||
var got []string
|
||||
for _, c := range evaluateNotifications(in).Changes[evUser] {
|
||||
got = append(got, c.Event+" "+c.Name)
|
||||
}
|
||||
if want := "node.offline Alpha|node.offline Bravo|node.battery Alpha"; strings.Join(got, "|") != want {
|
||||
t.Fatalf("order = %q; want %q", strings.Join(got, "|"), want)
|
||||
}
|
||||
}
|
||||
|
||||
// 100 users x 50 watches over 2,000 nodes: the spec's Performance figure
|
||||
// (5,000 state rows per event type). Run with -bench and quote the result
|
||||
// in the PR.
|
||||
func BenchmarkEvaluateNotifications(b *testing.B) {
|
||||
in := notifyInput{Now: evNow, Health: (&Config{}).GetHealthThresholds(), LowMv: 3300,
|
||||
Accounts: map[int64]users.User{}, Nodes: map[string]notifyNode{}, Heard: map[string]time.Time{}, Relayed: map[string]time.Time{}}
|
||||
for i := 0; i < 2000; i++ {
|
||||
pk := fmt.Sprintf("%064x", i)
|
||||
in.Nodes[pk] = notifyNode{Pubkey: pk, Role: "repeater", LastSeen: evAgo(time.Duration(i) * time.Minute), BatteryMv: evMv(3200 + i%300)}
|
||||
}
|
||||
for u := int64(1); u <= 100; u++ {
|
||||
in.Accounts[u] = users.User{ID: u, Role: users.RoleUser, Status: users.StatusActive}
|
||||
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: u, Enabled: true, Events: users.NodeNotifyEvents})
|
||||
for w := 0; w < 50; w++ {
|
||||
in.Watches = append(in.Watches, users.NotifyWatch{UserID: u, Pubkey: fmt.Sprintf("%064x", (int(u)*37+w*11)%2000)})
|
||||
}
|
||||
}
|
||||
in.States = evaluateNotifications(in).States
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
evaluateNotifications(in)
|
||||
}
|
||||
}
|
||||
|
||||
// P7: an admin who loses the admin role loses their admin-event states, like
|
||||
// opting out of those events, so a re-promotion starts a fresh baseline.
|
||||
func TestEvaluateDemotedAdminDropsAdminEventStates(t *testing.T) {
|
||||
in := evInput()
|
||||
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true,
|
||||
Events: []string{users.NotifyNodeOffline, users.NotifyForeignNew, users.NotifyObserverOffline}})
|
||||
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evAdmin, Pubkey: evPkA})
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(time.Hour)}
|
||||
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
|
||||
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(time.Hour)}}
|
||||
in.States = evaluateNotifications(in).States
|
||||
if s, ok := evState(notifyResult{States: in.States}, evAdmin, users.NotifyForeignNew, foreignBaselineSubject); !ok || s != users.NotifyGood {
|
||||
t.Fatalf("baseline row = %q, %v", s, ok)
|
||||
}
|
||||
|
||||
// Demoted: the admin-event rows are dropped, the node rows stay, nothing mailed.
|
||||
admin := in.Accounts[evAdmin]
|
||||
admin.Role = users.RoleUser
|
||||
in.Accounts[evAdmin] = admin
|
||||
in.Nodes[evPkG] = notifyNode{Pubkey: evPkG, Name: "Golf", Foreign: true}
|
||||
in.Observers[0].LastSeen = evAgo(48 * time.Hour)
|
||||
res := evaluateNotifications(in)
|
||||
if len(res.Changes) != 0 || len(res.States) != 0 {
|
||||
t.Fatalf("demoted admin evaluated: %+v", res)
|
||||
}
|
||||
want := map[users.NotifyKey]bool{
|
||||
{UserID: evAdmin, Event: users.NotifyForeignNew, Subject: foreignBaselineSubject}: true,
|
||||
{UserID: evAdmin, Event: users.NotifyForeignNew, Subject: evPkF}: true,
|
||||
{UserID: evAdmin, Event: users.NotifyObserverOffline, Subject: "OBS1"}: true,
|
||||
}
|
||||
if len(res.Drop) != len(want) {
|
||||
t.Fatalf("drop = %+v; want %d admin-event keys", res.Drop, len(want))
|
||||
}
|
||||
for _, k := range res.Drop {
|
||||
if !want[k] {
|
||||
t.Errorf("dropped %+v", k)
|
||||
}
|
||||
}
|
||||
dropped := map[users.NotifyKey]bool{}
|
||||
for _, k := range res.Drop {
|
||||
dropped[k] = true
|
||||
}
|
||||
var kept []users.NotifyState
|
||||
for _, s := range in.States {
|
||||
if !dropped[s.NotifyKey] {
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
in.States = kept
|
||||
if res := evaluateNotifications(in); len(res.Drop) != 0 {
|
||||
t.Fatalf("dropped again: %+v", res.Drop)
|
||||
}
|
||||
|
||||
// Re-promoted: Golf appeared and the observer went offline meanwhile; the
|
||||
// first evaluation stores a new baseline and mails nothing.
|
||||
admin.Role = users.RoleAdmin
|
||||
in.Accounts[evAdmin] = admin
|
||||
res = evaluateNotifications(in)
|
||||
if len(res.Changes) != 0 || len(res.Drop) != 0 {
|
||||
t.Fatalf("re-promoted admin: changes %+v, drop %+v", res.Changes, res.Drop)
|
||||
}
|
||||
for _, c := range []struct{ event, subject, want string }{
|
||||
{users.NotifyForeignNew, foreignBaselineSubject, users.NotifyGood},
|
||||
{users.NotifyForeignNew, evPkG, users.NotifyTold},
|
||||
{users.NotifyObserverOffline, "OBS1", users.NotifyBad},
|
||||
} {
|
||||
if got, ok := evState(res, evAdmin, c.event, c.subject); !ok || got != c.want {
|
||||
t.Errorf("%s %s = %q (stored %v); want %q", c.event, c.subject, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A user who watches nodes but has no preferences row (never opened the
|
||||
// settings, or created before the notification schema) gets the defaults:
|
||||
// enabled, node events on, admin events off.
|
||||
func TestEvaluateWatcherWithoutPrefsGetsDefaults(t *testing.T) {
|
||||
in := evInput()
|
||||
in.Prefs = nil
|
||||
in.Watches = []users.NotifyWatch{{UserID: evAdmin, Pubkey: evPkA}}
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(3000)}
|
||||
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
|
||||
in.Observers = []notifyObserver{{ID: "OBS1", LastSeen: evAgo(time.Hour)}}
|
||||
in.States = []users.NotifyState{
|
||||
evStateRow(evAdmin, users.NotifyNodeOffline, evPkA, users.NotifyGood),
|
||||
evStateRow(evAdmin, users.NotifyNodeBattery, evPkA, users.NotifyGood),
|
||||
}
|
||||
res := evaluateNotifications(in)
|
||||
var got []string
|
||||
for _, c := range res.Changes[evAdmin] {
|
||||
got = append(got, c.Event+" "+c.To)
|
||||
}
|
||||
if want := "node.offline bad|node.battery bad"; strings.Join(got, "|") != want {
|
||||
t.Fatalf("changes = %q; want %q", strings.Join(got, "|"), want)
|
||||
}
|
||||
for _, s := range res.States {
|
||||
if users.IsAdminNotifyEvent(s.Event) {
|
||||
t.Errorf("admin event evaluated without opting in: %+v", s)
|
||||
}
|
||||
}
|
||||
in.Accounts = map[int64]users.User{}
|
||||
if res := evaluateNotifications(in); len(res.States) != 0 {
|
||||
t.Fatalf("watcher without an account evaluated: %+v", res.States)
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled user keeps being evaluated: states follow the node, nothing is
|
||||
// mailed, and re-enabling does not mail what changed while off.
|
||||
func TestEvaluateDisabledUserUpdatesStatesSilently(t *testing.T) {
|
||||
in := evInput()
|
||||
in.Prefs[0].Enabled = false
|
||||
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: false, Events: []string{users.NotifyForeignNew}})
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(time.Hour)}
|
||||
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
|
||||
in.States = evaluateNotifications(in).States
|
||||
|
||||
// While off: Alpha goes offline and Golf appears.
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
|
||||
in.Nodes[evPkG] = notifyNode{Pubkey: evPkG, Name: "Golf", Foreign: true}
|
||||
res := evaluateNotifications(in)
|
||||
if len(res.Changes) != 0 {
|
||||
t.Fatalf("disabled users mailed: %+v", res.Changes)
|
||||
}
|
||||
if s, ok := evState(res, evUser, users.NotifyNodeOffline, evPkA); !ok || s != users.NotifyBad {
|
||||
t.Fatalf("disabled user's offline state = %q, %v; want bad stored", s, ok)
|
||||
}
|
||||
if s, ok := evState(res, evAdmin, users.NotifyForeignNew, evPkG); !ok || s != users.NotifyTold {
|
||||
t.Fatalf("disabled admin's foreign state = %q, %v; want told stored", s, ok)
|
||||
}
|
||||
in.States = append(in.States, res.States...)
|
||||
|
||||
// Re-enabled: the next evaluation has nothing to send.
|
||||
in.Prefs[0].Enabled = true
|
||||
in.Prefs[1].Enabled = true
|
||||
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
|
||||
t.Fatalf("re-enabling mailed old changes: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// While ingest is stale nobody can tell a silent node from a silent
|
||||
// MQTT feed: offline comparisons pause with their states unchanged;
|
||||
// battery and foreign nodes go on.
|
||||
func TestEvaluateIngestStalePausesOfflineChecks(t *testing.T) {
|
||||
in := evInput()
|
||||
in.IngestStale = true
|
||||
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evUser, Pubkey: evPkB})
|
||||
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyForeignNew, users.NotifyObserverOffline}})
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(3100)}
|
||||
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
|
||||
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(48 * time.Hour)}}
|
||||
in.States = []users.NotifyState{
|
||||
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
|
||||
evStateRow(evUser, users.NotifyNodeBattery, evPkA, users.NotifyGood),
|
||||
evStateRow(evAdmin, users.NotifyObserverOffline, "OBS1", users.NotifyGood),
|
||||
evStateRow(evAdmin, users.NotifyForeignNew, foreignBaselineSubject, users.NotifyGood),
|
||||
}
|
||||
res := evaluateNotifications(in)
|
||||
for _, k := range []struct {
|
||||
uid int64
|
||||
event, subject string
|
||||
}{{evUser, users.NotifyNodeOffline, evPkA}, {evUser, users.NotifyNodeOffline, evPkB}, {evAdmin, users.NotifyObserverOffline, "OBS1"}} {
|
||||
if s, ok := evState(res, k.uid, k.event, k.subject); ok {
|
||||
t.Errorf("%s %s written as %q while ingest is stale", k.event, k.subject, s)
|
||||
}
|
||||
}
|
||||
if ch := res.Changes[evUser]; len(ch) != 1 || ch[0].Event != users.NotifyNodeBattery || ch[0].To != users.NotifyBad {
|
||||
t.Fatalf("user changes = %+v; want only the battery", ch)
|
||||
}
|
||||
if ch := res.Changes[evAdmin]; len(ch) != 1 || ch[0].Event != users.NotifyForeignNew || ch[0].Subject != evPkF {
|
||||
t.Fatalf("admin changes = %+v; want only the foreign node", ch)
|
||||
}
|
||||
|
||||
in.IngestStale = false
|
||||
res = evaluateNotifications(in)
|
||||
if s, _ := evState(res, evUser, users.NotifyNodeOffline, evPkA); s != users.NotifyBad {
|
||||
t.Fatalf("offline check after ingest resumed = %q; want bad", s)
|
||||
}
|
||||
if s, _ := evState(res, evAdmin, users.NotifyObserverOffline, "OBS1"); s != users.NotifyBad {
|
||||
t.Fatalf("observer check after ingest resumed = %q; want bad", s)
|
||||
}
|
||||
}
|
||||
|
||||
// After a feed outage, evidence older than the recovery counts as heard at
|
||||
// the recovery: no node or observer goes offline until a full silent window
|
||||
// has passed since then. A state that is already bad is not lifted by it.
|
||||
func TestEvaluateGraceAfterIngestResumes(t *testing.T) {
|
||||
in := evInput()
|
||||
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyObserverOffline}})
|
||||
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evUser, Pubkey: evPkB})
|
||||
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(25 * time.Hour)}
|
||||
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
|
||||
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(25 * time.Hour)}, {ID: "OBS2", Name: "Mast", LastSeen: evAgo(48 * time.Hour)}}
|
||||
in.States = []users.NotifyState{
|
||||
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
|
||||
evStateRow(evUser, users.NotifyNodeOffline, evPkB, users.NotifyBad),
|
||||
evStateRow(evAdmin, users.NotifyObserverOffline, "OBS1", users.NotifyGood),
|
||||
evStateRow(evAdmin, users.NotifyObserverOffline, "OBS2", users.NotifyBad),
|
||||
}
|
||||
in.IngestResumedAt = evNow.Add(-10 * time.Minute)
|
||||
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
|
||||
t.Fatalf("10 minutes after recovery: changes %+v, states %+v; want none", res.Changes, res.States)
|
||||
}
|
||||
|
||||
// One silent window (24 h companions, observerStaleMinutes 1440) plus a
|
||||
// minute after recovery, still no new evidence: offline.
|
||||
in.Now = in.IngestResumedAt.Add(24*time.Hour + time.Minute)
|
||||
res := evaluateNotifications(in)
|
||||
if ch := res.Changes[evUser]; len(ch) != 1 || ch[0].Subject != evPkA || ch[0].To != users.NotifyBad {
|
||||
t.Fatalf("node changes after the window = %+v; want Alpha offline", ch)
|
||||
}
|
||||
if ch := res.Changes[evAdmin]; len(ch) != 1 || ch[0].Subject != "OBS1" || ch[0].To != users.NotifyBad {
|
||||
t.Fatalf("observer changes after the window = %+v; want Roof offline", ch)
|
||||
}
|
||||
|
||||
// A node heard after the recovery behaves as without the grace.
|
||||
in.Now = evNow
|
||||
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(5 * time.Minute)}
|
||||
in.Observers[1].LastSeen = evAgo(5 * time.Minute)
|
||||
res = evaluateNotifications(in)
|
||||
if ch := res.Changes[evUser]; len(ch) != 1 || ch[0].Subject != evPkB || ch[0].To != users.NotifyGood {
|
||||
t.Fatalf("node heard after recovery = %+v; want Bravo back online", ch)
|
||||
}
|
||||
if ch := res.Changes[evAdmin]; len(ch) != 1 || ch[0].Subject != "OBS2" || ch[0].To != users.NotifyGood {
|
||||
t.Fatalf("observer heard after recovery = %+v; want Mast back online", ch)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/meshcore-analyzer/users"
|
||||
)
|
||||
|
||||
// Node notification routes (docs/specs/2026-10-07-node-notifications-design.md).
|
||||
// Registered only when userManagement.notifications.enabled (see
|
||||
// registerAuthRoutes).
|
||||
|
||||
type notifyPrefsRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Events []string `json:"events"`
|
||||
}
|
||||
|
||||
type notifyWatchJSON struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name"` // "" when unnamed or no longer in the analyzer database
|
||||
Known bool `json:"known"` // false once the node left the analyzer database
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type notifyLimitsJSON struct {
|
||||
MaxWatches int `json:"maxWatches"`
|
||||
PerUserPerDay int `json:"perUserPerDay"`
|
||||
MailsLast24h int `json:"mailsLast24h"`
|
||||
}
|
||||
|
||||
// notifyAccountJSON is the caller's notification state: GET answers it, and
|
||||
// so does every change, so the client can replace its copy.
|
||||
type notifyAccountJSON struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Events []string `json:"events"`
|
||||
AvailableEvents []string `json:"availableEvents"`
|
||||
Watches []notifyWatchJSON `json:"watches"`
|
||||
Limits notifyLimitsJSON `json:"limits"`
|
||||
}
|
||||
|
||||
type watchMyNodesJSON struct {
|
||||
Added int `json:"added"`
|
||||
Already int `json:"already"`
|
||||
Skipped int `json:"skipped"` // malformed, not in the analyzer database, or over the limit
|
||||
Account notifyAccountJSON `json:"account"`
|
||||
}
|
||||
|
||||
func (s *Server) registerNotifyRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/account/notifications", s.withUser(s.handleNotifyGet)).Methods("GET")
|
||||
r.HandleFunc("/api/account/notifications", s.withUser(s.handleNotifyPut)).Methods("PUT")
|
||||
r.HandleFunc("/api/account/notifications/watches/{pubkey}", s.withUser(s.handleWatchPut)).Methods("PUT")
|
||||
r.HandleFunc("/api/account/notifications/watches/{pubkey}", s.withUser(s.handleWatchDelete)).Methods("DELETE")
|
||||
r.HandleFunc("/api/account/notifications/watch-my-nodes", s.withUser(s.handleWatchMyNodes)).Methods("POST")
|
||||
// No session: the link comes from a mail, and the provider's one-click
|
||||
// POST (List-Unsubscribe-Post) carries neither a cookie nor our Origin.
|
||||
r.HandleFunc("/api/notifications/unsubscribe", s.handleUnsubscribeGet).Methods("GET")
|
||||
r.HandleFunc("/api/notifications/unsubscribe", s.handleUnsubscribePost).Methods("POST")
|
||||
}
|
||||
|
||||
var notifyPubkeyRE = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// notifyPubkey lowercases raw and reports whether it is a 64-hex-character key.
|
||||
func notifyPubkey(raw string) (string, bool) {
|
||||
pk := strings.ToLower(strings.TrimSpace(raw))
|
||||
return pk, notifyPubkeyRE.MatchString(pk)
|
||||
}
|
||||
|
||||
func availableNotifyEvents(u *users.User) []string {
|
||||
out := append([]string{}, users.NodeNotifyEvents...)
|
||||
if u.Role == users.RoleAdmin {
|
||||
out = append(out, users.AdminNotifyEvents...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func notifyInternal(w http.ResponseWriter, op string, uid int64, err error) {
|
||||
log.Printf("[notify] %s for user #%d: %v", op, uid, err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
|
||||
// notifyAccount builds the caller's state; it creates the default
|
||||
// preferences row on first use. A demoted admin's stored admin events are
|
||||
// not shown (and are not evaluated).
|
||||
func (s *Server) notifyAccount(u *users.User) (notifyAccountJSON, error) {
|
||||
n, st, ns := s.auth.notify, s.auth.st, s.auth.set.notify
|
||||
p, err := st.NotifyPrefsFor(u.ID)
|
||||
if err != nil {
|
||||
return notifyAccountJSON{}, err
|
||||
}
|
||||
ws, err := st.WatchesFor(u.ID)
|
||||
if err != nil {
|
||||
return notifyAccountJSON{}, err
|
||||
}
|
||||
pks := make([]string, len(ws))
|
||||
for i, w := range ws {
|
||||
pks[i] = w.Pubkey
|
||||
}
|
||||
nodes, err := n.src.nodes(pks, false)
|
||||
if err != nil {
|
||||
return notifyAccountJSON{}, err
|
||||
}
|
||||
_, perUser, err := st.NotifyMailCounts(n.now().Add(-24 * time.Hour))
|
||||
if err != nil {
|
||||
return notifyAccountJSON{}, err
|
||||
}
|
||||
out := notifyAccountJSON{Enabled: p.Enabled, Events: []string{}, AvailableEvents: availableNotifyEvents(u),
|
||||
Watches: make([]notifyWatchJSON, 0, len(ws)),
|
||||
Limits: notifyLimitsJSON{MaxWatches: ns.maxWatchesPerUser, PerUserPerDay: ns.perUserPerDay, MailsLast24h: perUser[u.ID]}}
|
||||
for _, e := range p.Events {
|
||||
if u.Role == users.RoleAdmin || !users.IsAdminNotifyEvent(e) {
|
||||
out.Events = append(out.Events, e)
|
||||
}
|
||||
}
|
||||
for _, w := range ws {
|
||||
nd, known := nodes[w.Pubkey]
|
||||
out.Watches = append(out.Watches, notifyWatchJSON{Pubkey: w.Pubkey, Name: nd.Name, Known: known, CreatedAt: rfc3339(w.CreatedAt)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) writeNotifyAccount(w http.ResponseWriter, u *users.User) {
|
||||
out, err := s.notifyAccount(u)
|
||||
if err != nil {
|
||||
notifyInternal(w, "account state", u.ID, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleNotifyGet(w http.ResponseWriter, _ *http.Request, u *users.User, _ *users.Session) {
|
||||
s.writeNotifyAccount(w, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleNotifyPut(w http.ResponseWriter, r *http.Request, u *users.User, _ *users.Session) {
|
||||
var req notifyPrefsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
for _, e := range req.Events {
|
||||
if !users.ValidNotifyEvent(e) {
|
||||
writeError(w, http.StatusBadRequest, "unknown event type")
|
||||
return
|
||||
}
|
||||
if users.IsAdminNotifyEvent(e) && u.Role != users.RoleAdmin {
|
||||
writeError(w, http.StatusForbidden, "only admins can choose this event")
|
||||
return
|
||||
}
|
||||
}
|
||||
p, err := s.auth.st.SetNotifyPrefs(u.ID, req.Enabled, req.Events)
|
||||
if err != nil {
|
||||
notifyInternal(w, "save preferences", u.ID, err)
|
||||
return
|
||||
}
|
||||
s.auth.audit(idPtr(u.ID), "notify.prefs", idPtr(u.ID),
|
||||
map[string]string{"enabled": strconv.FormatBool(p.Enabled), "events": strings.Join(p.Events, ",")})
|
||||
s.writeNotifyAccount(w, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleWatchPut(w http.ResponseWriter, r *http.Request, u *users.User, _ *users.Session) {
|
||||
pk, ok := notifyPubkey(mux.Vars(r)["pubkey"])
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid node key: expected 64 hex characters")
|
||||
return
|
||||
}
|
||||
nodes, err := s.auth.notify.src.nodes([]string{pk}, false)
|
||||
if err != nil {
|
||||
notifyInternal(w, "look up node", u.ID, err)
|
||||
return
|
||||
}
|
||||
if _, found := nodes[pk]; !found {
|
||||
writeError(w, http.StatusNotFound, "node not found")
|
||||
return
|
||||
}
|
||||
if _, err := s.auth.st.NotifyPrefsFor(u.ID); err != nil {
|
||||
notifyInternal(w, "preferences", u.ID, err)
|
||||
return
|
||||
}
|
||||
limit := s.auth.set.notify.maxWatchesPerUser
|
||||
if err := s.auth.st.AddWatch(u.ID, pk, limit); err != nil {
|
||||
if errors.Is(err, users.ErrWatchLimit) {
|
||||
writeError(w, http.StatusConflict, fmt.Sprintf("you watch the maximum of %d nodes; remove one first", limit))
|
||||
return
|
||||
}
|
||||
notifyInternal(w, "add watch", u.ID, err)
|
||||
return
|
||||
}
|
||||
s.writeNotifyAccount(w, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleWatchDelete(w http.ResponseWriter, r *http.Request, u *users.User, _ *users.Session) {
|
||||
pk, ok := notifyPubkey(mux.Vars(r)["pubkey"])
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid node key: expected 64 hex characters")
|
||||
return
|
||||
}
|
||||
if err := s.auth.st.RemoveWatch(u.ID, pk); err != nil {
|
||||
notifyInternal(w, "remove watch", u.ID, err)
|
||||
return
|
||||
}
|
||||
s.writeNotifyAccount(w, u)
|
||||
}
|
||||
|
||||
// myNodesPubkeys returns the pubkeys of the synced "meshcore-my-nodes" list
|
||||
// (settings sync; [{pubkey, name, addedAt}]) in list order; nil when the
|
||||
// document or the key is absent or does not decode.
|
||||
func myNodesPubkeys(doc string) []string {
|
||||
if doc == "" {
|
||||
return nil
|
||||
}
|
||||
var d settingsDoc
|
||||
if json.Unmarshal([]byte(doc), &d) != nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := d.Keys["meshcore-my-nodes"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var items []struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &items) != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, it.Pubkey)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleWatchMyNodes(w http.ResponseWriter, _ *http.Request, u *users.User, _ *users.Session) {
|
||||
st := s.auth.st
|
||||
doc, _, err := st.GetSettings(u.ID)
|
||||
if err != nil {
|
||||
notifyInternal(w, "read synced settings", u.ID, err)
|
||||
return
|
||||
}
|
||||
var res watchMyNodesJSON
|
||||
seen := map[string]bool{}
|
||||
var valid []string
|
||||
for _, raw := range myNodesPubkeys(doc) {
|
||||
pk, ok := notifyPubkey(raw)
|
||||
if !ok {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
if !seen[pk] {
|
||||
seen[pk] = true
|
||||
valid = append(valid, pk)
|
||||
}
|
||||
}
|
||||
nodes, err := s.auth.notify.src.nodes(valid, false)
|
||||
if err != nil {
|
||||
notifyInternal(w, "look up nodes", u.ID, err)
|
||||
return
|
||||
}
|
||||
known := make([]string, 0, len(valid))
|
||||
for _, pk := range valid {
|
||||
if _, ok := nodes[pk]; ok {
|
||||
known = append(known, pk)
|
||||
} else {
|
||||
res.Skipped++
|
||||
}
|
||||
}
|
||||
if _, err := st.NotifyPrefsFor(u.ID); err != nil {
|
||||
notifyInternal(w, "preferences", u.ID, err)
|
||||
return
|
||||
}
|
||||
added, already, over, err := st.AddWatches(u.ID, known, s.auth.set.notify.maxWatchesPerUser)
|
||||
if err != nil {
|
||||
notifyInternal(w, "add watches", u.ID, err)
|
||||
return
|
||||
}
|
||||
res.Added, res.Already, res.Skipped = added, already, res.Skipped+over
|
||||
if res.Account, err = s.notifyAccount(u); err != nil {
|
||||
notifyInternal(w, "account state", u.ID, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, res)
|
||||
}
|
||||
|
||||
// handleUnsubscribeGet sends a mail client or a scanner that follows the
|
||||
// List-Unsubscribe URL to the confirm view. A GET never changes anything.
|
||||
func (s *Server) handleUnsubscribeGet(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, s.auth.link("unsubscribe", r.URL.Query().Get("token")), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleUnsubscribePost turns notifications off for the token's owner: the
|
||||
// confirm button and the provider's one-click POST. The token only does this.
|
||||
func (s *Server) handleUnsubscribePost(w http.ResponseWriter, r *http.Request) {
|
||||
uid, was, err := s.auth.st.DisableNotifyByToken(r.URL.Query().Get("token"))
|
||||
if err != nil {
|
||||
if !errors.Is(err, users.ErrTokenInvalid) {
|
||||
log.Printf("[notify] unsubscribe: %v", err)
|
||||
}
|
||||
writeTokenError(w, err)
|
||||
return
|
||||
}
|
||||
if was {
|
||||
s.auth.audit(idPtr(uid), "notify.unsubscribe", idPtr(uid), map[string]string{"via": "link"})
|
||||
}
|
||||
writeJSON(w, okResponse{OK: true, Message: "Notifications are off. Turn them back on from your account page."})
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/users"
|
||||
)
|
||||
|
||||
func notifyPath(pk string) string { return "/api/account/notifications/watches/" + pk }
|
||||
|
||||
func TestNotifyRoutesAbsentWhenOff(t *testing.T) {
|
||||
f := newNotifyFixture(t, notifySettings{})
|
||||
for _, rt := range []struct{ method, path string }{
|
||||
{"GET", "/api/account/notifications"}, {"PUT", "/api/account/notifications"},
|
||||
{"PUT", notifyPath(evPkA)}, {"DELETE", notifyPath(evPkA)},
|
||||
{"POST", "/api/account/notifications/watch-my-nodes"},
|
||||
{"GET", "/api/notifications/unsubscribe?token=x"}, {"POST", "/api/notifications/unsubscribe?token=x"},
|
||||
} {
|
||||
if w := f.do(rt.method, rt.path, nil); w.Code != 404 {
|
||||
t.Errorf("%s %s = %d with notifications off; want 404", rt.method, rt.path, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyGetDefaultsAndAdminEvents(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw)
|
||||
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
|
||||
expectStatus(t, f.do("GET", "/api/account/notifications", nil), 401)
|
||||
w := f.do("GET", "/api/account/notifications", nil, as(pat))
|
||||
expectStatus(t, w, 200)
|
||||
want := notifyAccountJSON{Enabled: true, Events: []string{"node.offline", "node.battery"}, AvailableEvents: []string{"node.offline", "node.battery"},
|
||||
Watches: []notifyWatchJSON{}, Limits: notifyLimitsJSON{MaxWatches: 50, PerUserPerDay: 20}}
|
||||
if got := decode[notifyAccountJSON](t, w); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("user defaults = %+v; want %+v", got, want)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"watches":[]`) {
|
||||
t.Fatalf("empty watch list is not an array: %s", w.Body.String())
|
||||
}
|
||||
adm := decode[notifyAccountJSON](t, f.do("GET", "/api/account/notifications", nil, as(boss)))
|
||||
if !reflect.DeepEqual(adm.AvailableEvents, []string{"node.offline", "node.battery", "foreign.new", "observer.offline"}) ||
|
||||
!reflect.DeepEqual(adm.Events, []string{"node.offline", "node.battery"}) {
|
||||
t.Fatalf("admin = %+v", adm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyPutPrefs(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw)
|
||||
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
|
||||
put := func(c *client, body any) *httptest.ResponseRecorder {
|
||||
return f.do("PUT", "/api/account/notifications", body, as(c))
|
||||
}
|
||||
expectStatus(t, put(&client{cookie: pat.cookie}, notifyPrefsRequest{Enabled: true}), 403)
|
||||
w := put(pat, notifyPrefsRequest{Enabled: false, Events: []string{"node.battery"}})
|
||||
expectStatus(t, w, 200)
|
||||
if got := decode[notifyAccountJSON](t, w); got.Enabled || !reflect.DeepEqual(got.Events, []string{"node.battery"}) {
|
||||
t.Fatalf("after PUT = %+v", got)
|
||||
}
|
||||
entries, err := f.st.AuditList(users.AuditFilter{Actions: []string{"notify.prefs"}})
|
||||
if err != nil || len(entries) != 1 || *entries[0].ActorUserID != pat.me.ID || entries[0].Detail["enabled"] != "false" || entries[0].Detail["events"] != "node.battery" {
|
||||
t.Fatalf("audit = %+v, %v", entries, err)
|
||||
}
|
||||
if w := put(pat, notifyPrefsRequest{Enabled: true, Events: []string{"node.reboot"}}); w.Code != 400 || !strings.Contains(w.Body.String(), "unknown event type") {
|
||||
t.Errorf("unknown event: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := put(pat, notifyPrefsRequest{Enabled: true, Events: []string{"foreign.new"}}); w.Code != 403 || !strings.Contains(w.Body.String(), "only admins") {
|
||||
t.Errorf("admin event by a user: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := put(pat, map[string]any{"enabled": true, "x": 1}); w.Code != 400 {
|
||||
t.Errorf("unknown field: %d", w.Code)
|
||||
}
|
||||
w = put(boss, notifyPrefsRequest{Enabled: true, Events: []string{"observer.offline", "foreign.new"}})
|
||||
if got := decode[notifyAccountJSON](t, w); w.Code != 200 || !reflect.DeepEqual(got.Events, []string{"foreign.new", "observer.offline"}) {
|
||||
t.Fatalf("admin PUT: %d %+v", w.Code, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchPutAndDelete(t *testing.T) {
|
||||
ns := defaultNotifySettings()
|
||||
ns.maxWatchesPerUser = 1
|
||||
f := newNotifyFixture(t, ns)
|
||||
f.setNode(evPkA, "Alpha", "repeater", time.Hour, nil)
|
||||
f.setNode(evPkB, "Bravo", "companion", time.Hour, nil)
|
||||
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
|
||||
expectStatus(t, f.do("PUT", notifyPath(evPkA), nil), 401)
|
||||
expectStatus(t, f.do("PUT", notifyPath(evPkA), nil, as(&client{cookie: pat.cookie})), 403)
|
||||
for _, bad := range []string{"xyz", evPkA[:63], evPkA + "0", strings.Repeat("g", 64)} {
|
||||
if w := f.do("PUT", notifyPath(bad), nil, as(pat)); w.Code != 400 {
|
||||
t.Errorf("PUT %q = %d; want 400", bad, w.Code)
|
||||
}
|
||||
}
|
||||
if w := f.do("PUT", notifyPath(evPkG), nil, as(pat)); w.Code != 404 || !strings.Contains(w.Body.String(), "node not found") {
|
||||
t.Fatalf("unknown node: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w := f.do("PUT", notifyPath(strings.ToUpper(evPkA)), nil, as(pat))
|
||||
expectStatus(t, w, 200)
|
||||
got := decode[notifyAccountJSON](t, w)
|
||||
if len(got.Watches) != 1 || got.Watches[0].Pubkey != evPkA || got.Watches[0].Name != "Alpha" || !got.Watches[0].Known {
|
||||
t.Fatalf("after watching = %+v", got.Watches)
|
||||
}
|
||||
expectStatus(t, f.do("PUT", notifyPath(evPkA), nil, as(pat)), 200)
|
||||
if w := f.do("PUT", notifyPath(evPkB), nil, as(pat)); w.Code != 409 || !strings.Contains(w.Body.String(), "you watch the maximum of 1 nodes") {
|
||||
t.Fatalf("over the limit: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
delete(f.src.nodeMap, evPkA) // the node left the analyzer database
|
||||
if got := decode[notifyAccountJSON](t, f.do("GET", "/api/account/notifications", nil, as(pat))); got.Watches[0].Known || got.Watches[0].Name != "" {
|
||||
t.Fatalf("vanished node = %+v", got.Watches[0])
|
||||
}
|
||||
w = f.do("DELETE", notifyPath(evPkA), nil, as(pat))
|
||||
expectStatus(t, w, 200)
|
||||
if got := decode[notifyAccountJSON](t, w); len(got.Watches) != 0 {
|
||||
t.Fatalf("after DELETE = %+v", got.Watches)
|
||||
}
|
||||
expectStatus(t, f.do("DELETE", notifyPath(evPkA), nil, as(pat)), 200)
|
||||
expectStatus(t, f.do("DELETE", notifyPath("xyz"), nil, as(pat)), 400)
|
||||
if entries, _ := f.st.AuditList(users.AuditFilter{Actions: []string{"notify.*"}}); len(entries) != 0 {
|
||||
t.Fatalf("watch changes were audited: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyNodesPubkeys(t *testing.T) {
|
||||
for _, doc := range []string{"", "not json", `{"v":1,"keys":{}}`, `{"v":1,"keys":{"meshcore-my-nodes":"not json"}}`} {
|
||||
if got := myNodesPubkeys(doc); got != nil {
|
||||
t.Errorf("%q = %v; want nil", doc, got)
|
||||
}
|
||||
}
|
||||
got := myNodesPubkeys(`{"v":1,"keys":{"meshcore-my-nodes":"[{\"pubkey\":\"ab\",\"name\":\"x\",\"addedAt\":1},{\"name\":\"no key\"}]"}}`)
|
||||
if !reflect.DeepEqual(got, []string{"ab", ""}) {
|
||||
t.Fatalf("myNodesPubkeys = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchMyNodes(t *testing.T) {
|
||||
ns := defaultNotifySettings()
|
||||
ns.maxWatchesPerUser = 1
|
||||
f := newNotifyFixture(t, ns)
|
||||
f.setNode(evPkA, "Alpha", "repeater", time.Hour, nil)
|
||||
f.setNode(evPkB, "Bravo", "companion", time.Hour, nil)
|
||||
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
|
||||
post := func() watchMyNodesJSON {
|
||||
t.Helper()
|
||||
w := f.do("POST", "/api/account/notifications/watch-my-nodes", nil, as(pat))
|
||||
expectStatus(t, w, 200)
|
||||
return decode[watchMyNodesJSON](t, w)
|
||||
}
|
||||
if r := post(); r.Added != 0 || r.Already != 0 || r.Skipped != 0 {
|
||||
t.Fatalf("without synced settings = %+v", r)
|
||||
}
|
||||
list, _ := json.Marshal([]map[string]string{{"pubkey": strings.ToUpper(evPkA)}, {"pubkey": evPkB}, {"pubkey": evPkG}, {"pubkey": "bad"}, {"pubkey": evPkA}})
|
||||
doc, _ := json.Marshal(settingsDoc{V: 1, Keys: map[string]string{"meshcore-my-nodes": string(list)}})
|
||||
if _, err := f.st.PutSettings(pat.me.ID, users.SettingsVersion{}, string(doc)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A watched, B over the limit, G not in the analyzer, "bad" malformed, the second A a duplicate.
|
||||
if r := post(); r.Added != 1 || r.Already != 0 || r.Skipped != 3 || len(r.Account.Watches) != 1 || r.Account.Watches[0].Pubkey != evPkA {
|
||||
t.Fatalf("first copy = %+v", r)
|
||||
}
|
||||
if r := post(); r.Added != 0 || r.Already != 1 || r.Skipped != 3 {
|
||||
t.Fatalf("second copy = %+v", r)
|
||||
}
|
||||
expectStatus(t, f.do("POST", "/api/account/notifications/watch-my-nodes", nil, as(&client{cookie: pat.cookie})), 403)
|
||||
}
|
||||
|
||||
func TestUnsubscribe(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
|
||||
p, err := f.st.NotifyPrefsFor(pat.me.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := "/api/notifications/unsubscribe?token=" + p.UnsubToken
|
||||
w := f.do("GET", q, nil)
|
||||
if w.Code != 303 || w.Header().Get("Location") != testBase+"/#/account/unsubscribe?token="+p.UnsubToken {
|
||||
t.Fatalf("GET = %d, Location %q", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
if again, _ := f.st.NotifyPrefsFor(pat.me.ID); !again.Enabled {
|
||||
t.Fatal("a GET (a link scanner) turned notifications off")
|
||||
}
|
||||
// The provider's one-click POST: form body, a foreign Origin, no session.
|
||||
w = f.do("POST", q, nil, header("Origin", "https://mail.example.net"), header("Content-Type", "application/x-www-form-urlencoded"))
|
||||
expectStatus(t, w, 200)
|
||||
if r := decode[okResponse](t, w); !r.OK || !strings.Contains(r.Message, "Notifications are off") {
|
||||
t.Fatalf("POST = %+v", r)
|
||||
}
|
||||
if again, _ := f.st.NotifyPrefsFor(pat.me.ID); again.Enabled {
|
||||
t.Fatal("still enabled after the POST")
|
||||
}
|
||||
expectStatus(t, f.do("POST", q, nil), 200)
|
||||
entries, err := f.st.AuditList(users.AuditFilter{Actions: []string{"notify.unsubscribe"}})
|
||||
if err != nil || len(entries) != 1 || *entries[0].TargetUserID != pat.me.ID || entries[0].Detail["via"] != "link" {
|
||||
t.Fatalf("audit = %+v, %v; want one row for the change", entries, err)
|
||||
}
|
||||
for _, bad := range []string{"/api/notifications/unsubscribe?token=nope", "/api/notifications/unsubscribe"} {
|
||||
if w := f.do("POST", bad, nil); w.Code != 410 {
|
||||
t.Errorf("POST %s = %d; want 410", bad, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientConfigAdvertisesNotifications(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
a, _ := newTestAuthService(t)
|
||||
a.notify = newNotifier(a, &fakeNotifySource{}, time.Now)
|
||||
srv.auth = a
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest("GET", "/api/config/client", nil))
|
||||
if !strings.Contains(w.Body.String(), `"userManagement":{"enabled":true,"notifications":true}`) {
|
||||
t.Fatalf("client config = %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStatsNotifyBlock(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw)
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA, evPkB)
|
||||
if _, err := f.st.LogMail(nil, "gone@example.org", users.NotifyMailPurpose, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := f.do("GET", "/api/admin/stats", nil, as(boss))
|
||||
expectStatus(t, w, 200)
|
||||
st := decode[adminStatsJSON](t, w)
|
||||
if st.Notify == nil || *st.Notify != (adminNotifyJSON{MailsLast24h: 1, MaxMailsPerDay: 300, Watches: 2, WatchingUsers: 1}) {
|
||||
t.Fatalf("notify block = %+v", st.Notify)
|
||||
}
|
||||
off, offBoss, _ := adminFixture(t)
|
||||
if w := off.do("GET", "/api/admin/stats", nil, as(offBoss)); strings.Contains(w.Body.String(), `"notify"`) {
|
||||
t.Fatalf("notify block with notifications off: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// users.db never holds case variants: every route lowercases the key first.
|
||||
func TestWatchRoutesStoreLowercaseKeys(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.setNode(evPkA, "Alpha", "repeater", time.Hour, nil)
|
||||
f.setNode(evPkB, "Bravo", "companion", time.Hour, nil)
|
||||
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
|
||||
stored := func() []string {
|
||||
t.Helper()
|
||||
ws, err := f.st.WatchesFor(pat.me.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := []string{}
|
||||
for _, w := range ws {
|
||||
out = append(out, w.Pubkey)
|
||||
}
|
||||
return out
|
||||
}
|
||||
expectStatus(t, f.do("PUT", notifyPath(strings.ToUpper(evPkA)), nil, as(pat)), 200)
|
||||
expectStatus(t, f.do("PUT", notifyPath(evPkA), nil, as(pat)), 200)
|
||||
if got := stored(); !reflect.DeepEqual(got, []string{evPkA}) {
|
||||
t.Fatalf("after PUT upper then lower = %v", got)
|
||||
}
|
||||
expectStatus(t, f.do("DELETE", notifyPath(strings.ToUpper(evPkA)), nil, as(pat)), 200)
|
||||
if got := stored(); len(got) != 0 {
|
||||
t.Fatalf("DELETE with an uppercase key left %v", got)
|
||||
}
|
||||
list, _ := json.Marshal([]map[string]string{{"pubkey": strings.ToUpper(evPkB)}})
|
||||
doc, _ := json.Marshal(settingsDoc{V: 1, Keys: map[string]string{"meshcore-my-nodes": string(list)}})
|
||||
if _, err := f.st.PutSettings(pat.me.ID, users.SettingsVersion{}, string(doc)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectStatus(t, f.do("POST", "/api/account/notifications/watch-my-nodes", nil, as(pat)), 200)
|
||||
if got := stored(); !reflect.DeepEqual(got, []string{evPkB}) {
|
||||
t.Fatalf("watch-my-nodes stored %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/mailer"
|
||||
"github.com/meshcore-analyzer/users"
|
||||
)
|
||||
|
||||
// notifier evaluates node notifications on a timer and mails the changes
|
||||
// (docs/specs/2026-10-07-node-notifications-design.md). authService.notify
|
||||
// is nil unless userManagement.notifications.enabled.
|
||||
type notifier struct {
|
||||
a *authService
|
||||
src notifySource
|
||||
now func() time.Time
|
||||
// ingestStale is the last tick's verdict, so the pause and the resume
|
||||
// are each logged once; ingestResumedAt is when the last stale period
|
||||
// ended (the offline grace, see notifyInput). In memory only: a restart
|
||||
// inside the grace loses it. Only the loop goroutine touches them.
|
||||
ingestStale bool
|
||||
ingestResumedAt time.Time
|
||||
}
|
||||
|
||||
func newNotifier(a *authService, src notifySource, now func() time.Time) *notifier {
|
||||
return ¬ifier{a: a, src: src, now: now}
|
||||
}
|
||||
|
||||
// notifySendTimeout bounds one mail send inside a tick.
|
||||
const notifySendTimeout = 30 * time.Second
|
||||
|
||||
// notifyIngestStaleAfter: when the newest packet in the store is older,
|
||||
// offline checks pause (an MQTT or ingestor outage would otherwise mail
|
||||
// every watcher "offline" and later "back online").
|
||||
const notifyIngestStaleAfter = 30 * time.Minute
|
||||
|
||||
// checkIngest reports whether ingest is stale at now and logs when that
|
||||
// verdict changes.
|
||||
func (n *notifier) checkIngest(now time.Time) bool {
|
||||
newest := n.src.newestPacket()
|
||||
stale := newest.IsZero() || now.Sub(newest) > notifyIngestStaleAfter
|
||||
switch {
|
||||
case stale && !n.ingestStale:
|
||||
since := "(no packets)"
|
||||
if !newest.IsZero() {
|
||||
since = newest.UTC().Format(time.RFC3339)
|
||||
}
|
||||
log.Printf("[notify] ingest stale since %s; offline checks paused", since)
|
||||
case !stale && n.ingestStale:
|
||||
n.ingestResumedAt = now
|
||||
log.Printf("[notify] ingest fresh again; offline checks resumed")
|
||||
}
|
||||
n.ingestStale = stale
|
||||
return stale
|
||||
}
|
||||
|
||||
// loop evaluates every interval until stop closes. The first evaluation
|
||||
// runs one interval after startup. A tick in flight sees its context
|
||||
// cancelled when stop closes, so a slow mail provider does not hold up
|
||||
// the shutdown.
|
||||
func (n *notifier) loop(stop <-chan struct{}, every time.Duration) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() {
|
||||
select {
|
||||
case <-stop:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
n.safeTick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// safeTick runs one evaluation; a panic is logged and the next tick runs.
|
||||
func (n *notifier) safeTick(ctx context.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[notify] evaluation panicked, the next one runs as planned: %v", r)
|
||||
}
|
||||
}()
|
||||
n.tick(ctx)
|
||||
}
|
||||
|
||||
// tick reads all preferences, watches and states (one query each), the
|
||||
// accounts and the analyzer snapshot they need, evaluates, deletes the
|
||||
// dropped states and writes the new ones, and then mails each user's
|
||||
// changes. Users without a preferences row and users with notifications
|
||||
// off are evaluated too (the evaluator gives the first the defaults and
|
||||
// keeps the second's states current without changes). A failed read or
|
||||
// state write ends the tick without mail; the next tick starts over. A
|
||||
// tick that evaluates logs one line with its counts, its duration and how
|
||||
// long LastHeardMap held the packet store's read lock.
|
||||
func (n *notifier) tick(ctx context.Context) {
|
||||
if !n.src.ready() {
|
||||
return
|
||||
}
|
||||
started := time.Now()
|
||||
now := n.now()
|
||||
stale := n.checkIngest(now)
|
||||
st := n.a.st
|
||||
prefs, err := st.AllNotifyPrefs()
|
||||
if err != nil {
|
||||
log.Printf("[notify] read preferences: %v", err)
|
||||
return
|
||||
}
|
||||
stored, err := st.AllWatches()
|
||||
if err != nil {
|
||||
log.Printf("[notify] read watches: %v", err)
|
||||
return
|
||||
}
|
||||
if len(prefs) == 0 && len(stored) == 0 {
|
||||
return
|
||||
}
|
||||
states, err := st.AllNotifyStates()
|
||||
if err != nil {
|
||||
log.Printf("[notify] read states: %v", err)
|
||||
return
|
||||
}
|
||||
// The analyzer snapshot is keyed by lowercase pubkey; normalise the
|
||||
// watches once so every lookup and state subject uses that form.
|
||||
watches := make([]users.NotifyWatch, 0, len(stored))
|
||||
watchSeen := make(map[users.NotifyKey]bool, len(stored))
|
||||
seen := map[string]bool{}
|
||||
var pubkeys []string
|
||||
for _, w := range stored {
|
||||
w.Pubkey = strings.ToLower(w.Pubkey)
|
||||
k := users.NotifyKey{UserID: w.UserID, Subject: w.Pubkey}
|
||||
if watchSeen[k] {
|
||||
continue
|
||||
}
|
||||
watchSeen[k] = true
|
||||
watches = append(watches, w)
|
||||
if !seen[w.Pubkey] {
|
||||
seen[w.Pubkey] = true
|
||||
pubkeys = append(pubkeys, w.Pubkey)
|
||||
}
|
||||
}
|
||||
byUser := make(map[int64]users.NotifyPrefs, len(prefs))
|
||||
var ids []int64
|
||||
for _, p := range prefs {
|
||||
byUser[p.UserID] = p
|
||||
ids = append(ids, p.UserID)
|
||||
}
|
||||
noPrefs := map[int64]bool{}
|
||||
for _, w := range watches {
|
||||
if _, ok := byUser[w.UserID]; !ok && !noPrefs[w.UserID] {
|
||||
noPrefs[w.UserID] = true
|
||||
ids = append(ids, w.UserID)
|
||||
}
|
||||
}
|
||||
accounts, err := st.UsersByID(ids)
|
||||
if err != nil {
|
||||
log.Printf("[notify] read accounts: %v", err)
|
||||
return
|
||||
}
|
||||
wantForeign, wantObservers := false, false
|
||||
for _, p := range prefs {
|
||||
if accounts[p.UserID].Role != users.RoleAdmin {
|
||||
continue
|
||||
}
|
||||
wantForeign = wantForeign || p.Has(users.NotifyForeignNew)
|
||||
wantObservers = wantObservers || p.Has(users.NotifyObserverOffline)
|
||||
}
|
||||
nodes, err := n.src.nodes(pubkeys, wantForeign)
|
||||
if err != nil {
|
||||
log.Printf("[notify] read nodes: %v", err)
|
||||
return
|
||||
}
|
||||
var infra []string
|
||||
for _, pk := range pubkeys {
|
||||
if nd, ok := nodes[pk]; ok && notifyInfra(nd.Role) {
|
||||
infra = append(infra, pk)
|
||||
}
|
||||
}
|
||||
var observers []notifyObserver
|
||||
if wantObservers {
|
||||
if observers, err = n.src.observers(); err != nil {
|
||||
log.Printf("[notify] read observers: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
heard, lock := n.src.lastHeard(pubkeys)
|
||||
res := evaluateNotifications(notifyInput{Now: now, Health: n.src.health(), LowMv: n.src.lowBatteryMv(),
|
||||
Accounts: accounts, Prefs: prefs, Watches: watches, States: states, Nodes: nodes,
|
||||
Heard: heard, Relayed: n.src.lastRelayed(infra), Observers: observers, IngestStale: stale, IngestResumedAt: n.ingestResumedAt})
|
||||
if err := st.DeleteNotifyStates(res.Drop); err != nil {
|
||||
log.Printf("[notify] delete dropped states, nothing mailed this time: %v", err)
|
||||
return
|
||||
}
|
||||
if len(res.States) > 0 {
|
||||
if err := st.WriteNotifyStates(res.States); err != nil {
|
||||
log.Printf("[notify] write states, nothing mailed this time: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
mails := n.deliver(ctx, now, res.Changes, accounts, byUser)
|
||||
changes := 0
|
||||
for _, list := range res.Changes {
|
||||
changes += len(list)
|
||||
}
|
||||
log.Printf("[notify] tick: users=%d changes=%d mails=%d took=%.2fms lock=%.2fms",
|
||||
len(accounts), changes, mails, msFloat(time.Since(started)), msFloat(lock))
|
||||
}
|
||||
|
||||
func msFloat(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }
|
||||
|
||||
// notifySkipReason says why a user's changes are not mailed; "" means send.
|
||||
// Skipped changes are already stored and are never mailed later.
|
||||
func notifySkipReason(u users.User, p users.NotifyPrefs, userMails, totalMails int, ns notifySettings) string {
|
||||
switch {
|
||||
case u.Status != users.StatusActive:
|
||||
return "account not active"
|
||||
case u.EmailBouncing:
|
||||
return "address bounces"
|
||||
case !p.Enabled:
|
||||
return "notifications off"
|
||||
case userMails >= ns.perUserPerDay:
|
||||
return "daily limit for this user reached"
|
||||
case totalMails >= ns.maxMailsPerDay:
|
||||
return "daily limit for this instance reached"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// deliver sends one mail per user with changes, within the limits counted
|
||||
// from mail_log over the last 24 hours (this tick's mails included). A user
|
||||
// with changes but no preferences row was evaluated with the defaults; the
|
||||
// row (and so the unsubscribe token) is created before the first mail.
|
||||
// Once ctx is cancelled (shutdown) the remaining users are not mailed;
|
||||
// their changes are already stored, like any other skipped change. It
|
||||
// returns the number of mails sent.
|
||||
func (n *notifier) deliver(ctx context.Context, now time.Time, changes map[int64][]notifyChange,
|
||||
accounts map[int64]users.User, prefs map[int64]users.NotifyPrefs) int {
|
||||
if len(changes) == 0 {
|
||||
return 0
|
||||
}
|
||||
total, perUser, err := n.a.st.NotifyMailCounts(now.Add(-24 * time.Hour))
|
||||
if err != nil {
|
||||
log.Printf("[notify] count mails, nothing mailed this time: %v", err)
|
||||
return 0
|
||||
}
|
||||
sent := 0
|
||||
ids := make([]int64, 0, len(changes))
|
||||
for uid := range changes {
|
||||
ids = append(ids, uid)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
for i, uid := range ids {
|
||||
if ctx.Err() != nil {
|
||||
log.Printf("[notify] shutting down: %d user(s) with changes not mailed", len(ids)-i)
|
||||
return sent
|
||||
}
|
||||
u := accounts[uid]
|
||||
p, ok := prefs[uid]
|
||||
if !ok {
|
||||
p = users.DefaultNotifyPrefs(uid)
|
||||
}
|
||||
if why := notifySkipReason(u, p, perUser[uid], total, n.a.set.notify); why != "" {
|
||||
log.Printf("[notify] user #%d: %d change(s) not mailed: %s", uid, len(changes[uid]), why)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
if p, err = n.a.st.NotifyPrefsFor(uid); err != nil {
|
||||
log.Printf("[notify] user #%d: create preferences, %d change(s) not mailed: %v", uid, len(changes[uid]), err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
sendCtx, cancel := context.WithTimeout(ctx, notifySendTimeout)
|
||||
err := n.a.sendMail(sendCtx, &u, users.NotifyMailPurpose, n.a.notifyMail(u, p, changes[uid]))
|
||||
cancel()
|
||||
if err != nil {
|
||||
continue // logged by sendMail; the changes stay recorded
|
||||
}
|
||||
total++
|
||||
perUser[uid]++
|
||||
sent++
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
// notifyMail is the one mail for all of a user's changes in one evaluation.
|
||||
func (a *authService) notifyMail(u users.User, p users.NotifyPrefs, changes []notifyChange) mailer.Message {
|
||||
base := a.set.baseURL.String()
|
||||
lines := make([]mailLine, 0, len(changes))
|
||||
for _, c := range changes {
|
||||
lines = append(lines, mailLine{text: notifyChangeText(c), url: notifySubjectURL(base, c)})
|
||||
}
|
||||
subject := fmt.Sprintf("%d changes on your watched nodes", len(changes))
|
||||
if len(changes) == 1 {
|
||||
subject = "1 change on your watched nodes"
|
||||
}
|
||||
msg := a.render(u.Email, u.DisplayName, "notify", mailContent{
|
||||
subject: subject, greeting: "Hello " + u.DisplayName + ",",
|
||||
paragraphs: []string{"Changes since the last check:"},
|
||||
lines: lines,
|
||||
actionLabel: "Manage notifications", actionURL: base + "/#/account?section=notifications",
|
||||
footer: &mailLine{text: "You get this mail because you asked " + a.set.baseURL.Host +
|
||||
" to watch these nodes. Turn all notification mails off with one click:", url: a.link("unsubscribe", p.UnsubToken)},
|
||||
})
|
||||
msg.Headers = map[string]string{
|
||||
"List-Unsubscribe": "<" + base + "/api/notifications/unsubscribe?token=" + url.QueryEscape(p.UnsubToken) + ">",
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// notifyChangeText is one line of the mail: name, what happened, when (UTC).
|
||||
func notifyChangeText(c notifyChange) string {
|
||||
bad := c.To == users.NotifyBad
|
||||
var what string
|
||||
switch c.Event {
|
||||
case users.NotifyNodeOffline:
|
||||
what = "back online"
|
||||
if bad {
|
||||
what = "offline"
|
||||
}
|
||||
case users.NotifyNodeBattery:
|
||||
what = "battery recovered"
|
||||
if bad {
|
||||
what = "battery low"
|
||||
}
|
||||
if c.BatteryMv != nil {
|
||||
what += fmt.Sprintf(" (%d mV)", *c.BatteryMv)
|
||||
}
|
||||
case users.NotifyForeignNew:
|
||||
what = "new foreign node"
|
||||
case users.NotifyObserverOffline:
|
||||
what = "observer back online"
|
||||
if bad {
|
||||
what = "observer offline"
|
||||
}
|
||||
}
|
||||
return c.Name + ": " + what + ", " + c.At.UTC().Format("2006-01-02 15:04 UTC")
|
||||
}
|
||||
|
||||
// notifySubjectURL links a change to its node or observer page.
|
||||
func notifySubjectURL(base string, c notifyChange) string {
|
||||
if c.Event == users.NotifyObserverOffline {
|
||||
return base + "/#/observers/" + url.PathEscape(c.Subject)
|
||||
}
|
||||
return base + "/#/nodes/" + url.PathEscape(c.Subject)
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/meshcore-analyzer/mailer"
|
||||
"github.com/meshcore-analyzer/users"
|
||||
)
|
||||
|
||||
type fakeNotifySource struct {
|
||||
isReady bool
|
||||
hs HealthThresholds
|
||||
low int
|
||||
nodeMap map[string]notifyNode
|
||||
heard map[string]time.Time
|
||||
relayed map[string]time.Time
|
||||
obs []notifyObserver
|
||||
newestAt func() time.Time // newest packet in the store; nil means none
|
||||
lock time.Duration // what lastHeard reports as its lock time
|
||||
panicNext bool
|
||||
heardAsked []string
|
||||
foreignAsked int
|
||||
observersAsked int
|
||||
}
|
||||
|
||||
func (f *fakeNotifySource) ready() bool { return f.isReady }
|
||||
func (f *fakeNotifySource) health() HealthThresholds { return f.hs }
|
||||
func (f *fakeNotifySource) lowBatteryMv() int { return f.low }
|
||||
func (f *fakeNotifySource) nodes(pubkeys []string, withForeign bool) (map[string]notifyNode, error) {
|
||||
if f.panicNext {
|
||||
f.panicNext = false
|
||||
panic("fake source panic")
|
||||
}
|
||||
out := map[string]notifyNode{}
|
||||
for _, pk := range pubkeys {
|
||||
if n, ok := f.nodeMap[pk]; ok {
|
||||
out[pk] = n
|
||||
}
|
||||
}
|
||||
if withForeign {
|
||||
f.foreignAsked++
|
||||
for pk, n := range f.nodeMap {
|
||||
if n.Foreign {
|
||||
out[pk] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeNotifySource) lastHeard(pks []string) (map[string]time.Time, time.Duration) {
|
||||
f.heardAsked = append([]string(nil), pks...)
|
||||
return f.heard, f.lock
|
||||
}
|
||||
func (f *fakeNotifySource) lastRelayed([]string) map[string]time.Time { return f.relayed }
|
||||
func (f *fakeNotifySource) newestPacket() time.Time {
|
||||
if f.newestAt == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return f.newestAt()
|
||||
}
|
||||
func (f *fakeNotifySource) observers() ([]notifyObserver, error) {
|
||||
f.observersAsked++
|
||||
return f.obs, nil
|
||||
}
|
||||
|
||||
type notifyClock struct{ t time.Time }
|
||||
|
||||
func (c *notifyClock) Now() time.Time { return c.t }
|
||||
func (c *notifyClock) Advance(d time.Duration) { c.t = c.t.Add(d) }
|
||||
|
||||
func defaultNotifySettings() notifySettings {
|
||||
return notifySettings{enabled: true, interval: 5 * time.Minute, perUserPerDay: 20, maxMailsPerDay: 300, maxWatchesPerUser: 50}
|
||||
}
|
||||
|
||||
type notifyFixture struct {
|
||||
*authFixture
|
||||
n *notifier
|
||||
src *fakeNotifySource
|
||||
clk *notifyClock
|
||||
}
|
||||
|
||||
// newNotifyFixture is newAuthFixture with notifications set to ns, a fake
|
||||
// analyzer source and one fixed clock for the notifier and users.db.
|
||||
// admin@example.org is a config admin.
|
||||
func newNotifyFixture(t *testing.T, ns notifySettings) *notifyFixture {
|
||||
t.Helper()
|
||||
a, fake := newTestAuthService(t, "admin@example.org")
|
||||
a.set.notify = ns
|
||||
clk := ¬ifyClock{t: time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)}
|
||||
a.st.SetClock(clk.Now)
|
||||
src := &fakeNotifySource{isReady: true, hs: (&Config{}).GetHealthThresholds(), low: 3300,
|
||||
nodeMap: map[string]notifyNode{}, heard: map[string]time.Time{}, relayed: map[string]time.Time{}, newestAt: clk.Now}
|
||||
if ns.enabled {
|
||||
a.notify = newNotifier(a, src, clk.Now)
|
||||
}
|
||||
srv := &Server{cfg: &Config{APIKey: testAPIKey}, perfStats: NewPerfStats(), auth: a}
|
||||
r := mux.NewRouter()
|
||||
srv.registerAuthRoutes(r)
|
||||
return ¬ifyFixture{authFixture: &authFixture{srv: srv, router: r, fake: fake, st: a.st}, n: a.notify, src: src, clk: clk}
|
||||
}
|
||||
|
||||
// watcher registers and activates a user with default prefs watching pubkeys.
|
||||
func (f *notifyFixture) watcher(t *testing.T, email, name string, pubkeys ...string) *client {
|
||||
t.Helper()
|
||||
c := f.registerAndActivate(t, email, name, pw)
|
||||
if _, err := f.st.NotifyPrefsFor(c.me.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := f.st.AddWatches(c.me.ID, pubkeys, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// setNode puts a node in the fake analyzer, last seen lastSeen before now.
|
||||
func (f *notifyFixture) setNode(pk, name, role string, lastSeen time.Duration, battery *int) {
|
||||
f.src.nodeMap[pk] = notifyNode{Pubkey: pk, Name: name, Role: role, LastSeen: f.clk.t.Add(-lastSeen).Format(time.RFC3339), BatteryMv: battery}
|
||||
}
|
||||
|
||||
func (f *notifyFixture) tick() { f.n.tick(context.Background()) }
|
||||
|
||||
func (f *notifyFixture) notifyMails() []mailer.Message {
|
||||
var out []mailer.Message
|
||||
for _, m := range f.fake.Sent() {
|
||||
if m.Tag == "notify" {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNotifierMailsAChangeAfterTheSilentFirstTick(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
pat := f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 0 {
|
||||
t.Fatalf("first tick mailed: %+v", m)
|
||||
}
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
f.tick()
|
||||
m := f.notifyMails()
|
||||
if len(m) != 1 {
|
||||
t.Fatalf("mails = %d; want 1", len(m))
|
||||
}
|
||||
p, _ := f.st.NotifyPrefsFor(pat.me.ID)
|
||||
if m[0].To != "pat@example.org" || m[0].Subject != "[CoreScope] 1 change on your watched nodes" ||
|
||||
!strings.Contains(m[0].Text, "Alpha: offline, 2026-10-08 13:00 UTC") ||
|
||||
!strings.Contains(m[0].Text, testBase+"/#/nodes/"+evPkA) ||
|
||||
!strings.Contains(m[0].Text, testBase+"/#/account?section=notifications") ||
|
||||
!strings.Contains(m[0].Text, testBase+"/#/account/unsubscribe?token="+p.UnsubToken) {
|
||||
t.Fatalf("mail = %+v", m[0])
|
||||
}
|
||||
if m[0].Headers["List-Unsubscribe"] != "<"+testBase+"/api/notifications/unsubscribe?token="+p.UnsubToken+">" ||
|
||||
m[0].Headers["List-Unsubscribe-Post"] != "List-Unsubscribe=One-Click" {
|
||||
t.Fatalf("headers = %v", m[0].Headers)
|
||||
}
|
||||
if total, per, err := f.st.NotifyMailCounts(f.clk.t.Add(-time.Hour)); err != nil || total != 1 || per[pat.me.ID] != 1 {
|
||||
t.Fatalf("mail_log notify rows = %d, %v, %v", total, per, err)
|
||||
}
|
||||
f.clk.Advance(5 * time.Minute)
|
||||
f.tick()
|
||||
if len(f.notifyMails()) != 1 {
|
||||
t.Fatal("an unchanged state was mailed again")
|
||||
}
|
||||
f.src.heard[evPkA] = f.clk.t.Add(-time.Minute)
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 2 || !strings.Contains(m[1].Text, "Alpha: back online") {
|
||||
t.Fatalf("back-online mail = %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierOneMailPerUserPerTick(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA, evPkB)
|
||||
f.watcher(t, "quinn@example.org", "Quinn", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.setNode(evPkB, "Bravo", "companion", time.Hour, nil)
|
||||
f.tick()
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
f.tick()
|
||||
m := f.notifyMails()
|
||||
if len(m) != 2 {
|
||||
t.Fatalf("mails = %d; want one per user", len(m))
|
||||
}
|
||||
byTo := map[string]mailer.Message{m[0].To: m[0], m[1].To: m[1]}
|
||||
pat, quinn := byTo["pat@example.org"], byTo["quinn@example.org"]
|
||||
if pat.Subject != "[CoreScope] 2 changes on your watched nodes" || !strings.Contains(pat.Text, "Alpha: offline") || !strings.Contains(pat.Text, "Bravo: offline") {
|
||||
t.Fatalf("Pat's mail = %+v", pat)
|
||||
}
|
||||
if quinn.Subject != "[CoreScope] 1 change on your watched nodes" || strings.Contains(quinn.Text, "Bravo") {
|
||||
t.Fatalf("Quinn's mail = %+v", quinn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierSkipsAreNeverMailedLater(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tune func(*notifySettings)
|
||||
block func(*notifyFixture, int64) error
|
||||
lift func(*notifyFixture, int64) error
|
||||
}{
|
||||
{"notifications off", nil,
|
||||
func(f *notifyFixture, uid int64) error {
|
||||
_, err := f.st.SetNotifyPrefs(uid, false, users.NodeNotifyEvents)
|
||||
return err
|
||||
},
|
||||
func(f *notifyFixture, uid int64) error {
|
||||
_, err := f.st.SetNotifyPrefs(uid, true, users.NodeNotifyEvents)
|
||||
return err
|
||||
}},
|
||||
{"address bounces", nil,
|
||||
func(f *notifyFixture, uid int64) error { return f.st.SetEmailBouncing(uid, true) },
|
||||
func(f *notifyFixture, uid int64) error { return f.st.SetEmailBouncing(uid, false) }},
|
||||
{"account disabled", nil,
|
||||
func(f *notifyFixture, uid int64) error { return f.st.SetStatus(uid, users.StatusDisabled) },
|
||||
func(f *notifyFixture, uid int64) error { return f.st.SetStatus(uid, users.StatusActive) }},
|
||||
{"per-user limit", func(ns *notifySettings) { ns.perUserPerDay = 1 },
|
||||
func(f *notifyFixture, uid int64) error {
|
||||
_, err := f.st.LogMail(&uid, "pat@example.org", users.NotifyMailPurpose, "")
|
||||
return err
|
||||
},
|
||||
func(f *notifyFixture, uid int64) error { f.clk.Advance(25 * time.Hour); return nil }},
|
||||
{"instance limit", func(ns *notifySettings) { ns.maxMailsPerDay = 1 },
|
||||
func(f *notifyFixture, uid int64) error {
|
||||
_, err := f.st.LogMail(nil, "gone@example.org", users.NotifyMailPurpose, "")
|
||||
return err
|
||||
},
|
||||
func(f *notifyFixture, uid int64) error { f.clk.Advance(25 * time.Hour); return nil }},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
ns := defaultNotifySettings()
|
||||
if c.tune != nil {
|
||||
c.tune(&ns)
|
||||
}
|
||||
f := newNotifyFixture(t, ns)
|
||||
pat := f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.tick()
|
||||
// Advance first, so a blocking mail_log row lies inside the
|
||||
// rolling 24 hours of the next tick.
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
if err := c.block(f, pat.me.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 0 {
|
||||
t.Fatalf("mailed while blocked: %+v", m)
|
||||
}
|
||||
if err := c.lift(f, pat.me.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 0 {
|
||||
t.Fatalf("a skipped change was mailed later: %+v", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierRestartAndNewWatchStaySilent(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
pat := f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.tick()
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
f.tick()
|
||||
if len(f.notifyMails()) != 1 {
|
||||
t.Fatal("no mail for the change")
|
||||
}
|
||||
restarted := newNotifier(f.srv.auth, f.src, f.clk.Now) // the states live in users.db
|
||||
f.setNode(evPkB, "Bravo", "companion", 48*time.Hour, nil)
|
||||
if _, _, _, err := f.st.AddWatches(pat.me.ID, []string{evPkB}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.clk.Advance(5 * time.Minute)
|
||||
restarted.tick(context.Background())
|
||||
if m := f.notifyMails(); len(m) != 1 {
|
||||
t.Fatalf("the restart or the new watch mailed: %+v", m[1:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierSendFailureIsNotRetried(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.tick()
|
||||
f.fake.SetSendErr(errors.New("provider down"))
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
f.tick()
|
||||
f.fake.SetSendErr(nil)
|
||||
f.clk.Advance(5 * time.Minute)
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 0 {
|
||||
t.Fatalf("a failed send was retried: %+v", m)
|
||||
}
|
||||
if total, _, _ := f.st.NotifyMailCounts(f.clk.t.Add(-48 * time.Hour)); total != 0 {
|
||||
t.Fatalf("mail_log has %d notify rows for a mail that never left", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierStateWriteFailureSendsNothing(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.tick()
|
||||
f.execDB(t, `CREATE TRIGGER notify_state_fail BEFORE UPDATE ON notification_state BEGIN SELECT RAISE(ABORT, 'boom'); END`)
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 0 {
|
||||
t.Fatalf("mailed although the states were not written: %+v", m)
|
||||
}
|
||||
f.execDB(t, `DROP TRIGGER notify_state_fail`)
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 1 {
|
||||
t.Fatalf("mails after the write works again = %d; want 1", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierWaitsForTheAnalyzer(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.src.isReady = false
|
||||
f.tick()
|
||||
if states, _ := f.st.AllNotifyStates(); len(states) != 0 {
|
||||
t.Fatalf("evaluated while the store was loading: %+v", states)
|
||||
}
|
||||
f.src.isReady = true
|
||||
f.tick()
|
||||
if states, _ := f.st.AllNotifyStates(); len(states) != 1 {
|
||||
t.Fatalf("states once ready = %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierPanicIsRecovered(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.src.panicNext = true
|
||||
f.n.safeTick(context.Background())
|
||||
f.tick()
|
||||
if states, _ := f.st.AllNotifyStates(); len(states) != 1 {
|
||||
t.Fatalf("the tick after the panic did not run: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierReadsForeignAndObserversOnlyForAdmins(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.tick()
|
||||
if f.src.foreignAsked != 0 || f.src.observersAsked != 0 {
|
||||
t.Fatalf("admin data read without an admin asking: foreign %d, observers %d", f.src.foreignAsked, f.src.observersAsked)
|
||||
}
|
||||
boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw)
|
||||
if _, err := f.st.SetNotifyPrefs(boss.me.ID, true, []string{users.NotifyForeignNew}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.tick()
|
||||
if f.src.foreignAsked != 1 || f.src.observersAsked != 0 {
|
||||
t.Fatalf("foreign %d, observers %d; want 1, 0", f.src.foreignAsked, f.src.observersAsked)
|
||||
}
|
||||
if err := f.st.SetRole(boss.me.ID, users.RoleUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.tick()
|
||||
if f.src.foreignAsked != 1 {
|
||||
t.Fatal("foreign nodes read for a demoted admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyMailEscapesNodeNames(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, `<b>Bad</b> & "co"`, "companion", time.Hour, nil)
|
||||
f.tick()
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
f.tick()
|
||||
m := f.notifyMails()
|
||||
if len(m) != 1 || strings.Contains(m[0].HTML, "<b>Bad") || !strings.Contains(m[0].HTML, "<b>Bad</b> & "co"") {
|
||||
t.Fatalf("HTML = %q", m[0].HTML)
|
||||
}
|
||||
}
|
||||
|
||||
// Names come from whoever owns a node or runs an observer: a line break
|
||||
// must not forge a line in the mail, a bidi override must not reorder it.
|
||||
func TestNotifyMailSanitisesNames(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
admin := f.registerAndActivate(t, "admin@example.org", "Ada", pw)
|
||||
if _, err := f.st.SetNotifyPrefs(admin.me.ID, true, []string{users.NotifyObserverOffline}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.setNode(evPkA, "x\r\nAccount suspended, sign in: https://evil.example\t\u202egnp.exe", "companion", time.Hour, nil)
|
||||
seen := f.clk.t.Add(-time.Minute).Format(time.RFC3339)
|
||||
f.src.obs = []notifyObserver{{ID: "OBS1", Name: "Roof\n\u2066Fake line\u2069", LastSeen: seen}, {ID: "OBS2", Name: "\n\u202e", LastSeen: seen}}
|
||||
f.tick()
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
f.tick()
|
||||
m := f.notifyMails()
|
||||
if len(m) != 2 {
|
||||
t.Fatalf("mails = %d, want 2", len(m))
|
||||
}
|
||||
for _, msg := range m {
|
||||
for _, part := range []string{msg.Text, msg.HTML} {
|
||||
for _, bad := range []string{"\r", "\t", "\u202e", "\u2066", "\u2069", "\nAccount", "\nFake"} {
|
||||
if strings.Contains(part, bad) {
|
||||
t.Fatalf("mail part contains %q:\n%s", bad, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
body := m[0].Text + m[1].Text
|
||||
for _, want := range []string{"- x Account suspended, sign in: https://evil.example gnp.exe: offline,",
|
||||
"- Roof Fake line: observer offline,", "- OBS2: observer offline,"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("text lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailSafeText(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"plain": "plain",
|
||||
" a \n\n b\r\tc ": "a b c",
|
||||
"a\u202eb\u2066c\u2069d": "a b c d",
|
||||
"a\u202ab\u202dc": "a b c",
|
||||
"a\u0085b\x7fc": "a b c",
|
||||
"\n\u202a": "",
|
||||
"Zo\u00eb \u00e9": "Zo\u00eb \u00e9",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := mailSafeText(in); got != want {
|
||||
t.Errorf("mailSafeText(%q) = %q; want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyChangeText(t *testing.T) {
|
||||
at := time.Date(2026, 10, 7, 9, 5, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
c notifyChange
|
||||
want string
|
||||
}{
|
||||
{notifyChange{Event: users.NotifyNodeOffline, Name: "A", To: users.NotifyBad, At: at}, "A: offline, 2026-10-07 09:05 UTC"},
|
||||
{notifyChange{Event: users.NotifyNodeOffline, Name: "A", To: users.NotifyGood, At: at}, "A: back online, 2026-10-07 09:05 UTC"},
|
||||
{notifyChange{Event: users.NotifyNodeBattery, Name: "A", To: users.NotifyBad, BatteryMv: evMv(3250), At: at}, "A: battery low (3250 mV), 2026-10-07 09:05 UTC"},
|
||||
{notifyChange{Event: users.NotifyNodeBattery, Name: "A", To: users.NotifyGood, BatteryMv: evMv(3410), At: at}, "A: battery recovered (3410 mV), 2026-10-07 09:05 UTC"},
|
||||
{notifyChange{Event: users.NotifyForeignNew, Name: "F", To: users.NotifyTold, At: at}, "F: new foreign node, 2026-10-07 09:05 UTC"},
|
||||
{notifyChange{Event: users.NotifyObserverOffline, Name: "O", To: users.NotifyBad, At: at}, "O: observer offline, 2026-10-07 09:05 UTC"},
|
||||
{notifyChange{Event: users.NotifyObserverOffline, Name: "O", To: users.NotifyGood, At: at}, "O: observer back online, 2026-10-07 09:05 UTC"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := notifyChangeText(c.c); got != c.want {
|
||||
t.Errorf("%q; want %q", got, c.want)
|
||||
}
|
||||
}
|
||||
if got := notifySubjectURL("https://e.org", notifyChange{Event: users.NotifyObserverOffline, Subject: "AB CD"}); got != "https://e.org/#/observers/AB%20CD" {
|
||||
t.Errorf("observer URL = %q", got)
|
||||
}
|
||||
if got := notifySubjectURL("https://e.org", notifyChange{Event: users.NotifyNodeOffline, Subject: evPkA}); got != "https://e.org/#/nodes/"+evPkA {
|
||||
t.Errorf("node URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifySkipReason(t *testing.T) {
|
||||
ns := defaultNotifySettings()
|
||||
ok := users.User{Status: users.StatusActive}
|
||||
on := users.NotifyPrefs{Enabled: true}
|
||||
cases := []struct {
|
||||
u users.User
|
||||
p users.NotifyPrefs
|
||||
mine, total int
|
||||
want string
|
||||
}{
|
||||
{ok, on, 0, 0, ""},
|
||||
{users.User{Status: users.StatusDisabled}, on, 0, 0, "account not active"},
|
||||
{users.User{Status: users.StatusActive, EmailBouncing: true}, on, 0, 0, "address bounces"},
|
||||
{ok, users.NotifyPrefs{}, 0, 0, "notifications off"},
|
||||
{ok, on, 20, 0, "daily limit for this user reached"},
|
||||
{ok, on, 19, 300, "daily limit for this instance reached"},
|
||||
{ok, on, 19, 299, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := notifySkipReason(c.u, c.p, c.mine, c.total, ns); got != c.want {
|
||||
t.Errorf("%+v %+v %d %d = %q; want %q", c.u, c.p, c.mine, c.total, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitUserManagementStartsTheNotifierOnlyWhenEnabled(t *testing.T) {
|
||||
for _, on := range []bool{false, true} {
|
||||
um := &UserManagementConfig{Enabled: true, PublicBaseURL: testBase,
|
||||
Mail: UserMailConfig{BrevoAPIKey: "k", FromEmail: "noreply@example.org"}}
|
||||
if on {
|
||||
um.Notifications = &NotificationsConfig{Enabled: true}
|
||||
}
|
||||
srv := &Server{cfg: &Config{UserManagement: um}}
|
||||
if err := srv.initUserManagement(filepath.Join(t.TempDir(), "meshcore.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if (srv.auth.notify != nil) != on {
|
||||
t.Fatalf("notifications %v: notifier = %v", on, srv.auth.notify)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { srv.closeUserManagement(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("closeUserManagement did not stop the notifier")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierDeletesAdminStatesOfADemotedAdmin(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw)
|
||||
if _, err := f.st.SetNotifyPrefs(boss.me.ID, true, []string{users.NotifyForeignNew}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.src.nodeMap[evPkB] = notifyNode{Pubkey: evPkB, Name: "Far", Foreign: true}
|
||||
f.tick()
|
||||
if states, _ := f.st.AllNotifyStates(); len(states) != 2 {
|
||||
t.Fatalf("baseline states = %+v; want the sentinel and Far", states)
|
||||
}
|
||||
if err := f.st.SetRole(boss.me.ID, users.RoleUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.tick()
|
||||
if states, _ := f.st.AllNotifyStates(); len(states) != 0 {
|
||||
t.Fatalf("states of a demoted admin = %+v; want none", states)
|
||||
}
|
||||
if err := f.st.SetRole(boss.me.ID, users.RoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 0 {
|
||||
t.Fatalf("re-promotion mailed: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierMailsAUserWithoutAPrefsRow(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
|
||||
if _, _, _, err := f.st.AddWatches(pat.me.ID, []string{evPkA}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.tick()
|
||||
if states, _ := f.st.AllNotifyStates(); len(states) != 1 {
|
||||
t.Fatalf("states = %+v; want the default node.offline baseline", states)
|
||||
}
|
||||
if prefs, _ := f.st.AllNotifyPrefs(); len(prefs) != 0 {
|
||||
t.Fatalf("a silent tick created prefs: %+v", prefs)
|
||||
}
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
f.tick()
|
||||
m := f.notifyMails()
|
||||
p, err := f.st.NotifyPrefsFor(pat.me.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m) != 1 || p.UnsubToken == "" || !strings.Contains(m[0].Text, "unsubscribe?token="+p.UnsubToken) ||
|
||||
m[0].Headers["List-Unsubscribe"] != "<"+testBase+"/api/notifications/unsubscribe?token="+p.UnsubToken+">" {
|
||||
t.Fatalf("mail = %+v, prefs = %+v", m, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierNormalisesMixedCaseWatches(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
pat := f.watcher(t, "pat@example.org", "Pat", strings.ToUpper(evPkA))
|
||||
if _, _, _, err := f.st.AddWatches(pat.me.ID, []string{evPkA}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) // keyed lowercase, like NotifyNodes
|
||||
f.src.heard[evPkA] = f.clk.t.Add(-time.Minute)
|
||||
f.tick()
|
||||
if len(f.src.heardAsked) != 1 || f.src.heardAsked[0] != evPkA {
|
||||
t.Fatalf("last heard asked for %v; want [%s]", f.src.heardAsked, evPkA)
|
||||
}
|
||||
states, _ := f.st.AllNotifyStates()
|
||||
if len(states) != 1 || states[0].Subject != evPkA || states[0].State != users.NotifyGood {
|
||||
t.Fatalf("states = %+v; want one good row under the lowercase key", states)
|
||||
}
|
||||
delete(f.src.heard, evPkA)
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 1 || !strings.Contains(m[0].Text, "Alpha: offline") ||
|
||||
strings.Count(m[0].Text, "Alpha:") != 1 {
|
||||
t.Fatalf("mail = %+v; want one Alpha offline line", m)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelAfterSend cancels the tick's context once a mail went out, like a
|
||||
// shutdown arriving mid-delivery.
|
||||
type cancelAfterSend struct {
|
||||
mailer.Mailer
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (c cancelAfterSend) Send(ctx context.Context, m mailer.Message) (string, error) {
|
||||
id, err := c.Mailer.Send(ctx, m)
|
||||
c.cancel()
|
||||
return id, err
|
||||
}
|
||||
|
||||
func TestNotifierStopsDeliveringAfterShutdown(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.watcher(t, "quinn@example.org", "Quinn", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.tick()
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
f.n.a.mail = cancelAfterSend{Mailer: f.n.a.mail, cancel: cancel}
|
||||
f.n.tick(ctx)
|
||||
if m := f.notifyMails(); len(m) != 1 {
|
||||
t.Fatalf("mails after a shutdown mid-delivery = %d; want 1", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierPausesOfflineChecksWhileIngestIsStale(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.tick() // baseline: online
|
||||
lastPacket := f.clk.t
|
||||
f.src.newestAt = func() time.Time { return lastPacket }
|
||||
|
||||
logs := captureLog(func() {
|
||||
f.clk.Advance(25 * time.Hour) // feed down for a day: Alpha looks offline
|
||||
f.tick()
|
||||
f.clk.Advance(5 * time.Minute)
|
||||
f.tick()
|
||||
})
|
||||
if m := f.notifyMails(); len(m) != 0 {
|
||||
t.Fatalf("mailed %d while ingest is stale", len(m))
|
||||
}
|
||||
paused := "[notify] ingest stale since " + lastPacket.UTC().Format(time.RFC3339) + "; offline checks paused"
|
||||
if n := strings.Count(logs, paused); n != 1 {
|
||||
t.Fatalf("pause logged %d times; want once:\n%s", n, logs)
|
||||
}
|
||||
|
||||
f.src.newestAt = f.clk.Now // feed back, Alpha still silent
|
||||
logs = captureLog(f.tick)
|
||||
if m := f.notifyMails(); len(m) != 0 {
|
||||
t.Fatalf("mailed %d on the first tick after recovery; offline waits one silent window", len(m))
|
||||
}
|
||||
if n := strings.Count(logs, "[notify] ingest fresh again; offline checks resumed"); n != 1 {
|
||||
t.Fatalf("resume logged %d times; want once:\n%s", n, logs)
|
||||
}
|
||||
f.clk.Advance(23 * time.Hour)
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 0 {
|
||||
t.Fatalf("mailed %d inside the window after recovery", len(m))
|
||||
}
|
||||
f.clk.Advance(time.Hour + time.Minute) // a full companion window after recovery
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 1 || !strings.Contains(m[0].Text, "Alpha: offline") {
|
||||
t.Fatalf("mails one window after recovery = %+v; want Alpha offline", m)
|
||||
}
|
||||
}
|
||||
|
||||
// A store that is already stale at startup gets the same grace once fresh.
|
||||
func TestNotifierGraceWhenStaleAtStartup(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.tick() // baseline: online (states persist in users.db)
|
||||
f.n = newNotifier(f.n.a, f.src, f.clk.Now) // restart
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
old := f.clk.t.Add(-25 * time.Hour)
|
||||
f.src.newestAt = func() time.Time { return old }
|
||||
f.tick() // stale at startup
|
||||
f.src.newestAt = f.clk.Now
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 0 {
|
||||
t.Fatalf("mailed %d right after a stale startup recovered", len(m))
|
||||
}
|
||||
f.clk.Advance(24*time.Hour + time.Minute)
|
||||
f.tick()
|
||||
if m := f.notifyMails(); len(m) != 1 {
|
||||
t.Fatalf("mails one window after recovery = %d; want 1", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierTreatsAnEmptyStoreAsStale(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.src.newestAt = nil
|
||||
if logs := captureLog(f.tick); !strings.Contains(logs, "[notify] ingest stale since (no packets); offline checks paused") {
|
||||
t.Fatalf("log = %q", logs)
|
||||
}
|
||||
if s, err := f.st.AllNotifyStates(); err != nil || len(s) != 0 {
|
||||
t.Fatalf("states with no packets in the store = %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierLogsOneLinePerTick(t *testing.T) {
|
||||
f := newNotifyFixture(t, defaultNotifySettings())
|
||||
f.watcher(t, "pat@example.org", "Pat", evPkA, evPkB)
|
||||
f.watcher(t, "quinn@example.org", "Quinn", evPkB)
|
||||
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
|
||||
f.setNode(evPkB, "Bravo", "companion", time.Hour, nil)
|
||||
f.src.lock = 1500 * time.Microsecond
|
||||
logs := captureLog(f.tick)
|
||||
if !regexp.MustCompile(`\[notify\] tick: users=2 changes=0 mails=0 took=\d+\.\d\dms lock=1\.50ms`).MatchString(logs) {
|
||||
t.Fatalf("first tick log = %q", logs)
|
||||
}
|
||||
f.clk.Advance(25 * time.Hour)
|
||||
f.src.newestAt = f.clk.Now
|
||||
logs = captureLog(f.tick)
|
||||
if n := strings.Count(logs, "[notify] tick:"); n != 1 || !strings.Contains(logs, "users=2 changes=3 mails=2 ") {
|
||||
t.Fatalf("second tick log (%d lines) = %q", n, logs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// notifySource is the analyzer side the notifier reads. serverNotifySource
|
||||
// is the production one; tests substitute a fake.
|
||||
type notifySource interface {
|
||||
ready() bool // false while the packet store is still loading after startup
|
||||
health() HealthThresholds
|
||||
lowBatteryMv() int
|
||||
nodes(pubkeys []string, withForeign bool) (map[string]notifyNode, error)
|
||||
lastHeard(pubkeys []string) (map[string]time.Time, time.Duration) // and how long the store lock was held
|
||||
lastRelayed(pubkeys []string) map[string]time.Time
|
||||
observers() ([]notifyObserver, error)
|
||||
newestPacket() time.Time // zero when the store holds no packet
|
||||
}
|
||||
|
||||
type serverNotifySource struct{ s *Server }
|
||||
|
||||
// ready needs /api/healthz readiness (best-observation pick and neighbor
|
||||
// graph done) and the finished startup load: hot window and background
|
||||
// fill. Readiness alone can come while the newest packets are still
|
||||
// loading.
|
||||
func (src serverNotifySource) ready() bool {
|
||||
if readiness.Load() == 0 || src.s.store == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-src.s.store.StartupLoadDone():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (src serverNotifySource) health() HealthThresholds { return src.s.cfg.GetHealthThresholds() }
|
||||
func (src serverNotifySource) lowBatteryMv() int { return src.s.cfg.LowBatteryMv() }
|
||||
|
||||
func (src serverNotifySource) nodes(pubkeys []string, withForeign bool) (map[string]notifyNode, error) {
|
||||
return src.s.db.NotifyNodes(pubkeys, withForeign)
|
||||
}
|
||||
|
||||
func (src serverNotifySource) lastHeard(pubkeys []string) (map[string]time.Time, time.Duration) {
|
||||
if src.s.store == nil {
|
||||
return map[string]time.Time{}, 0
|
||||
}
|
||||
return src.s.store.LastHeardMap(pubkeys)
|
||||
}
|
||||
|
||||
// lastRelayed reads the cached relay metrics (the map /api/nodes uses; the
|
||||
// background recomputer refreshes it). Pass repeaters and rooms only.
|
||||
func (src serverNotifySource) lastRelayed(pubkeys []string) map[string]time.Time {
|
||||
if src.s.store == nil || len(pubkeys) == 0 {
|
||||
return map[string]time.Time{}
|
||||
}
|
||||
return relayTimes(src.s.store.GetRepeaterRelayInfoMap(src.s.cfg.GetHealthThresholds().RelayActiveHours), pubkeys)
|
||||
}
|
||||
|
||||
func (src serverNotifySource) newestPacket() time.Time {
|
||||
if src.s.store == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return src.s.store.NewestFirstSeen()
|
||||
}
|
||||
|
||||
func (src serverNotifySource) observers() ([]notifyObserver, error) {
|
||||
list, err := src.s.db.GetObservers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return notifyObserversFrom(list), nil
|
||||
}
|
||||
|
||||
// relayTimes parses LastRelayed for the given keys; keys without a relay
|
||||
// time are absent.
|
||||
func relayTimes(m map[string]RepeaterRelayInfo, pubkeys []string) map[string]time.Time {
|
||||
out := make(map[string]time.Time, len(pubkeys))
|
||||
for _, pk := range pubkeys {
|
||||
info, ok := lookupRelayInfo(m, pk)
|
||||
if !ok || info.LastRelayed == "" {
|
||||
continue
|
||||
}
|
||||
if t, ok := parseRelayTS(info.LastRelayed); ok {
|
||||
out[pk] = t
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func notifyObserversFrom(list []Observer) []notifyObserver {
|
||||
out := make([]notifyObserver, 0, len(list))
|
||||
for _, o := range list {
|
||||
n := notifyObserver{ID: o.ID}
|
||||
if o.Name != nil {
|
||||
n.Name = *o.Name
|
||||
}
|
||||
if o.LastSeen != nil {
|
||||
n.LastSeen = *o.LastSeen
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// notifyNodeChunk bounds the IN list of one query, far below SQLite's
|
||||
// variable limit.
|
||||
const notifyNodeChunk = 500
|
||||
|
||||
const notifyNodeCols = `SELECT public_key, COALESCE(name, ''), COALESCE(role, ''), COALESCE(last_seen, ''), battery_mv, COALESCE(foreign_advert, 0) FROM nodes`
|
||||
|
||||
// NotifyNodes reads the node rows notifications need: the given pubkeys
|
||||
// (primary-key lookups, in chunks) and, with withForeign, every node with
|
||||
// foreign_advert = 1 (partial index idx_nodes_foreign_advert). Keyed by
|
||||
// lowercase pubkey. Read-only, on the server's mode=ro handle.
|
||||
func (db *DB) NotifyNodes(pubkeys []string, withForeign bool) (map[string]notifyNode, error) {
|
||||
out := make(map[string]notifyNode, len(pubkeys))
|
||||
scan := func(rows *sql.Rows) error {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var n notifyNode
|
||||
var mv sql.NullInt64
|
||||
var foreign int
|
||||
if err := rows.Scan(&n.Pubkey, &n.Name, &n.Role, &n.LastSeen, &mv, &foreign); err != nil {
|
||||
return err
|
||||
}
|
||||
n.Pubkey = strings.ToLower(n.Pubkey)
|
||||
if mv.Valid {
|
||||
v := int(mv.Int64)
|
||||
n.BatteryMv = &v
|
||||
}
|
||||
n.Foreign = foreign != 0
|
||||
out[n.Pubkey] = n
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
for i := 0; i < len(pubkeys); i += notifyNodeChunk {
|
||||
chunk := pubkeys[i:min(i+notifyNodeChunk, len(pubkeys))]
|
||||
args := make([]any, len(chunk))
|
||||
for j, pk := range chunk {
|
||||
args[j] = pk
|
||||
}
|
||||
rows, err := db.conn.Query(notifyNodeCols+` WHERE public_key IN (`+sqlPlaceholders(len(chunk))+`)`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := scan(rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if withForeign {
|
||||
rows, err := db.conn.Query(notifyNodeCols + ` WHERE foreign_advert = 1`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := scan(rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LastHeardMap returns, per pubkey with packets in the store, the newest
|
||||
// FirstSeen among them: the node page's "Last Heard" (GetNodeHealth) in
|
||||
// bulk, and how long the read lock was held (logged once per tick). One
|
||||
// read lock, O(packets of the given nodes), string compares only; parsing
|
||||
// happens after the lock is released.
|
||||
func (s *PacketStore) LastHeardMap(pubkeys []string) (map[string]time.Time, time.Duration) {
|
||||
latest := make(map[string]string, len(pubkeys))
|
||||
s.mu.RLock()
|
||||
locked := time.Now()
|
||||
for _, pk := range pubkeys {
|
||||
for _, tx := range s.byNode[pk] {
|
||||
if tx != nil && tx.FirstSeen > latest[pk] {
|
||||
latest[pk] = tx.FirstSeen
|
||||
}
|
||||
}
|
||||
}
|
||||
held := time.Since(locked)
|
||||
s.mu.RUnlock()
|
||||
out := make(map[string]time.Time, len(latest))
|
||||
for pk, ts := range latest {
|
||||
if t, ok := parseRelayTS(ts); ok {
|
||||
out[pk] = t
|
||||
}
|
||||
}
|
||||
return out, held
|
||||
}
|
||||
|
||||
// NewestFirstSeen is the FirstSeen of the newest transmission in the
|
||||
// store, zero when there is none or it does not parse. O(1): s.packets is
|
||||
// kept sorted by FirstSeen with the newest at the tail.
|
||||
func (s *PacketStore) NewestFirstSeen() time.Time {
|
||||
s.mu.RLock()
|
||||
var ts string
|
||||
if n := len(s.packets); n > 0 && s.packets[n-1] != nil {
|
||||
ts = s.packets[n-1].FirstSeen
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
t, _ := parseRelayTS(ts)
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDBNotifyNodes(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
for _, q := range []string{
|
||||
fmt.Sprintf(`INSERT INTO nodes (public_key, name, role, last_seen, battery_mv, foreign_advert) VALUES ('%s', 'Alpha', 'repeater', '2026-10-07T11:00:00Z', 3712, 0)`, evPkA),
|
||||
fmt.Sprintf(`INSERT INTO nodes (public_key, name, role, last_seen, foreign_advert) VALUES ('%s', NULL, 'companion', NULL, 0)`, evPkB),
|
||||
fmt.Sprintf(`INSERT INTO nodes (public_key, name, role, last_seen, foreign_advert) VALUES ('%s', 'Foxtrot', 'repeater', '2026-10-07T10:00:00Z', 1)`, evPkF),
|
||||
} {
|
||||
if _, err := db.conn.Exec(q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := db.NotifyNodes([]string{evPkA, evPkB, evPkG}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, b := got[evPkA], got[evPkB]
|
||||
if len(got) != 2 || a.Pubkey != evPkA || a.Name != "Alpha" || a.Role != "repeater" || a.LastSeen != "2026-10-07T11:00:00Z" ||
|
||||
a.BatteryMv == nil || *a.BatteryMv != 3712 || a.Foreign {
|
||||
t.Fatalf("NotifyNodes = %+v", got)
|
||||
}
|
||||
if b.Name != "" || b.LastSeen != "" || b.BatteryMv != nil {
|
||||
t.Fatalf("NULL columns = %+v", b)
|
||||
}
|
||||
got, err = db.NotifyNodes([]string{evPkA}, true)
|
||||
if err != nil || len(got) != 2 || !got[evPkF].Foreign || got[evPkF].Name != "Foxtrot" {
|
||||
t.Fatalf("with foreign = %+v, %v", got, err)
|
||||
}
|
||||
if got, err := db.NotifyNodes(nil, false); err != nil || len(got) != 0 {
|
||||
t.Fatalf("no keys = %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBNotifyNodesChunksLongLists(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
keys := make([]string, 0, 1200)
|
||||
for i := 0; i < 1200; i++ {
|
||||
keys = append(keys, fmt.Sprintf("%064x", i))
|
||||
}
|
||||
for _, i := range []int{3, 600, 1199} {
|
||||
if _, err := db.conn.Exec(`INSERT INTO nodes (public_key, role) VALUES (?, 'companion')`, keys[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := db.NotifyNodes(keys, false)
|
||||
if err != nil || len(got) != 3 {
|
||||
t.Fatalf("1200 keys over %d-key chunks = %d nodes, %v; want 3", notifyNodeChunk, len(got), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketStoreLastHeardMap(t *testing.T) {
|
||||
s := &PacketStore{byNode: map[string][]*StoreTx{
|
||||
evPkA: {{FirstSeen: "2026-10-07T10:00:00Z"}, {FirstSeen: "2026-10-07T11:30:00.000Z"}, nil, {FirstSeen: "2026-10-07T09:00:00Z"}},
|
||||
evPkB: {{FirstSeen: "not a time"}},
|
||||
}}
|
||||
got, lock := s.LastHeardMap([]string{evPkA, evPkB, evPkF})
|
||||
if len(got) != 1 || !got[evPkA].Equal(time.Date(2026, 10, 7, 11, 30, 0, 0, time.UTC)) {
|
||||
t.Fatalf("LastHeardMap = %v", got)
|
||||
}
|
||||
if lock < 0 || lock > time.Second {
|
||||
t.Fatalf("lock time = %v", lock)
|
||||
}
|
||||
}
|
||||
|
||||
// 5,000 watched nodes with 20 packets each, the spec's upper figure. Quote
|
||||
// the result in the PR: this runs under the store's read lock.
|
||||
func BenchmarkLastHeardMap(b *testing.B) {
|
||||
s := &PacketStore{byNode: map[string][]*StoreTx{}}
|
||||
keys := make([]string, 0, 5000)
|
||||
for i := 0; i < 5000; i++ {
|
||||
pk := fmt.Sprintf("%064x", i)
|
||||
keys = append(keys, pk)
|
||||
for j := 0; j < 20; j++ {
|
||||
s.byNode[pk] = append(s.byNode[pk], &StoreTx{FirstSeen: evNow.Add(-time.Duration(j) * time.Minute).Format(time.RFC3339)})
|
||||
}
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.LastHeardMap(keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayTimes(t *testing.T) {
|
||||
m := map[string]RepeaterRelayInfo{
|
||||
evPkA: {LastRelayed: "2026-10-07T11:00:00Z"},
|
||||
evPkB: {},
|
||||
}
|
||||
got := relayTimes(m, []string{evPkA, evPkB, evPkF})
|
||||
if len(got) != 1 || !got[evPkA].Equal(time.Date(2026, 10, 7, 11, 0, 0, 0, time.UTC)) {
|
||||
t.Fatalf("relayTimes = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyObserversFrom(t *testing.T) {
|
||||
name, seen := "Roof", "2026-10-07T11:00:00Z"
|
||||
got := notifyObserversFrom([]Observer{{ID: "OBS1", Name: &name, LastSeen: &seen}, {ID: "OBS2"}})
|
||||
if len(got) != 2 || got[0] != (notifyObserver{ID: "OBS1", Name: "Roof", LastSeen: seen}) || got[1] != (notifyObserver{ID: "OBS2"}) {
|
||||
t.Fatalf("notifyObserversFrom = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerNotifySourceWithoutStore(t *testing.T) {
|
||||
src := serverNotifySource{s: &Server{cfg: &Config{}}}
|
||||
if heard, lock := src.lastHeard([]string{evPkA}); len(heard) != 0 || lock != 0 || len(src.lastRelayed([]string{evPkA})) != 0 {
|
||||
t.Fatal("a server without a packet store reported times")
|
||||
}
|
||||
if src.lowBatteryMv() != 3300 || src.health().InfraSilentHours != 72 {
|
||||
t.Fatal("defaults not taken from the config")
|
||||
}
|
||||
}
|
||||
|
||||
// The startup load (hot window plus background fill) must be finished, not
|
||||
// only /api/healthz readiness: readiness can be reached while the newest
|
||||
// packets are still loading, which would mail "offline" then "back online".
|
||||
func TestServerNotifySourceReadyWaitsForTheStartupLoad(t *testing.T) {
|
||||
readiness.Store(1)
|
||||
defer readiness.Store(0)
|
||||
store := &PacketStore{clockSkew: &ClockSkewEngine{}}
|
||||
src := serverNotifySource{s: &Server{cfg: &Config{}, store: store}}
|
||||
if src.ready() {
|
||||
t.Fatal("ready before the startup load finished")
|
||||
}
|
||||
store.signalStartupLoadDone()
|
||||
if !src.ready() {
|
||||
t.Fatal("not ready after readiness and the startup load")
|
||||
}
|
||||
readiness.Store(0)
|
||||
if src.ready() {
|
||||
t.Fatal("ready without readiness")
|
||||
}
|
||||
if (serverNotifySource{s: &Server{cfg: &Config{}}}).ready() {
|
||||
t.Fatal("ready without a packet store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketStoreNewestFirstSeen(t *testing.T) {
|
||||
if got := (&PacketStore{}).NewestFirstSeen(); !got.IsZero() {
|
||||
t.Fatalf("empty store = %v", got)
|
||||
}
|
||||
s := &PacketStore{packets: []*StoreTx{{FirstSeen: "2026-10-07T10:00:00Z"}, {FirstSeen: "2026-10-07T11:30:00.000Z"}}}
|
||||
if got := s.NewestFirstSeen(); !got.Equal(time.Date(2026, 10, 7, 11, 30, 0, 0, time.UTC)) {
|
||||
t.Fatalf("NewestFirstSeen = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,15 @@ func routeDescriptions() map[string]routeMeta {
|
||||
"POST /api/admin/proposals/{id}/revoke": {Summary: "Revoke an approved proposal (admin)", Description: "Body {note}. Approved only (409 otherwise). The ingestor stops decrypting the channel from its next refresh unless the channel is configured; stored messages stay. The name can be proposed again.", Tag: "users", Session: true},
|
||||
"POST /api/mail/brevo/webhook": {Summary: "Brevo delivery-event webhook", Description: "Authenticated with Authorization: Bearer <userManagement.mail.webhookSecret>. Registered only when the secret is set.", Tag: "users"},
|
||||
|
||||
// Node notifications (user management, part E)
|
||||
"GET /api/account/notifications": {Summary: "Get own notification settings", Description: "Returns {enabled, events, availableEvents, watches: [{pubkey, name, known, createdAt}], limits: {maxWatches, perUserPerDay, mailsLast24h}}. Creates the default settings (on, node.offline and node.battery) on first use. known is false once the node left the analyzer database. Registered only when userManagement.notifications.enabled.", Tag: "users", Session: true},
|
||||
"PUT /api/account/notifications": {Summary: "Change own notification settings", Description: "Body {enabled, events}. events from node.offline, node.battery and, for admins, foreign.new and observer.offline. Answers the GET body. 400 unknown event type, 403 an admin event chosen by a non-admin. Events left out lose their stored state, so choosing them again starts without a mail.", Tag: "users", Session: true},
|
||||
"PUT /api/account/notifications/watches/{pubkey}": {Summary: "Watch a node", Description: "Answers the GET body. 400 when pubkey is not 64 hex characters, 404 when the node is not in the analyzer database, 409 when userManagement.notifications.maxWatchesPerUser nodes are watched. Watching a watched node is 200.", Tag: "users", Session: true},
|
||||
"DELETE /api/account/notifications/watches/{pubkey}": {Summary: "Stop watching a node", Description: "Answers the GET body; removing a node that is not watched is 200. 400 when pubkey is not 64 hex characters.", Tag: "users", Session: true},
|
||||
"POST /api/account/notifications/watch-my-nodes": {Summary: "Watch the synced My nodes", Description: "Copies the pubkeys of the caller's synced meshcore-my-nodes setting into the watch list, up to the limit. Answers {added, already, skipped, account}; skipped counts malformed keys, nodes not in the analyzer database and nodes over the limit; account is the GET body.", Tag: "users", Session: true},
|
||||
"GET /api/notifications/unsubscribe": {Summary: "Unsubscribe link (redirect)", Description: "303 to <publicBaseUrl>/#/account/unsubscribe?token=, the confirm view. Changes nothing, so link scanners cannot unsubscribe anyone.", Tag: "users", QueryParams: []paramMeta{{Name: "token", Description: "The unsubscribe token from the mail", Type: "string"}}},
|
||||
"POST /api/notifications/unsubscribe": {Summary: "Turn notification mails off", Description: "No session and no Origin check: the confirm button and the mail provider's List-Unsubscribe-Post one-click both land here. 200 {ok, message}, also when already off; 410 for an unknown token. The token can do nothing else.", Tag: "users", QueryParams: []paramMeta{{Name: "token", Description: "The unsubscribe token from the mail", Type: "string"}}},
|
||||
|
||||
// Packets
|
||||
"GET /api/packets": {Summary: "List packets", Description: "Returns decoded packets with filtering, sorting, and pagination.", Tag: "packets",
|
||||
QueryParams: []paramMeta{
|
||||
|
||||
@@ -534,7 +534,7 @@ func (s *Server) clientUserManagement() *ClientUserManagement {
|
||||
if s.auth == nil {
|
||||
return nil
|
||||
}
|
||||
return &ClientUserManagement{Enabled: true, ChannelProposals: s.auth.set.proposals.enabled}
|
||||
return &ClientUserManagement{Enabled: true, ChannelProposals: s.auth.set.proposals.enabled, Notifications: s.auth.notify != nil}
|
||||
}
|
||||
|
||||
func (s *Server) handleConfigAreas(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+2
-1
@@ -1093,10 +1093,11 @@ type ClientConfigResponse struct {
|
||||
}
|
||||
|
||||
// ClientUserManagement tells the frontend that accounts exist, and whether
|
||||
// channel proposals are on.
|
||||
// channel proposals and node notifications are on.
|
||||
type ClientUserManagement struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ChannelProposals bool `json:"channelProposals,omitempty"`
|
||||
Notifications bool `json:"notifications,omitempty"`
|
||||
}
|
||||
|
||||
// CustomizerClientConfig is the operator-side customizer-modal knobs that
|
||||
|
||||
@@ -22,6 +22,7 @@ type UserManagementConfig struct {
|
||||
TrustedProxies []string `json:"trustedProxies,omitempty"`
|
||||
Mail UserMailConfig `json:"mail"`
|
||||
ChannelProposals *ChannelProposalsConfig `json:"channelProposals,omitempty"`
|
||||
Notifications *NotificationsConfig `json:"notifications,omitempty"`
|
||||
}
|
||||
|
||||
// UserMailConfig is userManagement.mail.
|
||||
@@ -76,6 +77,50 @@ func resolveProposals(c *ChannelProposalsConfig) proposalSettings {
|
||||
}
|
||||
}
|
||||
|
||||
// NotificationsConfig is userManagement.notifications
|
||||
// (docs/specs/2026-10-07-node-notifications-design.md). Off by default.
|
||||
type NotificationsConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IntervalMinutes int `json:"intervalMinutes,omitempty"`
|
||||
PerUserPerDay int `json:"perUserPerDay,omitempty"`
|
||||
MaxMailsPerDay int `json:"maxMailsPerDay,omitempty"`
|
||||
MaxWatchesPerUser int `json:"maxWatchesPerUser,omitempty"`
|
||||
}
|
||||
|
||||
// notifySettings is the resolved form; the zero value means off.
|
||||
type notifySettings struct {
|
||||
enabled bool
|
||||
interval time.Duration // between evaluations, at least a minute
|
||||
perUserPerDay int // notification mails per user per 24 hours
|
||||
maxMailsPerDay int // notification mails per instance per 24 hours
|
||||
maxWatchesPerUser int
|
||||
}
|
||||
|
||||
const (
|
||||
defaultNotifyIntervalMinutes = 5
|
||||
defaultNotifyPerUserPerDay = 20
|
||||
// defaultNotifyMaxMailsPerDay counts notification mail only. A third of
|
||||
// Brevo's free 300 a day, so activation, reset and address-change mail
|
||||
// (and other senders on the same account) still fit in the quota.
|
||||
defaultNotifyMaxMailsPerDay = 100
|
||||
defaultNotifyMaxWatches = 50
|
||||
)
|
||||
|
||||
// resolveNotifications fills the defaults for absent, zero or negative
|
||||
// values. The interval is in whole minutes, so its floor is one minute.
|
||||
func resolveNotifications(c *NotificationsConfig) notifySettings {
|
||||
if c == nil || !c.Enabled {
|
||||
return notifySettings{}
|
||||
}
|
||||
return notifySettings{
|
||||
enabled: true,
|
||||
interval: time.Duration(positiveOr(c.IntervalMinutes, defaultNotifyIntervalMinutes)) * time.Minute,
|
||||
perUserPerDay: positiveOr(c.PerUserPerDay, defaultNotifyPerUserPerDay),
|
||||
maxMailsPerDay: positiveOr(c.MaxMailsPerDay, defaultNotifyMaxMailsPerDay),
|
||||
maxWatchesPerUser: positiveOr(c.MaxWatchesPerUser, defaultNotifyMaxWatches),
|
||||
}
|
||||
}
|
||||
|
||||
// UserManagementEnabled reports whether optional accounts are on. Nil config
|
||||
// or absent section means off (the default).
|
||||
func (c *Config) UserManagementEnabled() bool {
|
||||
@@ -96,6 +141,7 @@ type userMgmtSettings struct {
|
||||
fromName string
|
||||
webhookSecret string
|
||||
proposals proposalSettings
|
||||
notify notifySettings
|
||||
}
|
||||
|
||||
const defaultSessionDays = 30
|
||||
@@ -172,6 +218,7 @@ func resolveUserManagement(u *UserManagementConfig, measurementDBPath string, ge
|
||||
return nil, errors.New("userManagement.mail.webhookSecret must be at least 16 characters")
|
||||
}
|
||||
set.proposals = resolveProposals(u.ChannelProposals)
|
||||
set.notify = resolveNotifications(u.Notifications)
|
||||
return set, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -112,3 +112,31 @@ func TestResolveChannelProposals(t *testing.T) {
|
||||
t.Fatal("enabled false resolved as on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifications(t *testing.T) {
|
||||
u := validUM()
|
||||
set, err := resolveUserManagement(u, "meshcore.db", noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if set.notify != (notifySettings{}) {
|
||||
t.Fatalf("absent block = %+v; want off", set.notify)
|
||||
}
|
||||
defaults := notifySettings{enabled: true, interval: 5 * time.Minute, perUserPerDay: 20, maxMailsPerDay: 100, maxWatchesPerUser: 50}
|
||||
u.Notifications = &NotificationsConfig{Enabled: true}
|
||||
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify != defaults {
|
||||
t.Fatalf("defaults = %+v", set.notify)
|
||||
}
|
||||
u.Notifications = &NotificationsConfig{Enabled: true, IntervalMinutes: 1, PerUserPerDay: 3, MaxMailsPerDay: 9, MaxWatchesPerUser: 2}
|
||||
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify != (notifySettings{enabled: true, interval: time.Minute, perUserPerDay: 3, maxMailsPerDay: 9, maxWatchesPerUser: 2}) {
|
||||
t.Fatalf("explicit = %+v", set.notify)
|
||||
}
|
||||
u.Notifications = &NotificationsConfig{Enabled: true, IntervalMinutes: -5, PerUserPerDay: -1, MaxWatchesPerUser: -2}
|
||||
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify != defaults {
|
||||
t.Fatalf("zero and negative values = %+v; want defaults", set.notify)
|
||||
}
|
||||
u.Notifications = &NotificationsConfig{Enabled: false, PerUserPerDay: 3}
|
||||
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify.enabled {
|
||||
t.Fatal("enabled false resolved as on")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,10 +448,18 @@
|
||||
"maxPending": 100,
|
||||
"maxApproved": 128,
|
||||
"perUserPerDay": 5
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": false,
|
||||
"intervalMinutes": 5,
|
||||
"perUserPerDay": 20,
|
||||
"maxMailsPerDay": 100,
|
||||
"maxWatchesPerUser": 50
|
||||
}
|
||||
},
|
||||
"_comment_userManagement": "Optional accounts (off by default; see docs/user-guide/accounts.md). When enabled: visitors can register with email + password and activate through a mailed link; addresses in adminEmails become admins; admins manage users at #/admin/users and can use the operator endpoints without the apiKey. The dashboard stays public. dbPath defaults to users.db next to the analyzer database: a separate file, the server never writes measurement data. publicBaseUrl is required and is used for every link in every mail. Mail goes through Brevo's transactional API (a free account is enough); brevoApiKey and webhookSecret may instead come from CORESCOPE_BREVO_API_KEY and CORESCOPE_BREVO_WEBHOOK_SECRET. Set webhookSecret (16+ chars) and point a Brevo transactional webhook at <publicBaseUrl>/api/mail/brevo/webhook with bearer auth to see delivery status per user. trustedProxies (CIDRs) lets login rate limits use X-Forwarded-For behind a reverse proxy. Startup fails if enabled with an incomplete mail setup.",
|
||||
"_comment_channelProposals": "Optional, needs userManagement.enabled (docs/user-guide/accounts.md, Channel proposals). Logged-in users propose hashtag channels; admins approve, reject or revoke them on the Proposals tab. An approved channel is decrypted by the ingestor from its next refresh (once a minute, no restart) and listed for every visitor on the Channels page. maxApproved bounds the decryption work (every approved key is tried on every group message); perUserPerDay and maxPending limit proposals. The ingestor reads users.db read-only from userManagement.dbPath, or next to the analyzer database; set dbPath explicitly when the server and the ingestor are not given the same analyzer database path.",
|
||||
"_comment_notifications": "Optional, needs userManagement.enabled (docs/user-guide/accounts.md, Node notifications). Logged-in users watch nodes and get one mail per check when a watched node goes offline, comes back or reports a low battery; admins can also watch for new foreign nodes and observers going offline. Thresholds are healthThresholds and batteryThresholds. A check runs every intervalMinutes; at most perUserPerDay mails per user and maxMailsPerDay in total go out per 24 hours (over a limit the change is recorded and dropped, never queued). maxMailsPerDay counts notification mail only: keep it below the mail provider's daily quota so activation, password-reset and address-change mail still go out (the default 100 leaves 200 of Brevo's free 300 a day); maxWatchesPerUser bounds each watch list. Every mail has a one-click unsubscribe.",
|
||||
"customizer": {
|
||||
"disabledTabs": [],
|
||||
"_comment_disabledTabs": "Issue #1508. List of customizer-modal tab ids to hide from end users. Useful when operators want to expose only daily-use viewer controls and keep one-shot admin chrome out of the way. Recognized ids: branding, theme, nodes, home, display, geofilter, export. Example: [\"branding\", \"geofilter\", \"export\"] hides the admin tabs and leaves theme/colors/home/display visible. Default [] keeps the legacy behavior (all tabs visible). Operators edit this in config.json directly — there is no in-app UI for this list (by design)."
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
# Optional User Management, Sub-project E: Node Notifications, Design Spec
|
||||
|
||||
**Status:** written 2026-10-07 while the operator was away. The decisions in the table
|
||||
below were taken by Claude with its recommended option, as the operator asked; each one
|
||||
is open for review and can be reversed.
|
||||
**Builds on:** A (`docs/specs/2026-10-06-user-management-design.md`), B
|
||||
(`docs/specs/2026-10-06-user-settings-sync-design.md`), C
|
||||
(`docs/specs/2026-10-07-admin-dashboard-design.md`) and D
|
||||
(`docs/specs/2026-10-07-channel-proposals-design.md`). Roadmap #2128, part E.
|
||||
**Related issues:** #775 (alerting epic), #730 (foreign advert detection, implemented in
|
||||
the ingestor as `foreignAdverts.mode`), #663 (battery thresholds).
|
||||
|
||||
## Problem
|
||||
|
||||
A sysop learns that their repeater went silent, or that its battery is running down, only
|
||||
by opening CoreScope and looking. #775 asks for notifications on network events. With
|
||||
accounts (A) there is now a person and a verified mail address to notify, and a mailer
|
||||
with delivery status.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A logged-in user picks nodes to watch and gets one mail when a watched node goes
|
||||
offline, comes back, or reports a low battery.
|
||||
2. Admins can additionally watch the instance: a new foreign node appears, or an observer
|
||||
goes offline.
|
||||
3. Mail volume stays bounded per user and per instance, and every mail carries a
|
||||
one-click unsubscribe.
|
||||
4. Off by default. With the feature off, nothing changes.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Other channels (Discord, Telegram, push, outbound webhooks). The design keeps the event
|
||||
detection separate from delivery so a second channel can be added later.
|
||||
- The topology, RF and anomaly alerts of #775 (mesh split, SNR degradation, noise
|
||||
spikes, clock drift). They need their own thresholds and validation.
|
||||
- Notifications for visitors without an account.
|
||||
- Per-user thresholds. The instance's `healthThresholds` and `batteryThresholds` apply.
|
||||
|
||||
## Decisions (taken by Claude, open for review)
|
||||
|
||||
| # | Topic | Decision | Alternative not taken |
|
||||
|---|---|---|---|
|
||||
| 1 | Who | Logged-in active users watch nodes; admins also get two instance-wide event types. | Admin-only notifications. |
|
||||
| 2 | What to watch | An explicit watch list in `users.db`, filled from a "Notify me" toggle on the node page, with a one-click "Watch my nodes" that copies the synced `meshcore-my-nodes` list (B). | Watch every node in the synced "my nodes" list automatically. |
|
||||
| 3 | Events | Node offline, node back online, node battery low (and recovered, in the same mail as other changes); admins: new foreign node, observer offline and back. | All of #775. |
|
||||
| 4 | Detection | A server loop every 5 minutes compares current state with the last state stored per (user, subject, event) in `users.db`. | Event hooks inside packet ingest. |
|
||||
| 5 | Restart behaviour | A subject's first evaluation stores the state without mailing, so a restart or a new watch never sends a burst. | Mail on the first evaluation. |
|
||||
| 6 | Delivery | Mail only; all changes for one user in one evaluation go into one mail. | One mail per event. |
|
||||
| 7 | Limits | Per user at most 20 notification mails per day; per instance at most 300 per day (Brevo's free tier); at most 50 watched nodes per user. Over a limit, the change is recorded and skipped, never queued. | A queue that sends later. |
|
||||
| 8 | Unsubscribe | Each mail has a one-click unsubscribe link (and `List-Unsubscribe` + `List-Unsubscribe-Post` headers) that turns notifications off for that user; the account page turns them back on. | Link to the account page only. |
|
||||
|
||||
## Events
|
||||
|
||||
All thresholds are the instance's existing configuration, so a notification agrees with
|
||||
what the node page shows.
|
||||
|
||||
| Event | Subject | Goes to "bad" when | Goes back to "good" when |
|
||||
|---|---|---|---|
|
||||
| `node.offline` | node pubkey | the node is silent: no traffic for `healthThresholds` silent hours for its role (infra 72 h, others 24 h); for repeaters and rooms the later of last heard and last relayed counts (#1598), as on the node page | the node is heard again within the silent window |
|
||||
| `node.battery` | node pubkey | the latest advert telemetry `battery_mv` is below `batteryThresholds.lowMv` (3300) | `battery_mv` is at or above `lowMv + 100` (hysteresis against flapping around the threshold) |
|
||||
| `foreign.new` (admin) | node pubkey | a node with `foreign_advert = 1` that this admin has not been told about | no "good" transition; one mail per node, ever |
|
||||
| `observer.offline` (admin) | observer id | `last_seen` older than `healthThresholds.observerStaleMinutes` (1440) | `last_seen` within `observerOnlineMinutes` (60) |
|
||||
|
||||
A node without battery telemetry never triggers `node.battery`. A node that disappears
|
||||
from the analyzer database (retention) is treated as offline once and then dropped from
|
||||
evaluation; its watch stays until the user removes it.
|
||||
|
||||
## Storage (`internal/users`, schema v5)
|
||||
|
||||
```
|
||||
CREATE TABLE notification_prefs (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
events TEXT NOT NULL DEFAULT '', -- comma list of opted-in event types
|
||||
unsub_token TEXT NOT NULL UNIQUE, -- random, 32 bytes base64url
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE notification_watches (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
pubkey TEXT NOT NULL, -- lowercase hex
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, pubkey)
|
||||
);
|
||||
CREATE TABLE notification_state (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
state TEXT NOT NULL, -- 'good' | 'bad' | 'told' (foreign.new)
|
||||
changed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, event, subject)
|
||||
);
|
||||
```
|
||||
|
||||
Defaults on first use: node events opted in, admin events opted out (an admin turns them
|
||||
on). State rows for subjects no longer watched are deleted when the watch is removed.
|
||||
Mail counts for the limits come from `mail_log` (purpose `notify`).
|
||||
|
||||
## Config
|
||||
|
||||
```json
|
||||
"userManagement": {
|
||||
"notifications": {
|
||||
"enabled": false,
|
||||
"intervalMinutes": 5,
|
||||
"perUserPerDay": 20,
|
||||
"maxMailsPerDay": 300,
|
||||
"maxWatchesPerUser": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Values at or below 0 fall back to the defaults. `intervalMinutes` has a floor of 1.
|
||||
|
||||
## Server (`cmd/server`, only when user management and notifications are on)
|
||||
|
||||
- **Evaluator.** Started in `initUserManagement` next to the janitor, stopped through the
|
||||
same `stop` channel and wait group, with `recover()` so a panic logs and the loop
|
||||
continues on the next tick. Each tick:
|
||||
1. Read all watches, prefs and states from `users.db` (one query each).
|
||||
2. Read the analyzer data it needs on the read-only connection: one
|
||||
`SELECT public_key, role, last_seen, battery_mv, foreign_advert, name FROM nodes`
|
||||
limited to watched pubkeys and foreign nodes, the relay times from the existing
|
||||
in-memory relay metrics, and the active observers.
|
||||
3. Compute each (user, event, subject) state, compare, collect transitions per user.
|
||||
4. For users with transitions: skip when the user is not active, has
|
||||
`email_bouncing`, has notifications off, or is over a limit; otherwise send one mail.
|
||||
5. Write the new states in one transaction, also for skipped users (so a skipped change
|
||||
is not mailed later).
|
||||
- **Routes** (withUser unless noted, all in OpenAPI under the `users` tag):
|
||||
- `GET /api/account/notifications`: prefs, watches (with node names), limits.
|
||||
- `PUT /api/account/notifications`: `{enabled, events}`; admin events refused for
|
||||
non-admins.
|
||||
- `PUT /api/account/notifications/watches/{pubkey}` and `DELETE ...`: add or remove a
|
||||
watch; 409 over `maxWatchesPerUser`, 400 for a malformed pubkey, 404 for a node not in
|
||||
the analyzer database.
|
||||
- `POST /api/account/notifications/watch-my-nodes`: copies the pubkeys from the
|
||||
user's synced `meshcore-my-nodes` (B) into the watch list, up to the limit; returns
|
||||
added and skipped counts.
|
||||
- `GET /api/notifications/unsubscribe?token=` (no session): shows a confirm page;
|
||||
`POST` with the token turns notifications off. The `List-Unsubscribe-Post` one-click
|
||||
POST goes to the same handler.
|
||||
- **Mailer.** `mailer.Message` gains a `Headers map[string]string` field, passed to
|
||||
Brevo's `headers`. Only `List-Unsubscribe` and `List-Unsubscribe-Post` are set.
|
||||
- **Mail content.** Subject `[<fromName>] <n> change(s) on your watched nodes`; one line
|
||||
per transition with node name, event, time, and a link to the node page; footer with
|
||||
the unsubscribe link and a link to the account page. Plain text and HTML through the
|
||||
existing `render` helper.
|
||||
- **Audit.** `notify.unsubscribe` (via link) and `notify.prefs` (changes on the account
|
||||
page). Watch add/remove is not audited (noise).
|
||||
- **Admin dashboard (C).** The Users card's mail buckets already count `notify` mail
|
||||
through `mail_log`; the Overview gains "notification mails today: n of maxMailsPerDay".
|
||||
|
||||
## Frontend
|
||||
|
||||
- **Node page** (side pane and full page): a "Notify me" toggle for logged-in users when
|
||||
the feature is on, showing the watch state; disabled with a hint at the limit.
|
||||
- **Account page:** a "Notifications" section: on/off, event checkboxes (admin events
|
||||
only for admins), the watch list with remove buttons, "Watch my nodes", and the
|
||||
per-day limit.
|
||||
- **Unsubscribe page:** `#/account/unsubscribe?token=` with one confirm button.
|
||||
- Every rendered value goes through `escapeHtml`; colors through CSS variables; the
|
||||
account section deep-links as `#/account?section=notifications`.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
- Watches are private: only the user sees their own list; admins see counts, not lists.
|
||||
- The unsubscribe token is random, per user, unrelated to the session, and only turns
|
||||
notifications off (it cannot read or change anything else).
|
||||
- No new personal data leaves `users.db`; mail goes only to verified, active addresses.
|
||||
|
||||
## Performance
|
||||
|
||||
One tick every 5 minutes: three small `users.db` queries, one analyzer query bounded by
|
||||
the number of distinct watched pubkeys plus foreign nodes (indexed on `public_key`, and a
|
||||
partial index exists for `foreign_advert`), and an in-memory comparison. No work is added
|
||||
to packet ingest, WebSocket broadcast or any request path. With 100 users x 50 watches
|
||||
the state table holds at most 5,000 rows per event type.
|
||||
|
||||
## Testing
|
||||
|
||||
- `internal/users`: migration v4 to v5; prefs defaults and token uniqueness; watches add,
|
||||
remove, limit; state upsert and cleanup on watch removal; cascade on user delete.
|
||||
- `cmd/server` evaluator (pure function from inputs to transitions, plus the loop with a
|
||||
fake mailer and a fixed clock): first evaluation sends nothing; offline and back;
|
||||
relay-aware infra; battery hysteresis at `lowMv` and `lowMv + 100`; foreign node once
|
||||
per admin; observer offline and back; one mail per user per tick; skipped for inactive,
|
||||
bouncing, disabled, over per-user limit, over instance limit, and those skips not
|
||||
mailed later; restart (states survive).
|
||||
- Routes: auth, CSRF, limits, 400/404/409, admin events refused for users, unsubscribe
|
||||
with a valid and an invalid token, feature off: routes absent and no loop started.
|
||||
- Mailer: `Headers` reach the Brevo request body.
|
||||
- Frontend unit (vm): toggle states, account section, escaping.
|
||||
- Playwright (e2etest build): a user watches a node, the account page lists it, the
|
||||
unsubscribe link turns notifications off.
|
||||
|
||||
## Amendments from the implementation plan
|
||||
|
||||
The plan (`docs/plans/2026-10-07-node-notifications.md`, untracked) decided these points
|
||||
where the spec was silent or did not match the code; all 18 were accepted as written,
|
||||
except where a ruling below changes one.
|
||||
|
||||
1. "Per day" is a rolling 24 hours counted from `mail_log`, like the proposal limit.
|
||||
2. Offline uses the node page's three timestamps: `nodes.last_seen`, the packet store's
|
||||
newest packet involving the node, and for repeaters and rooms `last_relayed`.
|
||||
3. No evaluation runs while the packet store is still loading after a restart; the first
|
||||
one runs one interval after startup.
|
||||
4. `foreign.new` keeps a per-admin baseline row (subject `*`): the first evaluation after
|
||||
opting in stores the current foreign nodes without mailing.
|
||||
5. Without a previous state, a battery between `lowMv` and `lowMv + 100` and an observer
|
||||
between `observerOnlineMinutes` and `observerStaleMinutes` start as good.
|
||||
6. A watched node missing from the analyzer database is offline (one change) and not
|
||||
evaluated for battery; the watch stays.
|
||||
7. States are written before mails are sent; a failed state write sends nothing and the
|
||||
next check tries again; a failed send is not retried.
|
||||
8. States exist only for chosen events; leaving an event out deletes its states. Admin
|
||||
events are evaluated only while the account is an admin.
|
||||
9. Preferences rows are created lazily (GET or PUT of the notification state, a watch
|
||||
add, `watch-my-nodes`) with notifications on and the two node events. Changed by
|
||||
ruling R1: users without a row are evaluated too.
|
||||
10. The unsubscribe GET only redirects to `#/account/unsubscribe?token=`; the POST takes
|
||||
the token from the query string without a session or Origin check, answers 200 also
|
||||
when notifications were already off, and 410 for an unknown token. The token is
|
||||
stored raw, never rotates and only turns notifications off.
|
||||
11. `watch-my-nodes` answers `{added, already, skipped, account}`.
|
||||
12. The watch routes answer the full notification state; watching an already watched
|
||||
node and unwatching an unwatched one answer 200.
|
||||
13. An admin event chosen by a non-admin answers 403; an unknown event answers 400.
|
||||
14. Pubkeys are 64 hex characters, lowercased on input; anything else answers 400.
|
||||
15. Mail: one subject line with the number of changes, one linked line per change, a
|
||||
"Manage notifications" button and a footer with the one-click unsubscribe link;
|
||||
every existing mail renders byte-identical.
|
||||
16. The admin overview's Users card shows mails in the last 24 hours against
|
||||
`maxMailsPerDay`, watched nodes and watching users; the audit filter gains
|
||||
`notify.*`.
|
||||
17. The e2etest build gains `GET /__e2e/unsubscribe-link?email=` so Playwright can follow
|
||||
an unsubscribe link without waiting for a real state change.
|
||||
18. Foreign `told` rows are never pruned; they are bounded by the foreign nodes the
|
||||
instance has seen.
|
||||
|
||||
### Rulings during the implementation
|
||||
|
||||
- R1. Users without a preferences row are evaluated with the default preferences
|
||||
(enabled, node events). Users with notifications off keep being evaluated: their
|
||||
states are written and nothing is mailed, so turning notifications on again never
|
||||
mails old changes.
|
||||
- R2. Inactive and bouncing accounts are skipped at send time, not in the evaluator;
|
||||
their states are still written, so the skipped changes are never mailed later.
|
||||
- R3. Offline takes the later of `last_seen` and the packet store's last heard (more
|
||||
lenient than `roles.js` `last_heard || last_seen`); in a rare case the mail says
|
||||
online while the node page says stale.
|
||||
- R4. Admin events of a user who is no longer an admin: their states are deleted, like
|
||||
opting out, so a re-promotion starts with a fresh baseline and no burst of mails. A
|
||||
demoted and re-promoted admin misses the foreign nodes seen in between.
|
||||
- R5. The server lowercases pubkeys at the route boundary before storing a watch (PUT
|
||||
and DELETE of a watch, `watch-my-nodes`); `nodes.public_key` is lowercase.
|
||||
- R6. The Notify-me toggle waits for the first `/api/auth/me` round trip before it
|
||||
decides whether a user is logged in; a failed state GET hides the toggle silently.
|
||||
- R7. `DB.NotifyNodes` reuses the existing SQL placeholder helper; `internal/users`
|
||||
gets one placeholder helper shared by the new and the two existing inline copies.
|
||||
- R8. Test and tooling details without behaviour change: the limit test advances the
|
||||
clock before writing the blocking `mail_log` row; the frontend unit test reads
|
||||
elements through the test document; the toggle computes its state on its own line
|
||||
before the HTML sink (XSS gate); gofmt runs only on touched files.
|
||||
|
||||
### Amendments from the final review
|
||||
|
||||
- F1 (I1). "Packet store loaded" (amendment 3) means the whole startup load: the hot
|
||||
window and the background fill (`PacketStore.StartupLoadDone`), on top of the
|
||||
`/api/healthz` readiness. Readiness alone can come while the newest packets are still
|
||||
loading, which would mail "offline" and then "back online" for a node whose recent
|
||||
packets were not loaded yet.
|
||||
- F2 (I2). Node and observer names in a notification mail pass one sanitiser
|
||||
(`mailSafeText`): control characters (CR, LF and tab included) and bidi overrides and
|
||||
isolates (U+202A-U+202E, U+2066-U+2069) become a space, runs of spaces collapse and
|
||||
the ends are trimmed. It applies to the plain-text and the HTML part; the HTML part is
|
||||
still escaped. A name that is empty afterwards falls back to the key prefix or the
|
||||
observer id.
|
||||
- F3 (I3). The default `maxMailsPerDay` is 100, not 300 (decision 7 and the Config
|
||||
example). It counts notification mail only (`mail_log` purpose `notify`); activation,
|
||||
reset and address-change mail share the provider's daily quota (Brevo free tier: 300
|
||||
for the whole account) and would fail at the provider on a day notifications used all
|
||||
of it. 100 leaves room for that mail and for other senders on the same account.
|
||||
- F4 (I4). While ingest is stale (the newest transmission in the packet store, read in
|
||||
O(1) from the tail of `PacketStore.packets`, is older than 30 minutes, or the store is
|
||||
empty), `node.offline` and `observer.offline` are not compared: no state is written or
|
||||
changed and nothing is mailed for them. `node.battery` and `foreign.new` go on. The
|
||||
evaluator stays pure (`notifyInput.IngestStale`); the notifier logs
|
||||
`[notify] ingest stale since <time>; offline checks paused` once per stale period and
|
||||
`[notify] ingest fresh again; offline checks resumed` when it ends. Without this, an
|
||||
MQTT or ingestor outage longer than a role's silent window mailed every watcher
|
||||
"offline" and later "back online".
|
||||
- F5 (I5). Every tick that evaluates logs one line:
|
||||
`[notify] tick: users=N changes=M mails=K took=Xms lock=Yms`, where `lock` is how long
|
||||
`PacketStore.LastHeardMap` held the store's read lock (timed inside the locked section
|
||||
and returned with the map). The lock time at live scale was an estimate; staging and
|
||||
live now report the measured value every interval. Code changes follow only if it is
|
||||
high.
|
||||
- F6 (I4, second round). Pausing alone only moved the false mails to the first check
|
||||
after recovery. The notifier keeps `IngestResumedAt` in memory, set when a stale
|
||||
period ends (also when the store was stale at startup and becomes fresh), and passes
|
||||
it to the evaluator. For `node.offline` and `observer.offline` with a stored good
|
||||
state, evidence older than `IngestResumedAt` counts as heard at that time, so no
|
||||
offline transition fires until a full silent window (per role; `observerStaleMinutes`
|
||||
for observers) has passed after recovery. A stored bad state is not lifted by it. A
|
||||
node that died during the outage is reported up to one window late; the grace is lost
|
||||
when the server restarts after the recovery and inside that window, and when the
|
||||
server starts after an outage that ended before its first check (no stale check is
|
||||
ever seen; seeding the grace at every start would delay real offline reports by a
|
||||
silent window after each deploy). A watched node
|
||||
missing from the analyzer database is still offline at once (no evidence to defer).
|
||||
@@ -87,6 +87,9 @@ tracking pixels automatically, and others block them.
|
||||
(accounts, registrations, active users, logins, mail of the last 7 days) and the system
|
||||
status (version, uptime, MQTT sources, observers). It refreshes every minute while the
|
||||
tab is visible. Each item links to the matching user list or audit entries.
|
||||
With node notifications on, the Users card also shows the notification mails of the
|
||||
last 24 hours against `maxMailsPerDay`, and how many nodes are watched by how many
|
||||
users (counts only; watch lists are private).
|
||||
- **Users**: the user list. *Bouncing mail only* shows addresses whose mail bounces.
|
||||
- **Audit**: every recorded action, newest first, filtered by action, period or user.
|
||||
Successful and failed logins are recorded without IP address and deleted after 90
|
||||
@@ -139,6 +142,53 @@ on the *Proposals* tab.
|
||||
Names that need the zero-width non-joiner (U+200C), as some Persian and Urdu
|
||||
spellings do, are refused as well.
|
||||
|
||||
### Node notifications (optional)
|
||||
|
||||
With node notifications on, logged-in users choose nodes to watch and get a mail when a
|
||||
watched node changes state. Admins can also watch the instance.
|
||||
|
||||
```json
|
||||
"userManagement": {
|
||||
"notifications": { "enabled": true, "intervalMinutes": 5, "perUserPerDay": 20, "maxMailsPerDay": 100, "maxWatchesPerUser": 50 }
|
||||
}
|
||||
```
|
||||
|
||||
- Events: a watched node goes offline or comes back, using the node page's thresholds:
|
||||
offline once it has not been heard for the `healthThresholds` silent hours of its role
|
||||
(heard is the latest of its last advert, the packet store's newest packet involving
|
||||
it, and for repeaters and rooms its last relay hop). A watched node also reports a
|
||||
battery below `batteryThresholds.lowMv`, and recovers at `lowMv + 100` mV. Admins can
|
||||
add a new foreign node (once per node) and an observer going offline
|
||||
(`observerStaleMinutes`) or back (`observerOnlineMinutes`).
|
||||
- A check runs every `intervalMinutes` (the first one an interval after startup, and only
|
||||
after the startup load). All changes for one user in one check go into one
|
||||
mail. The first check of a newly watched node stores its state without a mail; states
|
||||
are kept in `users.db`, so a restart does not mail again.
|
||||
- While ingest is stale (the newest packet in the packet store is older than 30 minutes,
|
||||
for example when the MQTT broker or the ingestor is down), the offline checks for
|
||||
nodes and observers pause: their states stay as they were and nothing is mailed for
|
||||
them. Battery and foreign-node checks go on. The server log says when the pause
|
||||
starts and ends. After the feed comes back, offline reports wait one silent window
|
||||
(the role's silent hours for nodes, `observerStaleMinutes` for observers): until then
|
||||
older evidence counts as heard at the moment the feed came back, so the outage itself
|
||||
mails nobody. A node that died during the outage is reported up to one window late.
|
||||
The server keeps this grace in memory: a restart after the recovery and inside that
|
||||
window loses it, and so does a server that starts after an outage that already ended
|
||||
before its first check (for example a reboot of the whole host after a long outage).
|
||||
- Limits: `perUserPerDay` mails per user and `maxMailsPerDay` in total, both over a
|
||||
rolling 24 hours. A change over a limit, for an account that is not active, for a
|
||||
bouncing address or while the user has notifications off is recorded and never mailed
|
||||
later. `maxWatchesPerUser` bounds each watch list.
|
||||
- `maxMailsPerDay` counts notification mail only; activation, password-reset and
|
||||
address-change mail are not counted and are not limited by it. They share the mail
|
||||
provider's daily quota, though (Brevo's free tier allows 300 a day for the whole
|
||||
account), so keep `maxMailsPerDay` well below that quota. The default 100 leaves 200
|
||||
a day for account mail and for any other sender on the same provider account.
|
||||
- Every mail carries a one-click unsubscribe link and `List-Unsubscribe` headers; the
|
||||
link turns notification mails off for that account and nothing else. The account page
|
||||
turns them back on.
|
||||
- Watch lists are private: admins see counts on the overview, not lists.
|
||||
|
||||
### Backups
|
||||
|
||||
`users.db` holds password hashes and addresses. Back it up together with the analyzer
|
||||
@@ -166,6 +216,10 @@ database, and protect it the same way. Deleting it removes all accounts and noth
|
||||
channel*, type the hashtag name, *Propose for everyone*. *My account, My proposals*
|
||||
shows the status and the admin's note. Approved channels are readable for every
|
||||
visitor of the instance.
|
||||
- **Node notifications** (when the operator turned them on): *Notify me* on a node's
|
||||
page watches it; *My account, Notifications* lists your watched nodes, turns mails on
|
||||
or off, chooses the events, and *Watch my nodes* copies your synced My nodes. You get
|
||||
at most one mail per check, and every mail has a link that turns the mails off.
|
||||
- **Never synced:** channel keys and decrypted messages, the API key, panel and column
|
||||
sizes, collapsed panels and map positions. They stay in the browser where you set them.
|
||||
- **Logging out** asks whether to keep your settings on this device (the default) or
|
||||
|
||||
@@ -46,12 +46,13 @@ type brevoAddress struct {
|
||||
}
|
||||
|
||||
type brevoSendRequest struct {
|
||||
Sender brevoAddress `json:"sender"`
|
||||
To []brevoAddress `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTMLContent string `json:"htmlContent,omitempty"`
|
||||
TextContent string `json:"textContent,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Sender brevoAddress `json:"sender"`
|
||||
To []brevoAddress `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTMLContent string `json:"htmlContent,omitempty"`
|
||||
TextContent string `json:"textContent,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
type brevoError struct {
|
||||
@@ -69,6 +70,7 @@ func (b *Brevo) Send(ctx context.Context, m Message) (string, error) {
|
||||
Subject: m.Subject,
|
||||
HTMLContent: m.HTML,
|
||||
TextContent: m.Text,
|
||||
Headers: m.Headers,
|
||||
}
|
||||
if m.Tag != "" {
|
||||
req.Tags = []string{m.Tag}
|
||||
|
||||
@@ -134,3 +134,41 @@ func TestBrevoSendRejectsEmptyContentAndNilClient(t *testing.T) {
|
||||
t.Fatal("expected connection error")
|
||||
}
|
||||
}
|
||||
func TestBrevoSendPassesHeaders(t *testing.T) {
|
||||
var body map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(b, &body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"messageId":"<m1>"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
b := NewBrevo("k", "noreply@example.org", "CoreScope")
|
||||
b.BaseURL = srv.URL
|
||||
hdr := map[string]string{
|
||||
"List-Unsubscribe": "<https://scope.example.org/api/notifications/unsubscribe?token=t>",
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
}
|
||||
if _, err := b.Send(context.Background(), Message{To: "a@example.org", Text: "t", Headers: hdr}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := body["headers"].(map[string]any)
|
||||
if !ok || got["List-Unsubscribe"] != hdr["List-Unsubscribe"] || got["List-Unsubscribe-Post"] != "List-Unsubscribe=One-Click" || len(got) != 2 {
|
||||
t.Fatalf("headers in the request = %#v", body["headers"])
|
||||
}
|
||||
body = nil
|
||||
if _, err := b.Send(context.Background(), Message{To: "a@example.org", Text: "t"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, present := body["headers"]; present {
|
||||
t.Fatalf("headers key sent without headers: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeKeepsHeaders(t *testing.T) {
|
||||
f := &Fake{}
|
||||
f.Send(context.Background(), Message{To: "a@example.org", Text: "t", Headers: map[string]string{"List-Unsubscribe": "<x>"}})
|
||||
if m, _, _ := f.Last(); m.Headers["List-Unsubscribe"] != "<x>" {
|
||||
t.Fatalf("fake lost the headers: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ type Message struct {
|
||||
Subject string
|
||||
HTML string
|
||||
Text string
|
||||
Tag string // provider tag, e.g. "activate"
|
||||
Tag string // provider tag, e.g. "activate"
|
||||
Headers map[string]string // extra mail headers, e.g. List-Unsubscribe; nil for none
|
||||
}
|
||||
|
||||
// Canonical delivery event names stored by CoreScope, independent of provider.
|
||||
|
||||
@@ -122,7 +122,7 @@ func (s *Store) PruneAudit(actions []string, maxAge time.Duration) (int64, error
|
||||
args = append(args, a)
|
||||
}
|
||||
args = append(args, unix(s.now())-int64(maxAge/time.Second))
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(actions)), ",")
|
||||
ph := placeholders(len(actions))
|
||||
res, err := s.db.Exec(`DELETE FROM audit_log WHERE action IN (`+ph+`) AND at < ?`, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
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"
|
||||
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}
|
||||
// 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 || 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, 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
nPkA = strings.Repeat("a", 64)
|
||||
nPkB = strings.Repeat("b", 64)
|
||||
nPkC = strings.Repeat("c", 64)
|
||||
)
|
||||
|
||||
func mustPrefs(t *testing.T, st *Store, uid int64) NotifyPrefs {
|
||||
t.Helper()
|
||||
p, err := st.NotifyPrefsFor(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("NotifyPrefsFor(%d): %v", uid, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func nState(uid int64, event, subject, state string, at time.Time) NotifyState {
|
||||
return NotifyState{NotifyKey: NotifyKey{UserID: uid, Event: event, Subject: subject}, State: state, ChangedAt: at}
|
||||
}
|
||||
|
||||
func statesByKey(t *testing.T, st *Store) map[NotifyKey]NotifyState {
|
||||
t.Helper()
|
||||
list, err := st.AllNotifyStates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[NotifyKey]NotifyState{}
|
||||
for _, s := range list {
|
||||
out[s.NotifyKey] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A users.db written by a v4 binary gains the notification tables and keeps its rows.
|
||||
func TestMigrateV4DatabaseToV5(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "users.db")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stmts := []string{`CREATE TABLE schema_version (version INTEGER NOT NULL)`, `INSERT INTO schema_version (version) VALUES (4)`}
|
||||
for _, m := range migrations[:4] {
|
||||
stmts = append(stmts, m...)
|
||||
}
|
||||
stmts = append(stmts, `INSERT INTO users (email, display_name, password_hash, created_at) VALUES ('old@example.org', 'Old', 'x', 1)`)
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("build v4 db: %v", err)
|
||||
}
|
||||
}
|
||||
db.Close()
|
||||
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open v4 db: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
if v, err := st.SchemaVersion(); err != nil || v != len(migrations) {
|
||||
t.Fatalf("SchemaVersion = %d, %v; want %d", v, err, len(migrations))
|
||||
}
|
||||
if len(migrations) < 5 {
|
||||
t.Fatal("migration v5 missing")
|
||||
}
|
||||
u, err := st.GetByEmail("old@example.org")
|
||||
if err != nil {
|
||||
t.Fatalf("v4 user lost: %v", err)
|
||||
}
|
||||
if p := mustPrefs(t, st, u.ID); !p.Enabled {
|
||||
t.Fatalf("prefs after migration = %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyPrefsDefaultsAndToken(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
a := mustCreate(t, st, "a@example.org", "A")
|
||||
b := mustCreate(t, st, "b@example.org", "B")
|
||||
p := mustPrefs(t, st, a.ID)
|
||||
if !p.Enabled || !reflect.DeepEqual(p.Events, []string{NotifyNodeOffline, NotifyNodeBattery}) ||
|
||||
len(p.UnsubToken) != 43 || !p.UpdatedAt.Equal(clk.Now()) {
|
||||
t.Fatalf("defaults = %+v", p)
|
||||
}
|
||||
if again := mustPrefs(t, st, a.ID); again.UnsubToken != p.UnsubToken {
|
||||
t.Fatal("a second read issued a new token")
|
||||
}
|
||||
if other := mustPrefs(t, st, b.ID); other.UnsubToken == p.UnsubToken {
|
||||
t.Fatal("two users share an unsubscribe token")
|
||||
}
|
||||
if _, err := st.NotifyPrefsFor(9999); err == nil {
|
||||
t.Fatal("prefs created for a user that does not exist")
|
||||
}
|
||||
all, err := st.AllNotifyPrefs()
|
||||
if err != nil || len(all) != 2 || all[0].UserID != a.ID || all[1].UserID != b.ID {
|
||||
t.Fatalf("AllNotifyPrefs = %+v, %v", all, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyEventHelpers(t *testing.T) {
|
||||
for _, e := range []string{NotifyNodeOffline, NotifyNodeBattery, NotifyForeignNew, NotifyObserverOffline} {
|
||||
if !ValidNotifyEvent(e) {
|
||||
t.Errorf("%s not valid", e)
|
||||
}
|
||||
}
|
||||
if ValidNotifyEvent("node.reboot") || ValidNotifyEvent("") {
|
||||
t.Error("unknown event accepted")
|
||||
}
|
||||
if IsAdminNotifyEvent(NotifyNodeOffline) || !IsAdminNotifyEvent(NotifyForeignNew) || !IsAdminNotifyEvent(NotifyObserverOffline) {
|
||||
t.Error("admin event classification wrong")
|
||||
}
|
||||
p := NotifyPrefs{Events: []string{NotifyNodeBattery}}
|
||||
if !p.Has(NotifyNodeBattery) || p.Has(NotifyNodeOffline) {
|
||||
t.Error("Has wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyPrefsCanonicalAndDropsStates(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
u := mustCreate(t, st, "a@example.org", "A")
|
||||
tok := mustPrefs(t, st, u.ID).UnsubToken
|
||||
if err := st.WriteNotifyStates([]NotifyState{
|
||||
nState(u.ID, NotifyNodeOffline, nPkA, NotifyGood, clk.Now()),
|
||||
nState(u.ID, NotifyNodeBattery, nPkA, NotifyBad, clk.Now()),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(time.Minute)
|
||||
p, err := st.SetNotifyPrefs(u.ID, false, []string{NotifyObserverOffline, NotifyNodeOffline, "bogus", NotifyNodeOffline})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Enabled || !reflect.DeepEqual(p.Events, []string{NotifyNodeOffline, NotifyObserverOffline}) ||
|
||||
p.UnsubToken != tok || !p.UpdatedAt.Equal(clk.Now()) {
|
||||
t.Fatalf("after set = %+v", p)
|
||||
}
|
||||
got := statesByKey(t, st)
|
||||
if _, ok := got[NotifyKey{u.ID, NotifyNodeOffline, nPkA}]; !ok || len(got) != 1 {
|
||||
t.Fatalf("states after dropping node.battery = %+v", got)
|
||||
}
|
||||
if _, err := st.SetNotifyPrefs(u.ID, true, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := statesByKey(t, st); len(got) != 0 {
|
||||
t.Fatalf("states with no events chosen = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableNotifyByToken(t *testing.T) {
|
||||
st, _ := newTestStore(t)
|
||||
u := mustCreate(t, st, "a@example.org", "A")
|
||||
tok := mustPrefs(t, st, u.ID).UnsubToken
|
||||
uid, was, err := st.DisableNotifyByToken(tok)
|
||||
if err != nil || uid != u.ID || !was {
|
||||
t.Fatalf("first = %d, %v, %v", uid, was, err)
|
||||
}
|
||||
if p := mustPrefs(t, st, u.ID); p.Enabled {
|
||||
t.Fatal("still enabled")
|
||||
}
|
||||
if uid, was, err = st.DisableNotifyByToken(tok); err != nil || uid != u.ID || was {
|
||||
t.Fatalf("second = %d, %v, %v; want the user, wasEnabled false", uid, was, err)
|
||||
}
|
||||
for _, bad := range []string{"", "nope"} {
|
||||
if _, _, err := st.DisableNotifyByToken(bad); !errors.Is(err, ErrTokenInvalid) {
|
||||
t.Errorf("token %q: err = %v; want ErrTokenInvalid", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddWatchesLimitAndDuplicates(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
u := mustCreate(t, st, "a@example.org", "A")
|
||||
added, already, over, err := st.AddWatches(u.ID, []string{nPkA, nPkB, nPkC, nPkA}, 2)
|
||||
if err != nil || added != 2 || already != 1 || over != 1 {
|
||||
t.Fatalf("AddWatches = %d added, %d already, %d over, %v; want 2, 1, 1", added, already, over, err)
|
||||
}
|
||||
if err := st.AddWatch(u.ID, nPkC, 2); !errors.Is(err, ErrWatchLimit) {
|
||||
t.Fatalf("AddWatch over the limit = %v", err)
|
||||
}
|
||||
if err := st.AddWatch(u.ID, nPkA, 2); err != nil {
|
||||
t.Fatalf("AddWatch of a watched node at the limit = %v", err)
|
||||
}
|
||||
if err := st.AddWatch(u.ID, nPkC, 0); err != nil {
|
||||
t.Fatalf("AddWatch without a limit = %v", err)
|
||||
}
|
||||
ws, err := st.WatchesFor(u.ID)
|
||||
if err != nil || len(ws) != 3 || ws[0].Pubkey != nPkA || ws[0].UserID != u.ID || !ws[0].CreatedAt.Equal(clk.Now()) {
|
||||
t.Fatalf("WatchesFor = %+v, %v", ws, err)
|
||||
}
|
||||
watches, watching, err := st.NotifyWatchStats()
|
||||
if err != nil || watches != 3 || watching != 1 {
|
||||
t.Fatalf("NotifyWatchStats = %d, %d, %v", watches, watching, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveWatchDeletesItsNodeStates(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
u := mustCreate(t, st, "a@example.org", "A")
|
||||
if _, _, _, err := st.AddWatches(u.ID, []string{nPkA, nPkB}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := clk.Now()
|
||||
if err := st.WriteNotifyStates([]NotifyState{
|
||||
nState(u.ID, NotifyNodeOffline, nPkA, NotifyGood, now), nState(u.ID, NotifyNodeBattery, nPkA, NotifyBad, now),
|
||||
nState(u.ID, NotifyNodeOffline, nPkB, NotifyGood, now), nState(u.ID, NotifyObserverOffline, nPkA, NotifyGood, now),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.RemoveWatch(u.ID, nPkA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.RemoveWatch(u.ID, nPkA); err != nil {
|
||||
t.Fatalf("second remove = %v; want idempotent", err)
|
||||
}
|
||||
got := statesByKey(t, st)
|
||||
_, keepB := got[NotifyKey{u.ID, NotifyNodeOffline, nPkB}]
|
||||
_, keepObs := got[NotifyKey{u.ID, NotifyObserverOffline, nPkA}]
|
||||
if len(got) != 2 || !keepB || !keepObs {
|
||||
t.Fatalf("states after removing the watch = %+v", got)
|
||||
}
|
||||
all, err := st.AllWatches()
|
||||
if err != nil || len(all) != 1 || all[0].Pubkey != nPkB {
|
||||
t.Fatalf("AllWatches = %+v, %v", all, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNotifyStatesUpsertsAndSkipsDeletedUsers(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
u := mustCreate(t, st, "a@example.org", "A")
|
||||
gone := mustCreate(t, st, "b@example.org", "B")
|
||||
if err := st.Delete(gone.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t0 := clk.Now()
|
||||
if err := st.WriteNotifyStates([]NotifyState{
|
||||
nState(u.ID, NotifyNodeOffline, nPkA, NotifyGood, t0),
|
||||
nState(gone.ID, NotifyNodeOffline, nPkA, NotifyGood, t0),
|
||||
}); err != nil {
|
||||
t.Fatalf("write with a deleted user = %v; want the row skipped", err)
|
||||
}
|
||||
t1 := t0.Add(time.Hour)
|
||||
if err := st.WriteNotifyStates([]NotifyState{nState(u.ID, NotifyNodeOffline, nPkA, NotifyBad, t1)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := statesByKey(t, st)
|
||||
s, ok := got[NotifyKey{u.ID, NotifyNodeOffline, nPkA}]
|
||||
if len(got) != 1 || !ok || s.State != NotifyBad || !s.ChangedAt.Equal(t1) {
|
||||
t.Fatalf("states = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNotifyStatesRemovesOnlyTheKeys(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
u := mustCreate(t, st, "a@example.org", "A")
|
||||
now := clk.Now()
|
||||
if err := st.WriteNotifyStates([]NotifyState{
|
||||
nState(u.ID, NotifyNodeOffline, nPkA, NotifyGood, now),
|
||||
nState(u.ID, NotifyForeignNew, "*", NotifyGood, now),
|
||||
nState(u.ID, NotifyForeignNew, nPkB, NotifyTold, now),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.DeleteNotifyStates(nil); err != nil {
|
||||
t.Fatalf("empty delete: %v", err)
|
||||
}
|
||||
if err := st.DeleteNotifyStates([]NotifyKey{
|
||||
{u.ID, NotifyForeignNew, "*"},
|
||||
{u.ID, NotifyForeignNew, nPkB},
|
||||
{u.ID, NotifyObserverOffline, "OBS1"}, // no such row
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := statesByKey(t, st)
|
||||
if _, ok := got[NotifyKey{u.ID, NotifyNodeOffline, nPkA}]; len(got) != 1 || !ok {
|
||||
t.Fatalf("states after delete = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyRowsCascadeOnUserDelete(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
u := mustCreate(t, st, "a@example.org", "A")
|
||||
mustPrefs(t, st, u.ID)
|
||||
if _, _, _, err := st.AddWatches(u.ID, []string{nPkA}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.WriteNotifyStates([]NotifyState{nState(u.ID, NotifyNodeOffline, nPkA, NotifyGood, clk.Now())}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Delete(u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, table := range []string{"notification_prefs", "notification_watches", "notification_state"} {
|
||||
var n int
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n); err != nil || n != 0 {
|
||||
t.Errorf("%s rows after delete = %d, %v", table, n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyMailCounts(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
a := mustCreate(t, st, "a@example.org", "A")
|
||||
b := mustCreate(t, st, "b@example.org", "B")
|
||||
log := func(uid *int64, purpose string) {
|
||||
t.Helper()
|
||||
if _, err := st.LogMail(uid, "x@example.org", purpose, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
log(&a.ID, NotifyMailPurpose) // 25 hours before the count: outside the window
|
||||
clk.Advance(2 * time.Hour)
|
||||
log(&a.ID, NotifyMailPurpose)
|
||||
log(&a.ID, NotifyMailPurpose)
|
||||
log(&b.ID, NotifyMailPurpose)
|
||||
log(nil, NotifyMailPurpose) // account deleted since; still counts for the instance
|
||||
log(&a.ID, "activate")
|
||||
clk.Advance(23 * time.Hour)
|
||||
total, per, err := st.NotifyMailCounts(clk.Now().Add(-24 * time.Hour))
|
||||
if err != nil || total != 4 || per[a.ID] != 2 || per[b.ID] != 1 || len(per) != 2 {
|
||||
t.Fatalf("NotifyMailCounts = %d, %v, %v; want 4, {a: 2, b: 1}", total, per, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultNotifyPrefs(t *testing.T) {
|
||||
p := DefaultNotifyPrefs(7)
|
||||
if p.UserID != 7 || !p.Enabled || !reflect.DeepEqual(p.Events, NodeNotifyEvents) || p.UnsubToken != "" {
|
||||
t.Fatalf("DefaultNotifyPrefs = %+v", p)
|
||||
}
|
||||
p.Events[0] = "changed"
|
||||
if NodeNotifyEvents[0] != NotifyNodeOffline {
|
||||
t.Fatal("DefaultNotifyPrefs shares NodeNotifyEvents")
|
||||
}
|
||||
st, _ := newTestStore(t)
|
||||
u := mustCreate(t, st, "d@example.org", "D")
|
||||
got := mustPrefs(t, st, u.ID)
|
||||
want := DefaultNotifyPrefs(u.ID)
|
||||
if got.Enabled != want.Enabled || !reflect.DeepEqual(got.Events, want.Events) {
|
||||
t.Fatalf("NotifyPrefsFor created %+v; want the defaults %+v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -360,8 +360,8 @@ func TestMigrateV3DatabaseToV4(t *testing.T) {
|
||||
t.Fatalf("Open v3 db: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
if v, err := st.SchemaVersion(); err != nil || v != 4 {
|
||||
t.Fatalf("SchemaVersion = %d, %v; want 4", v, err)
|
||||
if v, err := st.SchemaVersion(); err != nil || v != len(migrations) {
|
||||
t.Fatalf("SchemaVersion = %d, %v; want %d", v, err, len(migrations))
|
||||
}
|
||||
if !hasIndex(t, st, "proposals_kind_subject") || !hasIndex(t, st, "proposals_status") {
|
||||
t.Fatal("proposals indexes missing after migration")
|
||||
|
||||
@@ -101,6 +101,29 @@ var migrations = [][]string{
|
||||
`CREATE UNIQUE INDEX proposals_kind_subject ON proposals(kind, subject)`,
|
||||
`CREATE INDEX proposals_status ON proposals(status)`,
|
||||
},
|
||||
{ // v5: sub-project E, node notifications
|
||||
`CREATE TABLE notification_prefs (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
events TEXT NOT NULL DEFAULT '',
|
||||
unsub_token TEXT NOT NULL UNIQUE,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE notification_watches (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
pubkey TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, pubkey)
|
||||
)`,
|
||||
`CREATE TABLE notification_state (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
changed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, event, subject)
|
||||
)`,
|
||||
},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -229,7 +229,7 @@ func (s *Store) UsersByID(ids []int64) (map[int64]User, error) {
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||
ph := placeholders(len(ids))
|
||||
rows, err := s.db.Query(`SELECT `+userCols+` FROM users WHERE id IN (`+ph+`)`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -86,3 +86,23 @@ a.account-btn { display: inline-flex; align-items: center; text-decoration: none
|
||||
.cs-dialog { display: flex; flex-direction: column; gap: 8px; color: var(--text); }
|
||||
.cs-dialog h3 { margin: 0; }
|
||||
.cs-dialog-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||
|
||||
/* Node notifications toggle (notifications.js) on the node side pane and full page. */
|
||||
.node-notify-slot { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-left: 4px; }
|
||||
.node-notify-slot:empty { display: none; }
|
||||
.node-notify-btn { white-space: nowrap; }
|
||||
/* Sized like the neighbouring buttons, which carry inline sizes in nodes.js
|
||||
(side pane: Details/Analytics/Reach; full page: Copy URL/Reach). */
|
||||
#nodeNotifySlot .node-notify-btn { font-size: 11px; padding: 2px 8px; }
|
||||
#nodeNotifySlotFull { margin-left: 0; } /* the full page row spaces its buttons with gap */
|
||||
#nodeNotifySlotFull .node-notify-btn { font-size: 12px; padding: 4px 10px; }
|
||||
.node-notify-btn[aria-pressed="true"] { box-shadow: inset 0 0 0 2px var(--status-green-text); }
|
||||
.node-notify-hint { color: var(--text-muted); font-size: var(--fs-sm); }
|
||||
.node-notify-hint:empty { display: none; }
|
||||
|
||||
/* Notifications section (notifications.js) on the account page. */
|
||||
.account-check { display: flex; align-items: center; gap: 8px; font-size: var(--fs-sm); }
|
||||
.account-fieldset { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; }
|
||||
#notifyForm + h4 { margin-top: var(--space-lg); }
|
||||
.account-watches { list-style: none; padding: 0; margin: 0 0 8px; }
|
||||
.account-watches li { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: var(--fs-sm); }
|
||||
|
||||
+30
-1
@@ -1,6 +1,7 @@
|
||||
/* Account pages for optional user management.
|
||||
* #/account/login | register | activate?token= | forgot | reset?token= | confirm-email?token= | check-mail
|
||||
* #/account : profile, password, address, sessions, my proposals, delete
|
||||
* #/account : profile, password, address, sessions, my proposals, notifications, delete
|
||||
* #/account/unsubscribe?token= : confirm turning notification mails off (link from a notification mail)
|
||||
* Every dynamic string goes through escapeHtml; messages use textContent. */
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -111,6 +112,7 @@
|
||||
submitBtn('Change address') + msgBox('emailMsg') + '</form>' +
|
||||
'<h3>Devices</h3><ul class="account-sessions" id="sessList"></ul>' + msgBox('sessMsg') +
|
||||
(window.CSProposals && window.CSProposals.enabled() ? '<h3>My proposals</h3><div id="propList"></div>' + msgBox('propMsg') : '') +
|
||||
(window.CSNotify && window.CSNotify.enabled() ? '<h3 id="notifications">Notifications</h3><div id="notifySection"></div>' + msgBox('notifyMsg') : '') +
|
||||
(window.CSSettingsSync ? '<h3>Settings sync</h3><div id="syncSection"></div>' : '') +
|
||||
'<h3>Delete account</h3><form id="delForm" class="account-form" novalidate>' +
|
||||
'<p class="account-hint">This removes your account permanently.</p>' +
|
||||
@@ -254,6 +256,27 @@
|
||||
if (p) p.then(function (r) { if (r && r.ok && CSAuth.user()) CSAuth.refreshMe(); });
|
||||
},
|
||||
|
||||
unsubscribe: function (app) {
|
||||
var token = takeToken('unsubscribe');
|
||||
app.innerHTML = shell('Stop notification mails',
|
||||
'<p class="account-hint">This turns off all node notification mails for the account the link was sent to. You can turn them back on from your account page.</p>' +
|
||||
'<button type="button" id="unsubBtn" class="account-btn account-btn-primary">Turn notifications off</button>' + msgBox() +
|
||||
'<p class="account-links"><a href="#/account?section=notifications">Notification settings</a></p>');
|
||||
var btn = document.getElementById('unsubBtn');
|
||||
if (!token) {
|
||||
btn.disabled = true;
|
||||
CSAuth.say('accountMsg', 'This link is incomplete. Open the link from the mail again.', false);
|
||||
return;
|
||||
}
|
||||
btn.addEventListener('click', function () {
|
||||
btn.disabled = true;
|
||||
return CSAuth.request('POST', '/api/notifications/unsubscribe?token=' + encodeURIComponent(token)).then(function (r) {
|
||||
CSAuth.say('accountMsg', r.ok ? r.data.message : CSAuth.errText(r), r.ok);
|
||||
if (!r.ok) btn.disabled = false;
|
||||
}, function () { btn.disabled = false; CSAuth.say('accountMsg', 'Network error, try again.', false); });
|
||||
});
|
||||
},
|
||||
|
||||
profile: function (app) {
|
||||
var u = CSAuth.user();
|
||||
if (!u) { location.hash = '#/account/login'; return; }
|
||||
@@ -263,6 +286,12 @@
|
||||
document.getElementById('profName').value = u.displayName;
|
||||
if (window.CSSettingsSync) window.CSSettingsSync.mountSection(document.getElementById('syncSection'));
|
||||
if (window.CSProposals && window.CSProposals.enabled()) window.CSProposals.loadMine(document.getElementById('propList'), 'propMsg');
|
||||
if (window.CSNotify && window.CSNotify.enabled()) {
|
||||
window.CSNotify.mountSection(document.getElementById('notifySection'), 'notifyMsg').then(function () {
|
||||
var h = query().get('section') === 'notifications' && document.getElementById('notifications');
|
||||
if (h && h.scrollIntoView) h.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('accountPageLogout').addEventListener('click', function () {
|
||||
return CSAuth.logout('#/account/login').then(function (r) {
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
['user.password.*', 'Password changes and resets'],
|
||||
['user.email.*', 'Address changes'],
|
||||
['user.mail.refresh', 'Mail status refreshed'],
|
||||
['proposal.*', 'Channel proposals']
|
||||
['proposal.*', 'Channel proposals'],
|
||||
['notify.*', 'Notification settings']
|
||||
];
|
||||
var DAY_MS = 24 * 60 * 60 * 1000;
|
||||
var PERIODS = [['', 'Any time', 0], ['24h', 'Last 24 hours', DAY_MS], ['7d', 'Last 7 days', 7 * DAY_MS], ['30d', 'Last 30 days', 30 * DAY_MS]];
|
||||
|
||||
@@ -79,6 +79,14 @@
|
||||
return html + '</div>';
|
||||
}
|
||||
|
||||
// Notification figures; the server sends them only with notifications on.
|
||||
function notifyStatsHtml(n) {
|
||||
if (!n) return '';
|
||||
return '<h4>Notifications</h4><dl class="admin-stats">' +
|
||||
stat('notifyMails', 'Mails, 24 hours', n.mailsLast24h + ' of ' + n.maxMailsPerDay) +
|
||||
stat('notifyWatches', 'Watched nodes', n.watches) + stat('notifyUsers', 'Users watching', n.watchingUsers) + '</dl>';
|
||||
}
|
||||
|
||||
function usersCardHtml(r) {
|
||||
var head = '<section class="admin-card" id="aoUsers" aria-labelledby="aoUsersH"><h3 id="aoUsersH">Users</h3>';
|
||||
if (!r || r.error) return head + failedHtml('user figures') + '</section>';
|
||||
@@ -94,7 +102,7 @@
|
||||
stat('mailDelivered', 'Delivered', m.delivered) + stat('mailBounced', 'Bounced', m.bounced) +
|
||||
stat('mailBlocked', 'Blocked', m.blocked) + stat('mailSpam', 'Spam', m.spam) +
|
||||
stat('mailPending', 'Pending', m.pending) + stat('mailOther', 'Other', m.other) +
|
||||
'</dl></section>';
|
||||
'</dl>' + notifyStatsHtml(s.notify) + '</section>';
|
||||
}
|
||||
|
||||
function systemCardHtml(res, nowMs) {
|
||||
|
||||
@@ -212,6 +212,7 @@
|
||||
<script src="channel-colors.js?v=__BUST__"></script>
|
||||
<script src="channel-color-picker.js?v=__BUST__"></script>
|
||||
<script src="channel-proposals.js?v=__BUST__"></script>
|
||||
<script src="notifications.js?v=__BUST__"></script>
|
||||
<script src="packets.js?v=__BUST__"></script>
|
||||
<script src="geo-filter-overlay.js?v=__BUST__"></script>
|
||||
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
@@ -680,6 +680,7 @@
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Analytics</a>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Reach</a>
|
||||
<a href="#/observers/${encodeURIComponent(n.public_key.toUpperCase())}" class="btn-primary" title="View this pubkey as an observer" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> Observer →</a>
|
||||
<span class="node-notify-slot" id="nodeNotifySlotFull"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -822,6 +823,7 @@
|
||||
</div>
|
||||
|
||||
<div class="node-full-card skew-detail-section" id="node-clock-skew" style="display:none"></div>`;
|
||||
if (window.CSNotify) window.CSNotify.mount(document.getElementById('nodeNotifySlotFull'), n.public_key);
|
||||
|
||||
// Map
|
||||
if (hasLoc) {
|
||||
@@ -1712,6 +1714,7 @@
|
||||
<button class="btn-primary node-detail-btn" data-pubkey="${encodeURIComponent(n.public_key)}" aria-label="View details for ${escapeHtml(n.name || n.public_key)}" style="font-size:11px;padding:2px 8px;margin-left:8px;cursor:pointer"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Details</button>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Analytics</a>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Reach</a>
|
||||
<span class="node-notify-slot" id="nodeNotifySlot"></span>
|
||||
</div>
|
||||
${renderStatusExplanation(n)}
|
||||
|
||||
@@ -1794,6 +1797,7 @@
|
||||
|
||||
<div class="node-detail-section skew-detail-section" id="node-clock-skew" style="display:none"></div>
|
||||
</div>`;
|
||||
if (window.CSNotify) window.CSNotify.mount(document.getElementById('nodeNotifySlot'), n.public_key);
|
||||
|
||||
// Init map
|
||||
if (hasLoc) {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/* Node notifications client (docs/specs/2026-10-07-node-notifications-design.md).
|
||||
* window.CSNotify: the "Notify me" toggle on the node side pane and full
|
||||
* page, and the account page's Notifications section. Inert unless
|
||||
* /api/config/client advertises userManagement.notifications (roles.js sets
|
||||
* window.MC_USER_MGMT). One GET /api/account/notifications per user, cached;
|
||||
* every change answers the full state, which replaces the cache. Every
|
||||
* dynamic value in HTML goes through escapeHtml; messages use textContent. */
|
||||
(function () {
|
||||
'use strict';
|
||||
var cache = { userId: null, promise: null };
|
||||
|
||||
function enabled() { return !!(window.MC_USER_MGMT && window.MC_USER_MGMT.notifications); }
|
||||
function currentUser() { return window.CSAuth ? window.CSAuth.user() : null; }
|
||||
|
||||
// load resolves to the caller's state (the GET body) or null.
|
||||
function load() {
|
||||
var u = currentUser();
|
||||
if (!enabled() || !u) return Promise.resolve(null);
|
||||
if (!cache.promise || cache.userId !== u.id) {
|
||||
cache.userId = u.id;
|
||||
cache.promise = window.CSAuth.request('GET', '/api/account/notifications').then(function (r) {
|
||||
if (!r.ok) { cache.promise = null; return null; }
|
||||
return r.data;
|
||||
}, function () { cache.promise = null; return null; });
|
||||
}
|
||||
return cache.promise;
|
||||
}
|
||||
|
||||
// store makes data (the answer of a change) the cached state.
|
||||
function store(data) {
|
||||
var u = currentUser();
|
||||
cache.userId = u ? u.id : null;
|
||||
cache.promise = Promise.resolve(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
function isWatched(data, pubkey) {
|
||||
var pk = String(pubkey || '').toLowerCase();
|
||||
return !!(data && data.watches && data.watches.some(function (w) { return w.pubkey === pk; }));
|
||||
}
|
||||
|
||||
// toggleState: 'hidden' (no state), 'on', 'off' or 'full' (limit reached).
|
||||
function toggleState(data, pubkey) {
|
||||
if (!data) return 'hidden';
|
||||
if (isWatched(data, pubkey)) return 'on';
|
||||
return (data.watches || []).length >= Number(data.limits && data.limits.maxWatches) ? 'full' : 'off';
|
||||
}
|
||||
|
||||
function toggleHtml(state, data) {
|
||||
if (state === 'hidden') return '';
|
||||
var on = state === 'on';
|
||||
var title = on ? 'You get a mail when this node goes offline, comes back or reports a low battery. Click to stop.'
|
||||
: 'Get a mail when this node goes offline, comes back or reports a low battery.';
|
||||
var html = '<button type="button" class="btn-primary node-notify-btn" data-notify-toggle aria-pressed="' + (on ? 'true' : 'false') + '"' +
|
||||
(state === 'full' ? ' disabled' : '') + ' title="' + escapeHtml(title) + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope-simple"/></svg> ' +
|
||||
escapeHtml(on ? 'Notifying' : 'Notify me') + '</button>';
|
||||
if (state === 'full') {
|
||||
html += ' <small class="node-notify-hint">' +
|
||||
escapeHtml('You watch the maximum of ' + data.limits.maxWatches + ' nodes; remove one on your account page.') + '</small>';
|
||||
}
|
||||
return html + ' <small class="node-notify-hint" data-notify-msg role="status" aria-live="polite"></small>';
|
||||
}
|
||||
|
||||
function say(slot, text) {
|
||||
var m = slot.querySelector('[data-notify-msg]');
|
||||
if (m) m.textContent = text;
|
||||
}
|
||||
|
||||
function render(slot, pubkey, data) {
|
||||
var state = toggleState(data, pubkey);
|
||||
slot.innerHTML = toggleHtml(state, data);
|
||||
var btn = slot.querySelector('[data-notify-toggle]');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
var on = btn.getAttribute('aria-pressed') === 'true';
|
||||
btn.disabled = true;
|
||||
var path = '/api/account/notifications/watches/' + encodeURIComponent(String(pubkey).toLowerCase());
|
||||
return window.CSAuth.request(on ? 'DELETE' : 'PUT', path).then(function (r) {
|
||||
if (!r.ok) { btn.disabled = false; say(slot, window.CSAuth.errText(r)); return; }
|
||||
render(slot, pubkey, store(r.data));
|
||||
}, function () { btn.disabled = false; say(slot, 'Network error, try again.'); });
|
||||
});
|
||||
}
|
||||
|
||||
// mount fills slot with the toggle for pubkey; empty when the feature is
|
||||
// off or nobody is logged in.
|
||||
function mount(slot, pubkey) {
|
||||
if (!slot) return Promise.resolve();
|
||||
slot.innerHTML = '';
|
||||
var ready = window.CSAuth && window.CSAuth.ready ? window.CSAuth.ready() : Promise.resolve();
|
||||
return Promise.resolve(ready).then(function () {
|
||||
if (!enabled() || !currentUser()) return;
|
||||
return load().then(function (data) { render(slot, pubkey, data); });
|
||||
});
|
||||
}
|
||||
|
||||
var EVENT_LABELS = {
|
||||
'node.offline': 'A watched node goes offline or comes back',
|
||||
'node.battery': 'A watched node reports a low battery or recovers',
|
||||
'foreign.new': 'A new foreign node appears (admin)',
|
||||
'observer.offline': 'An observer goes offline or comes back (admin)'
|
||||
};
|
||||
|
||||
function eventsHtml(data) {
|
||||
return (data.availableEvents || []).map(function (e) {
|
||||
var id = 'notifyEv-' + e.replace(/\./g, '-');
|
||||
return '<label class="account-check" for="' + escapeHtml(id) + '"><input type="checkbox" id="' + escapeHtml(id) +
|
||||
'" data-notify-event="' + escapeHtml(e) + '"' + ((data.events || []).indexOf(e) !== -1 ? ' checked' : '') + '> ' +
|
||||
escapeHtml(EVENT_LABELS[e] || e) + '</label>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function watchesHtml(data) {
|
||||
if (!data.watches.length) return '<p class="account-hint">You watch no nodes yet. Use Notify me on a node page, or Watch my nodes below.</p>';
|
||||
return '<ul class="account-watches">' + data.watches.map(function (w) {
|
||||
return '<li data-pubkey="' + escapeHtml(w.pubkey) + '"><span><a href="#/nodes/' + encodeURIComponent(w.pubkey) + '">' +
|
||||
escapeHtml(w.name || w.pubkey.slice(0, 12)) + '</a>' + (w.known ? '' : ' <small>(no longer in the database)</small>') + '</span>' +
|
||||
'<button type="button" class="account-btn account-btn-secondary" data-unwatch="' + escapeHtml(w.pubkey) + '">Remove</button></li>';
|
||||
}).join('') + '</ul>';
|
||||
}
|
||||
|
||||
function sectionHtml(data) {
|
||||
var lim = data.limits || {};
|
||||
return '<form id="notifyForm" class="account-form" novalidate>' +
|
||||
'<label class="account-check" for="notifyEnabled"><input type="checkbox" id="notifyEnabled"' + (data.enabled ? ' checked' : '') + '> Send me notification mails</label>' +
|
||||
'<fieldset class="account-fieldset"><legend>Events</legend>' + eventsHtml(data) + '</fieldset>' +
|
||||
'<p class="account-hint">' + escapeHtml('At most ' + lim.perUserPerDay + ' mails per 24 hours; sent in the last 24 hours: ' + lim.mailsLast24h + '.') + '</p>' +
|
||||
'<button type="submit" class="account-btn account-btn-primary">Save</button></form>' +
|
||||
'<h4>' + escapeHtml('Watched nodes (' + data.watches.length + ' of ' + lim.maxWatches + ')') + '</h4>' +
|
||||
'<div id="notifyWatches">' + watchesHtml(data) + '</div>' +
|
||||
'<button type="button" class="account-btn account-btn-secondary" id="notifyMyNodes">Watch my nodes</button>';
|
||||
}
|
||||
|
||||
function sayIn(msgId, text, ok) { window.CSAuth.say(msgId, text, ok); }
|
||||
function failed(msgId) { return function () { sayIn(msgId, 'Network error, try again.', false); }; }
|
||||
|
||||
// draw renders the section from data and binds its controls. Controls are
|
||||
// found by id: they are unique on the account page. busy guards against a
|
||||
// second request while one is in flight.
|
||||
function draw(el, msgId, data) {
|
||||
var busy = false;
|
||||
function guarded(fn) {
|
||||
if (busy) return Promise.resolve();
|
||||
busy = true;
|
||||
return fn().then(function () { busy = false; }, function () { busy = false; });
|
||||
}
|
||||
el.innerHTML = sectionHtml(data);
|
||||
document.getElementById('notifyForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var events = (data.availableEvents || []).filter(function (ev) {
|
||||
var box = document.getElementById('notifyEv-' + ev.replace(/\./g, '-'));
|
||||
return !!(box && box.checked);
|
||||
});
|
||||
var en = document.getElementById('notifyEnabled');
|
||||
return guarded(function () {
|
||||
return window.CSAuth.request('PUT', '/api/account/notifications', { enabled: !!(en && en.checked), events: events }).then(function (r) {
|
||||
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
|
||||
draw(el, msgId, store(r.data));
|
||||
sayIn(msgId, 'Saved.', true);
|
||||
}, failed(msgId));
|
||||
});
|
||||
});
|
||||
document.getElementById('notifyWatches').addEventListener('click', function (e) {
|
||||
var pk = e.target && e.target.getAttribute && e.target.getAttribute('data-unwatch');
|
||||
if (!pk) return;
|
||||
return guarded(function () {
|
||||
return window.CSAuth.request('DELETE', '/api/account/notifications/watches/' + encodeURIComponent(pk)).then(function (r) {
|
||||
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
|
||||
draw(el, msgId, store(r.data));
|
||||
sayIn(msgId, 'Removed.', true);
|
||||
}, failed(msgId));
|
||||
});
|
||||
});
|
||||
document.getElementById('notifyMyNodes').addEventListener('click', function () {
|
||||
return guarded(function () {
|
||||
return window.CSAuth.request('POST', '/api/account/notifications/watch-my-nodes').then(function (r) {
|
||||
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
|
||||
var d = r.data;
|
||||
draw(el, msgId, store(d.account));
|
||||
sayIn(msgId, (d.added + d.already + d.skipped) === 0
|
||||
? 'Your synced My nodes list is empty. Add nodes to My nodes on the Nodes page and turn on settings sync.'
|
||||
: 'Added ' + d.added + ', already watched ' + d.already + ', skipped ' + d.skipped + '.', true);
|
||||
}, failed(msgId));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// mountSection loads the caller's state (always fresh) into el.
|
||||
function mountSection(el, msgId) {
|
||||
if (!el || !enabled() || !currentUser()) return Promise.resolve();
|
||||
return window.CSAuth.request('GET', '/api/account/notifications').then(function (r) {
|
||||
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
|
||||
draw(el, msgId, store(r.data));
|
||||
}, failed(msgId));
|
||||
}
|
||||
|
||||
window.addEventListener('cs-auth-changed', function () { cache.userId = null; cache.promise = null; });
|
||||
|
||||
window.CSNotify = { enabled: enabled, load: load, store: store, toggleState: toggleState, toggleHtml: toggleHtml, mount: mount,
|
||||
sectionHtml: sectionHtml, mountSection: mountSection };
|
||||
})();
|
||||
+1
-1
@@ -656,7 +656,7 @@
|
||||
? { disabledTabs: Array.isArray(cfg.customizer.disabledTabs) ? cfg.customizer.disabledTabs : [] }
|
||||
: { disabledTabs: [] };
|
||||
// Optional user management: present only when the server enables it.
|
||||
window.MC_USER_MGMT = (cfg.userManagement && cfg.userManagement.enabled) ? { enabled: true, channelProposals: !!cfg.userManagement.channelProposals } : null;
|
||||
window.MC_USER_MGMT = (cfg.userManagement && cfg.userManagement.enabled) ? { enabled: true, channelProposals: !!cfg.userManagement.channelProposals, notifications: !!cfg.userManagement.notifications } : null;
|
||||
// #1574 — operator-configurable cap on /live map node count.
|
||||
if (cfg.liveMapMaxNodes != null) window.LIVE_MAP_MAX_NODES = cfg.liveMapMaxNodes;
|
||||
// #1784 — path trust threshold: minimum hash bytes for mapping evidence.
|
||||
|
||||
@@ -162,6 +162,7 @@ node tests/unit/test-node-reach-coverage.js
|
||||
node tests/unit/test-nodes-export-wiring.js
|
||||
node tests/unit/test-nav-drawer-version-footer.js
|
||||
node tests/unit/test-nodes-export.js
|
||||
node tests/unit/test-notifications-ui.js
|
||||
node tests/unit/test-observer-iata-1188.js
|
||||
node tests/unit/test-observer-menu-interactions.js
|
||||
node tests/unit/test-observer-naive-clock-1478.js
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* (cd cmd/server && go build -o ../../corescope-server . && go build -tags e2etest -o ../../corescope-server-e2e .)
|
||||
* # config.json for the on-server (in $CFGDIR): port 13582, userManagement {enabled: true,
|
||||
* # dbPath "users.db", adminEmails ["admin@e2e.test"], publicBaseUrl "http://localhost:13582",
|
||||
* # mail {provider "fake", fromEmail "noreply@e2e.test"}, channelProposals {enabled: true}}
|
||||
* # mail {provider "fake", fromEmail "noreply@e2e.test"}, channelProposals {enabled: true}, notifications {enabled: true}}
|
||||
* corescope-server -port 13581 -db "$TMP/off.db" -public public &
|
||||
* (cd "$CFGDIR" && corescope-server-e2e -config-dir . -port 13582 -db "$TMP/on.db" -public <repo>/public) &
|
||||
* BASE_URL=http://localhost:13582 BASE_URL_OFF=http://localhost:13581 node tests/e2e/test-user-management-e2e.js
|
||||
@@ -35,6 +35,16 @@ async function authReady(page) {
|
||||
await page.evaluate(() => window.CSAuth.ready());
|
||||
}
|
||||
|
||||
// Resolves once the full node page has rendered its paths section, which
|
||||
// needs a network round trip started after CSNotify.mount ran: a positive
|
||||
// signal that the page settled before asserting that something is absent.
|
||||
async function nodePageRendered(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.getElementById('fullPathsContent');
|
||||
return !!el && !el.querySelector('.spinner');
|
||||
});
|
||||
}
|
||||
|
||||
async function lastMailToken(page) {
|
||||
const r = await page.request.get(BASE + '/__e2e/last-mail');
|
||||
assert(r.ok(), 'last-mail HTTP ' + r.status());
|
||||
@@ -63,6 +73,10 @@ async function registerAndActivate(page, email, name) {
|
||||
|
||||
// axeClean fails on serious or critical WCAG 2 A/AA violations inside sel.
|
||||
async function axeClean(pg, sel) {
|
||||
// A finite animation in flight (the 150 ms .page-enter fade) blends colours
|
||||
// and fails color-contrast at random; wait until those have finished.
|
||||
await pg.waitForFunction(() => document.getAnimations().every((a) =>
|
||||
a.playState !== 'running' || !a.effect || a.effect.getComputedTiming().endTime === Infinity));
|
||||
const res = await new AxeBuilder({ page: pg }).include(sel).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||
const bad = res.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical');
|
||||
assert(bad.length === 0, sel + ': ' + bad.map((v) => v.id + ' ' + v.nodes.map((n) => n.target.join(' ') + ' ' + ((n.any[0] || {}).message || '')).join(' | ')).join(', '));
|
||||
@@ -352,6 +366,66 @@ async function until(fn, label) {
|
||||
assert(!('approvedChannels' in body), 'approvedChannels present while off');
|
||||
});
|
||||
|
||||
let watchKey = null;
|
||||
await step('notifications: no Notify me toggle on a node page while logged out', async () => {
|
||||
const body = await (await proposer.request.get(BASE + '/api/nodes?limit=1')).json();
|
||||
watchKey = body.nodes[0].public_key;
|
||||
const anon = await (await browser.newContext()).newPage();
|
||||
anon.setDefaultTimeout(8000);
|
||||
await anon.goto(BASE + '/#/nodes/' + encodeURIComponent(watchKey));
|
||||
await anon.waitForSelector('#nodeNotifySlotFull', { state: 'attached' });
|
||||
await authReady(anon);
|
||||
await nodePageRendered(anon);
|
||||
assert((await anon.locator('[data-notify-toggle]').count()) === 0, 'toggle shown while logged out');
|
||||
await anon.context().close();
|
||||
});
|
||||
|
||||
await step('notifications: a user turns on Notify me on a node page', async () => {
|
||||
await proposer.goto(BASE + '/#/nodes/' + encodeURIComponent(watchKey));
|
||||
await proposer.waitForSelector('#nodeNotifySlotFull [data-notify-toggle][aria-pressed="false"]');
|
||||
await proposer.click('#nodeNotifySlotFull [data-notify-toggle]');
|
||||
await proposer.waitForSelector('#nodeNotifySlotFull [data-notify-toggle][aria-pressed="true"]');
|
||||
});
|
||||
|
||||
await step('notifications: the #/account?section=notifications deep link shows the watched node', async () => {
|
||||
await proposer.goto(BASE + '/#/account?section=notifications');
|
||||
await proposer.waitForSelector('#notifySection li[data-pubkey="' + watchKey + '"]');
|
||||
assert(await proposer.isChecked('#notifyEnabled'), 'notifications are not on by default');
|
||||
await until(() => proposer.evaluate(() => {
|
||||
const r = document.getElementById('notifications').getBoundingClientRect();
|
||||
return r.top >= -1 && r.top < window.innerHeight;
|
||||
}), 'deep link scrolls to the Notifications heading');
|
||||
await axeClean(proposer, '#notifySection');
|
||||
});
|
||||
|
||||
await step('notifications: the unsubscribe link turns notifications off', async () => {
|
||||
const r = await proposer.request.get(BASE + '/__e2e/unsubscribe-link?email=' + encodeURIComponent('proposer@e2e.test'));
|
||||
assert(r.ok(), 'unsubscribe-link HTTP ' + r.status());
|
||||
const { link } = await r.json();
|
||||
const anon = await (await browser.newContext()).newPage();
|
||||
anon.setDefaultTimeout(8000);
|
||||
await anon.goto(link);
|
||||
await anon.waitForSelector('#unsubBtn:not([disabled])');
|
||||
await axeClean(anon, '#app');
|
||||
await anon.click('#unsubBtn');
|
||||
await until(async () => (await anon.textContent('#accountMsg')).indexOf('Notifications are off') !== -1, 'unsubscribe result');
|
||||
await anon.context().close();
|
||||
await proposer.goto(BASE + '/#/account?section=notifications');
|
||||
await proposer.reload();
|
||||
await proposer.waitForSelector('#notifyEnabled');
|
||||
assert(!(await proposer.isChecked('#notifyEnabled')), 'still on after the unsubscribe link');
|
||||
});
|
||||
|
||||
await step('feature off: no userManagement block, so no notifications flag, and no toggle on the node page', async () => {
|
||||
const body = await (await off.request.get(BASE_OFF + '/api/config/client')).json();
|
||||
assert(!body.userManagement, 'userManagement present while off');
|
||||
await off.goto(BASE_OFF + '/#/nodes/' + encodeURIComponent(watchKey));
|
||||
await off.waitForSelector('#nodeNotifySlotFull', { state: 'attached' });
|
||||
await authReady(off);
|
||||
await nodePageRendered(off);
|
||||
assert((await off.locator('[data-notify-toggle]').count()) === 0, 'toggle shown while the feature is off');
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
@@ -670,6 +670,19 @@ test('an unknown id does nothing; unmount drops a late answer', async () => {
|
||||
assert.strictEqual(env.calls.length, 1);
|
||||
});
|
||||
|
||||
test('Users card shows notification figures only when the server sends them', () => {
|
||||
const t = ovT();
|
||||
assert.strictEqual(t.usersCardHtml({ data: STATS }).indexOf('Notifications'), -1);
|
||||
const h = t.usersCardHtml({ data: Object.assign({}, STATS, { notify: { mailsLast24h: 7, maxMailsPerDay: 300, watches: 12, watchingUsers: 3 } }) });
|
||||
assert(h.indexOf('<h4>Notifications</h4>') !== -1, h);
|
||||
assert(h.indexOf('data-stat="notifyMails">7 of 300<') !== -1 && h.indexOf('data-stat="notifyWatches">12<') !== -1 &&
|
||||
h.indexOf('data-stat="notifyUsers">3<') !== -1, h);
|
||||
});
|
||||
|
||||
test('the audit action filter offers notification settings', () => {
|
||||
assert.strictEqual(auditT().readHash('#/admin?tab=audit&action=notify.*').action, 'notify.*');
|
||||
});
|
||||
|
||||
Promise.all(pending).then(() => {
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/* Unit tests for public/notifications.js
|
||||
* (docs/specs/2026-10-07-node-notifications-design.md), loaded from disk into
|
||||
* a vm sandbox (pattern of test-channel-proposals-ui.js). */
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
let passed = 0, failed = 0;
|
||||
const pending = [];
|
||||
function test(name, fn) {
|
||||
const p = Promise.resolve().then(fn).then(
|
||||
() => { passed++; console.log(' ok ' + name); },
|
||||
(e) => { failed++; console.log(' FAIL ' + name + ': ' + e.message); });
|
||||
pending.push(p);
|
||||
return p;
|
||||
}
|
||||
const src = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
||||
const plain = (v) => JSON.parse(JSON.stringify(v));
|
||||
const XSS = '<img src=x onerror=alert(1)>';
|
||||
const PK = 'ab'.repeat(32);
|
||||
|
||||
function loadEscapeHtml() {
|
||||
const m = src('public/app.js').match(/function escapeHtml\(s\) \{[\s\S]*?\n\}/);
|
||||
assert(m, 'escapeHtml not found in app.js');
|
||||
return vm.runInNewContext('(' + m[0].replace(/^function escapeHtml/, 'function') + ')');
|
||||
}
|
||||
|
||||
// makeEl is a stand-in DOM node: innerHTML is a string, querySelector finds
|
||||
// the [data-...] markers notifications.js uses (one object per marker and
|
||||
// innerHTML, so a re-render yields a new element).
|
||||
function makeEl(id) {
|
||||
const el = { id, innerHTML: '', textContent: '', checked: false, disabled: false, handlers: {}, attrs: {}, kids: {} };
|
||||
el.addEventListener = (t, fn) => { el.handlers[t] = fn; };
|
||||
el.getAttribute = (n) => (n in el.attrs ? el.attrs[n] : null);
|
||||
el.querySelector = (sel) => {
|
||||
const m = /^\[([a-z-]+)\]$/.exec(sel);
|
||||
if (!m || el.innerHTML.indexOf(m[1]) === -1) return null;
|
||||
const key = sel + '|' + el.innerHTML;
|
||||
if (!el.kids[key]) {
|
||||
const k = makeEl(sel);
|
||||
const pressed = /aria-pressed="(true|false)"/.exec(el.innerHTML);
|
||||
if (pressed) k.attrs['aria-pressed'] = pressed[1];
|
||||
k.disabled = m[1] === 'data-notify-toggle' && / disabled[ >]/.test(el.innerHTML);
|
||||
el.kids[key] = k;
|
||||
}
|
||||
return el.kids[key];
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
const STATE = (o) => Object.assign({ enabled: true, events: ['node.offline', 'node.battery'],
|
||||
availableEvents: ['node.offline', 'node.battery'], watches: [],
|
||||
limits: { maxWatches: 50, perUserPerDay: 20, mailsLast24h: 0 } }, o);
|
||||
|
||||
function load(opts) {
|
||||
opts = opts || {};
|
||||
const els = {};
|
||||
const document = { getElementById(id) { return els[id] || (els[id] = makeEl(id)); } };
|
||||
const calls = [];
|
||||
const listeners = {};
|
||||
const user = { current: opts.user === undefined ? { id: 2, role: 'user' } : opts.user };
|
||||
const routes = opts.routes || (() => ({ ok: true, status: 200, data: STATE() }));
|
||||
const CSAuth = {
|
||||
ready() { return opts.ready || Promise.resolve(); },
|
||||
user() { return user.current; },
|
||||
request(method, p, body) { calls.push({ method, p, body }); return Promise.resolve().then(() => routes(method, p, body)); },
|
||||
say(id, text, ok) { const el = document.getElementById(id); el.textContent = text; el.ok = !!ok; },
|
||||
errText(r) { return (r.data && r.data.error) || ('Request failed (HTTP ' + r.status + ')'); },
|
||||
};
|
||||
const ctx = { document, CSAuth, console, escapeHtml: loadEscapeHtml(),
|
||||
addEventListener(t, fn) { listeners[t] = fn; },
|
||||
MC_USER_MGMT: opts.flag === false ? null : { enabled: true, notifications: opts.notifications !== false } };
|
||||
ctx.window = ctx;
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(src('public/notifications.js'), ctx);
|
||||
return { N: ctx.CSNotify, els, calls, user, document, fire(t) { if (listeners[t]) listeners[t]({}); } };
|
||||
}
|
||||
|
||||
console.log('notifications.js: toggle');
|
||||
|
||||
test('no toggle and no request when the feature is off or nobody is logged in', async () => {
|
||||
for (const opts of [{ notifications: false }, { flag: false }, { user: null }]) {
|
||||
const env = load(opts);
|
||||
const slot = makeEl('s');
|
||||
slot.innerHTML = 'old';
|
||||
await env.N.mount(slot, PK);
|
||||
assert.strictEqual(slot.innerHTML, '', JSON.stringify(opts));
|
||||
assert.strictEqual(env.calls.length, 0, JSON.stringify(opts));
|
||||
}
|
||||
});
|
||||
|
||||
test('node data arriving before auth is ready still gets the toggle', async () => {
|
||||
let release;
|
||||
const ready = new Promise((res) => { release = res; });
|
||||
const env = load({ ready, user: null });
|
||||
const slot = makeEl('s');
|
||||
const p = env.N.mount(slot, PK);
|
||||
await Promise.resolve();
|
||||
assert.strictEqual(env.calls.length, 0, 'no request before ready');
|
||||
env.user.current = { id: 2, role: 'user' };
|
||||
release();
|
||||
await p;
|
||||
assert.strictEqual(env.calls.length, 1);
|
||||
assert(slot.innerHTML.indexOf('aria-pressed="false"') !== -1, slot.innerHTML);
|
||||
});
|
||||
|
||||
test('toggle states and markup: off, on, full, hidden', () => {
|
||||
const N = load().N;
|
||||
assert.strictEqual(N.toggleState(null, PK), 'hidden');
|
||||
assert.strictEqual(N.toggleState(STATE(), PK), 'off');
|
||||
assert.strictEqual(N.toggleState(STATE({ watches: [{ pubkey: PK }] }), PK.toUpperCase()), 'on');
|
||||
assert.strictEqual(N.toggleState(STATE({ watches: [{ pubkey: 'cd'.repeat(32) }], limits: { maxWatches: 1 } }), PK), 'full');
|
||||
const off = N.toggleHtml('off', STATE());
|
||||
assert(off.indexOf('aria-pressed="false"') !== -1 && off.indexOf('Notify me') !== -1 && off.indexOf(' disabled') === -1, off);
|
||||
const on = N.toggleHtml('on', STATE());
|
||||
assert(on.indexOf('aria-pressed="true"') !== -1 && on.indexOf('Notifying') !== -1, on);
|
||||
// An icon like the neighbouring node-page buttons, so it gets their height.
|
||||
assert(off.indexOf('<svg class="ph-icon" aria-hidden="true">') !== -1 && on.indexOf('#ph-envelope-simple') !== -1, off);
|
||||
const full = N.toggleHtml('full', STATE({ limits: { maxWatches: XSS } }));
|
||||
assert(full.indexOf(' disabled') !== -1 && full.indexOf('<img') === -1 && full.indexOf('<img') !== -1, full);
|
||||
assert.strictEqual(N.toggleHtml('hidden', null), '');
|
||||
});
|
||||
|
||||
test('two mounts share one GET; a click watches with a PUT and the answer replaces the cache', async () => {
|
||||
const watched = STATE({ watches: [{ pubkey: PK, name: 'N', known: true }] });
|
||||
const env = load({ routes: (m) => ({ ok: true, status: 200, data: m === 'GET' ? STATE() : watched }) });
|
||||
const a = makeEl('a'), b = makeEl('b');
|
||||
await Promise.all([env.N.mount(a, PK.toUpperCase()), env.N.mount(b, PK)]);
|
||||
assert.strictEqual(env.calls.length, 1);
|
||||
assert(a.innerHTML.indexOf('aria-pressed="false"') !== -1, a.innerHTML);
|
||||
await a.querySelector('[data-notify-toggle]').handlers.click();
|
||||
assert.deepStrictEqual(plain(env.calls[1]), { method: 'PUT', p: '/api/account/notifications/watches/' + PK });
|
||||
assert(a.innerHTML.indexOf('aria-pressed="true"') !== -1, a.innerHTML);
|
||||
await env.N.mount(b, PK);
|
||||
assert.strictEqual(env.calls.length, 2, 'mounted again from the cache');
|
||||
assert(b.innerHTML.indexOf('aria-pressed="true"') !== -1, b.innerHTML);
|
||||
});
|
||||
|
||||
test('a click on Notifying unwatches with a DELETE', async () => {
|
||||
const env = load({ routes: (m) => ({ ok: true, status: 200, data: m === 'GET' ? STATE({ watches: [{ pubkey: PK }] }) : STATE() }) });
|
||||
const slot = makeEl('s');
|
||||
await env.N.mount(slot, PK);
|
||||
await slot.querySelector('[data-notify-toggle]').handlers.click();
|
||||
assert.strictEqual(env.calls[1].method, 'DELETE');
|
||||
assert(slot.innerHTML.indexOf('aria-pressed="false"') !== -1, slot.innerHTML);
|
||||
});
|
||||
|
||||
test('a refused change shows the server message and re-enables the button', async () => {
|
||||
const msg = 'you watch the maximum of 50 nodes; remove one first';
|
||||
const env = load({ routes: (m) => (m === 'GET' ? { ok: true, status: 200, data: STATE() } : { ok: false, status: 409, data: { error: msg } }) });
|
||||
const slot = makeEl('s');
|
||||
await env.N.mount(slot, PK);
|
||||
const btn = slot.querySelector('[data-notify-toggle]');
|
||||
await btn.handlers.click();
|
||||
assert.strictEqual(btn.disabled, false);
|
||||
assert.strictEqual(slot.querySelector('[data-notify-msg]').textContent, msg);
|
||||
});
|
||||
|
||||
test('another user or an auth change drops the cached state', async () => {
|
||||
const env = load();
|
||||
await env.N.mount(makeEl('a'), PK);
|
||||
env.user.current = { id: 3, role: 'user' };
|
||||
await env.N.mount(makeEl('b'), PK);
|
||||
assert.strictEqual(env.calls.length, 2);
|
||||
env.fire('cs-auth-changed');
|
||||
await env.N.mount(makeEl('c'), PK);
|
||||
assert.strictEqual(env.calls.length, 3);
|
||||
});
|
||||
|
||||
test('nodes.js mounts the toggle on both views; roles.js and index.html wire the module', () => {
|
||||
const nodes = src('public/nodes.js');
|
||||
assert(nodes.indexOf('id="nodeNotifySlot"') !== -1 && nodes.indexOf("CSNotify.mount(document.getElementById('nodeNotifySlot'), n.public_key)") !== -1);
|
||||
assert(nodes.indexOf('id="nodeNotifySlotFull"') !== -1 && nodes.indexOf("CSNotify.mount(document.getElementById('nodeNotifySlotFull'), n.public_key)") !== -1);
|
||||
assert(/notifications: !!cfg\.userManagement\.notifications/.test(src('public/roles.js')));
|
||||
assert(src('public/index.html').indexOf('<script src="notifications.js?v=__BUST__"></script>') !== -1);
|
||||
});
|
||||
|
||||
console.log('notifications.js: account section');
|
||||
|
||||
test('section escapes names and shows events, watches and limits', () => {
|
||||
const N = load().N;
|
||||
const html = N.sectionHtml(STATE({ availableEvents: ['node.offline', 'node.battery', 'foreign.new', 'observer.offline'],
|
||||
events: ['node.offline', 'foreign.new'], limits: { maxWatches: 50, perUserPerDay: 20, mailsLast24h: 3 },
|
||||
watches: [{ pubkey: PK, name: XSS, known: true }, { pubkey: 'cd'.repeat(32), name: '', known: false }] }));
|
||||
assert(html.indexOf('<img') === -1 && html.indexOf('<img') !== -1, 'name not escaped');
|
||||
assert(html.indexOf('id="notifyEv-foreign-new" data-notify-event="foreign.new" checked') !== -1, html);
|
||||
assert(html.indexOf('id="notifyEv-node-battery" data-notify-event="node.battery">') !== -1, html);
|
||||
assert(html.indexOf('id="notifyEnabled" checked') !== -1);
|
||||
assert(html.indexOf('no longer in the database') !== -1);
|
||||
assert(html.indexOf('Watched nodes (2 of 50)') !== -1);
|
||||
assert(html.indexOf('At most 20 mails per 24 hours; sent in the last 24 hours: 3.') !== -1);
|
||||
assert(html.indexOf('data-unwatch="' + PK + '"') !== -1 && html.indexOf('href="#/nodes/' + PK + '"') !== -1);
|
||||
assert(N.sectionHtml(STATE()).indexOf('You watch no nodes yet') !== -1);
|
||||
});
|
||||
|
||||
test('Save sends enabled and the checked events; the answer redraws', async () => {
|
||||
const env = load({ routes: (m) => ({ ok: true, status: 200, data: m === 'GET' ? STATE() : STATE({ enabled: false, events: ['node.battery'] }) }) });
|
||||
await env.N.mountSection(env.document.getElementById('notifySection'), 'notifyMsg');
|
||||
assert(env.els.notifySection.innerHTML.indexOf('notifyForm') !== -1);
|
||||
env.document.getElementById('notifyEnabled').checked = false;
|
||||
env.document.getElementById('notifyEv-node-offline').checked = false;
|
||||
env.document.getElementById('notifyEv-node-battery').checked = true;
|
||||
await env.els.notifyForm.handlers.submit({ preventDefault() {} });
|
||||
assert.deepStrictEqual(plain(env.calls[1]), { method: 'PUT', p: '/api/account/notifications', body: { enabled: false, events: ['node.battery'] } });
|
||||
assert.strictEqual(env.els.notifyMsg.textContent, 'Saved.');
|
||||
assert(env.els.notifySection.innerHTML.indexOf('id="notifyEnabled">') !== -1, 'not redrawn from the answer');
|
||||
});
|
||||
|
||||
test('Remove unwatches; Watch my nodes reports the counts', async () => {
|
||||
const env = load({ routes: (m, p) => {
|
||||
if (m === 'GET') return { ok: true, status: 200, data: STATE({ watches: [{ pubkey: PK, name: 'N', known: true }] }) };
|
||||
if (m === 'DELETE') return { ok: true, status: 200, data: STATE() };
|
||||
return { ok: true, status: 200, data: { added: 2, already: 1, skipped: 3, account: STATE() } };
|
||||
} });
|
||||
await env.N.mountSection(env.document.getElementById('notifySection'), 'notifyMsg');
|
||||
await env.els.notifyWatches.handlers.click({ target: { getAttribute: (n) => (n === 'data-unwatch' ? PK : null) } });
|
||||
assert.deepStrictEqual(plain(env.calls[1]), { method: 'DELETE', p: '/api/account/notifications/watches/' + PK });
|
||||
assert.strictEqual(env.els.notifyMsg.textContent, 'Removed.');
|
||||
await env.els.notifyMyNodes.handlers.click();
|
||||
assert.deepStrictEqual(plain(env.calls[2]), { method: 'POST', p: '/api/account/notifications/watch-my-nodes' });
|
||||
assert.strictEqual(env.els.notifyMsg.textContent, 'Added 2, already watched 1, skipped 3.');
|
||||
});
|
||||
|
||||
test('Watch my nodes with an empty synced list says how to fill it', async () => {
|
||||
const env = load({ routes: (m) => ({ ok: true, status: 200, data: m === 'GET' ? STATE() : { added: 0, already: 0, skipped: 0, account: STATE() } }) });
|
||||
await env.N.mountSection(env.document.getElementById('notifySection'), 'notifyMsg');
|
||||
await env.els.notifyMyNodes.handlers.click();
|
||||
assert(env.els.notifyMsg.textContent.indexOf('My nodes list is empty') !== -1, env.els.notifyMsg.textContent);
|
||||
});
|
||||
|
||||
test('a failed load shows the server message', async () => {
|
||||
const env = load({ routes: () => ({ ok: false, status: 500, data: { error: 'internal error' } }) });
|
||||
await env.N.mountSection(env.document.getElementById('notifySection'), 'notifyMsg');
|
||||
assert.strictEqual(env.els.notifyMsg.textContent, 'internal error');
|
||||
});
|
||||
|
||||
test('a second click while a request is in flight sends nothing', async () => {
|
||||
let release;
|
||||
const gate = new Promise((res) => { release = res; });
|
||||
const env = load({ routes: (m) => (m === 'GET' ? { ok: true, status: 200, data: STATE() } : gate.then(() => ({ ok: true, status: 200, data: STATE() }))) });
|
||||
await env.N.mountSection(env.document.getElementById('notifySection'), 'notifyMsg');
|
||||
const a = env.els.notifyForm.handlers.submit({ preventDefault() {} });
|
||||
const b = env.els.notifyForm.handlers.submit({ preventDefault() {} });
|
||||
release();
|
||||
await Promise.all([a, b]);
|
||||
assert.strictEqual(env.calls.filter((c) => c.method === 'PUT').length, 1);
|
||||
});
|
||||
|
||||
Promise.all(pending).then(() => {
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
});
|
||||
@@ -946,6 +946,37 @@ test('feature on: a refused reset alerts and keeps the stats', async () => {
|
||||
assert.deepStrictEqual(alerts, ['Reset failed: HTTP 403']);
|
||||
});
|
||||
|
||||
test('profile has the Notifications section only with notifications on', () => {
|
||||
const u = { email: 'a@example.org', role: 'user', displayName: 'A' };
|
||||
assert.strictEqual(loadAccount('#/account', () => ({})).t.profileHtml(u).indexOf('notifySection'), -1);
|
||||
const on = loadAccount('#/account', () => ({}), { CSNotify: { enabled: () => true } });
|
||||
assert(on.t.profileHtml(u).indexOf('<h3 id="notifications">Notifications</h3><div id="notifySection"></div>') !== -1);
|
||||
});
|
||||
|
||||
test('unsubscribe view posts the token only after the confirm click', async () => {
|
||||
const msg = 'Notifications are off. Turn them back on from your account page.';
|
||||
const env = loadAccount('#/account/unsubscribe?token=T%2B1', () => ({ ok: true, status: 200, data: { ok: true, message: msg } }));
|
||||
env.t.views.unsubscribe({});
|
||||
assert.strictEqual(env.loc.hash, '#/account/unsubscribe', 'token left in the address bar');
|
||||
assert.strictEqual(env.calls.length, 0, 'posted before the click');
|
||||
await env.els.unsubBtn.handlers.click();
|
||||
assert.strictEqual(env.calls[0].method, 'POST');
|
||||
assert.strictEqual(env.calls[0].p, '/api/notifications/unsubscribe?token=T%2B1');
|
||||
assert.strictEqual(env.els.accountMsg.textContent, msg);
|
||||
});
|
||||
|
||||
test('unsubscribe view: no token, and a dead token', async () => {
|
||||
const none = loadAccount('#/account/unsubscribe', () => ({}));
|
||||
none.t.views.unsubscribe({});
|
||||
assert.strictEqual(none.els.unsubBtn.disabled, true);
|
||||
assert(none.els.accountMsg.textContent.indexOf('incomplete') !== -1);
|
||||
const dead = loadAccount('#/account/unsubscribe?token=x', () => ({ ok: false, status: 410, data: { error: 'this link is invalid or was already used' } }));
|
||||
dead.t.views.unsubscribe({});
|
||||
await dead.els.unsubBtn.handlers.click();
|
||||
assert.strictEqual(dead.els.accountMsg.textContent, 'this link is invalid or was already used');
|
||||
assert.strictEqual(dead.els.unsubBtn.disabled, false);
|
||||
});
|
||||
|
||||
Promise.all(pending).then(() => {
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user