mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 20:49:57 +00:00
Closes #2177. ## What Node notifications can mail alerts computed outside CoreScope. With `userManagement.notifications.externalAlerts` configured, users can choose a new event, `node.external`, which is off by default and only offered when configured: ```json "notifications": { "enabled": true, "externalAlerts": { "url": "http://coverage-job/alerts.json", "maxAgeHours": 48, "label": "Coverage" } } ``` The feed: ```json { "generatedAt": "2026-10-11T01:35:12Z", "alerts": [ { "pubkey": "<64 hex>", "key": "SE", "text": "never heard to the south-east", "url": "https://example.org/coverage" } ] } ``` Mail lines: `<name>: Coverage: never heard to the south-east, <time>` when an alert appears, `<name>: Coverage resolved (SE), <time>` when it is gone from a fresh feed. ## How - `cmd/server/notify_external.go`: feed types, `parseExternalFeed` (RFC 3339 `generatedAt`, age check, per-alert validation), `fetchExternalFeed` (200 only, 1 MB cap, 10 s timeout). - `notify_eval.go`: `evalExternal`. Subjects are `<pubkey>/<key>`; a `<pubkey>/*` row is the per-node baseline, so the first fresh feed after watching stores the current alerts without a mail (like `foreign.new`), and later alerts mail at once. `External == nil` (not configured, nobody chose it, or stale) leaves every `node.external` state as it is. - `notify_service.go`: the feed is fetched only when configured and at least one user chose the event. Each new reason the feed is unusable is logged once, and so is the recovery. Mail text and link: the alert's `url`, otherwise the node page. - `notify_handlers.go`: `availableEvents` and the new `externalLabel` include the event only when configured; PUT answers 400 for `node.external` otherwise. - `internal/users/notify.go`: event constant, `OptionalNodeNotifyEvents` (canonical order: node, optional, admin), `RemoveWatch` also deletes the node's `<pubkey>/...` rows. - Account page label, OpenAPI descriptions, `config.example.json` comment, `docs/user-guide/accounts.md` section. Decisions on the three open questions in #2177: the generic name `node.external`; the resolved mail names the key instead of repeating the text, so there is **no `users.db` schema change** (a bump would also make older binaries refuse the file); one feed. ## Invariants - Read/write separation (#1283): the server only reads the feed over HTTP. State rows go to `users.db` through `internal/users`, like the other events. - A dead, garbled or old feed never mails "resolved": `TestEvaluateExternalStaleFeedChangesNothing` and `TestNotifierMailsExternalAlertsAndSkipsAStaleFeed` (HTTP 502 and a 72 h old feed in a row, then a fresh empty feed resolves). ## Performance The evaluator adds one pass over the stored states (already read every tick) plus, per user with the event on and per watched node, the node's current alerts. Bounded by `maxWatchesPerUser` (50) times the alerts per node. The feed is fetched once per tick (default 5 minutes) only when someone chose the event, capped at 1 MB. No packet-store lock is taken for it. Nothing changes for instances without `externalAlerts`. ## Tests - `cmd/server`: `go test` green locally (Windows; `TestSaveGeoFilterPreservesFileMode` skipped as it needs Unix mode bits). New: `notify_external_test.go` (feed parsing and limits, HTTP fetch 200/404/oversized, config resolution, evaluator baseline/new/resolved/returning/stale/not chosen/notifications off, notifier end to end with a fake feed, account routes). - `internal/users`: green, 2 new tests (optional event and canonical order, `RemoveWatch` deletes external subjects only for that node). - Frontend: `test-all.sh` green, 1 new test in `test-notifications-ui.js` (label from `externalLabel`, escaped). - `scripts/check-xss-sinks.sh --file public/notifications.js`: clean. ## Not done - No E2E test: the account page change is one label, covered by the unit test. - Not run against a live instance yet. The first feed in use is the coverage job of on8ar.eu (`alerts.json`, 29 alerts at the moment). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
327 lines
10 KiB
Go
327 lines
10 KiB
Go
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"`
|
|
ExternalLabel string `json:"externalLabel,omitempty"` // names node.external when offered
|
|
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)
|
|
}
|
|
|
|
// availableNotifyEvents lists what u may choose; node.external only when
|
|
// the instance configures externalAlerts.
|
|
func availableNotifyEvents(u *users.User, external bool) []string {
|
|
out := append([]string{}, users.NodeNotifyEvents...)
|
|
if external {
|
|
out = append(out, users.OptionalNodeNotifyEvents...)
|
|
}
|
|
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
|
|
}
|
|
ext := ns.external.url != ""
|
|
out := notifyAccountJSON{Enabled: p.Enabled, Events: []string{}, AvailableEvents: availableNotifyEvents(u, ext),
|
|
Watches: make([]notifyWatchJSON, 0, len(ws)),
|
|
Limits: notifyLimitsJSON{MaxWatches: ns.maxWatchesPerUser, PerUserPerDay: ns.perUserPerDay, MailsLast24h: perUser[u.ID]}}
|
|
if ext {
|
|
out.ExternalLabel = ns.external.label
|
|
}
|
|
for _, e := range p.Events {
|
|
if (u.Role == users.RoleAdmin || !users.IsAdminNotifyEvent(e)) && (ext || e != users.NotifyNodeExternal) {
|
|
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
|
|
}
|
|
if e == users.NotifyNodeExternal && s.auth.set.notify.external.url == "" {
|
|
writeError(w, http.StatusBadRequest, "external alerts are not configured on this instance")
|
|
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."})
|
|
}
|