mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 03:39:53 +00:00
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>
320 lines
8.8 KiB
Go
320 lines
8.8 KiB
Go
package users
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Role string
|
|
|
|
const (
|
|
RoleUser Role = "user"
|
|
RoleAdmin Role = "admin"
|
|
)
|
|
|
|
// Valid reports whether r is a known role.
|
|
func (r Role) Valid() bool { return r == RoleUser || r == RoleAdmin }
|
|
|
|
type Status string
|
|
|
|
const (
|
|
StatusPending Status = "pending"
|
|
StatusActive Status = "active"
|
|
StatusDisabled Status = "disabled"
|
|
)
|
|
|
|
// User is one account. PasswordHash never leaves the server.
|
|
type User struct {
|
|
ID int64
|
|
Email string
|
|
DisplayName string
|
|
PasswordHash string
|
|
Role Role
|
|
Status Status
|
|
CreatedAt time.Time
|
|
ActivatedAt *time.Time
|
|
ActivatedBy *int64 // admin who activated manually; nil = activated by link
|
|
LastLoginAt *time.Time
|
|
EmailBouncing bool
|
|
}
|
|
|
|
const userCols = `id, email, display_name, password_hash, role, status, created_at, activated_at, activated_by, last_login_at, email_bouncing`
|
|
|
|
func scanUser(row rowScanner) (*User, error) {
|
|
var u User
|
|
var role, status string
|
|
var created int64
|
|
var activatedAt, activatedBy, lastLogin sql.NullInt64
|
|
var bouncing int
|
|
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.PasswordHash, &role, &status,
|
|
&created, &activatedAt, &activatedBy, &lastLogin, &bouncing); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
u.Role, u.Status = Role(role), Status(status)
|
|
u.CreatedAt = fromUnix(created)
|
|
u.ActivatedAt = fromNullUnix(activatedAt)
|
|
if activatedBy.Valid {
|
|
v := activatedBy.Int64
|
|
u.ActivatedBy = &v
|
|
}
|
|
u.LastLoginAt = fromNullUnix(lastLogin)
|
|
u.EmailBouncing = bouncing != 0
|
|
return &u, nil
|
|
}
|
|
|
|
// CreatePending inserts a new pending user. email must already be normalized.
|
|
func (s *Store) CreatePending(email, displayName, passwordHash string) (*User, error) {
|
|
res, err := s.db.Exec(`INSERT INTO users (email, display_name, password_hash, role, status, created_at)
|
|
VALUES (?, ?, ?, 'user', 'pending', ?)`, email, displayName, passwordHash, unix(s.now()))
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return nil, ErrEmailTaken
|
|
}
|
|
return nil, err
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.GetByID(id)
|
|
}
|
|
|
|
func (s *Store) GetByID(id int64) (*User, error) {
|
|
return scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE id = ?`, id))
|
|
}
|
|
|
|
// GetByEmail looks up a normalized address.
|
|
func (s *Store) GetByEmail(email string) (*User, error) {
|
|
return scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE email = ?`, email))
|
|
}
|
|
|
|
// Activate moves a pending user to active with role. by is the admin who
|
|
// activated manually, nil for link activation. ErrNotFound if not pending.
|
|
func (s *Store) Activate(id int64, role Role, by *int64) error {
|
|
return expectOne(s.db.Exec(`UPDATE users SET status = 'active', role = ?, activated_at = ?, activated_by = ?
|
|
WHERE id = ? AND status = 'pending'`, string(role), unix(s.now()), nullInt(by), id))
|
|
}
|
|
|
|
func (s *Store) SetStatus(id int64, st Status) error {
|
|
return expectOne(s.db.Exec(`UPDATE users SET status = ? WHERE id = ?`, string(st), id))
|
|
}
|
|
|
|
func (s *Store) SetRole(id int64, r Role) error {
|
|
return expectOne(s.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, string(r), id))
|
|
}
|
|
|
|
func (s *Store) SetPassword(id int64, hash string) error {
|
|
return expectOne(s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, id))
|
|
}
|
|
|
|
func (s *Store) SetDisplayName(id int64, name string) error {
|
|
return expectOne(s.db.Exec(`UPDATE users SET display_name = ? WHERE id = ?`, name, id))
|
|
}
|
|
|
|
// SetEmail changes the address (normalized) and clears the bounce flag.
|
|
func (s *Store) SetEmail(id int64, email string) error {
|
|
err := expectOne(s.db.Exec(`UPDATE users SET email = ?, email_bouncing = 0 WHERE id = ?`, email, id))
|
|
if isUniqueViolation(err) {
|
|
return ErrEmailTaken
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Store) SetEmailBouncing(id int64, v bool) error {
|
|
b := 0
|
|
if v {
|
|
b = 1
|
|
}
|
|
return expectOne(s.db.Exec(`UPDATE users SET email_bouncing = ? WHERE id = ?`, b, id))
|
|
}
|
|
|
|
func (s *Store) TouchLogin(id int64) error {
|
|
return expectOne(s.db.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, unix(s.now()), id))
|
|
}
|
|
|
|
// Delete removes a user; sessions and tokens cascade. Mail-log rows survive
|
|
// for delivery forensics, with the address replaced by HashedEmail.
|
|
func (s *Store) Delete(id int64) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
var exists int
|
|
if err := tx.QueryRow(`SELECT 1 FROM users WHERE id = ?`, id).Scan(&exists); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if err := hashMailLogTx(tx, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ListFilter narrows List. Zero values mean "any".
|
|
type ListFilter struct {
|
|
Status Status
|
|
Role Role
|
|
Query string // substring of email or display name, case-insensitive
|
|
Bouncing bool // only addresses whose mail bounces
|
|
}
|
|
|
|
// List returns at most 1000 users, newest first. SQLite lower()/LIKE fold
|
|
// ASCII only, so a non-ASCII search is case-sensitive.
|
|
func (s *Store) List(f ListFilter) ([]User, error) {
|
|
q := `SELECT ` + userCols + ` FROM users WHERE 1=1`
|
|
var args []any
|
|
if f.Status != "" {
|
|
q += ` AND status = ?`
|
|
args = append(args, string(f.Status))
|
|
}
|
|
if f.Role != "" {
|
|
q += ` AND role = ?`
|
|
args = append(args, string(f.Role))
|
|
}
|
|
if f.Bouncing {
|
|
q += ` AND email_bouncing != 0`
|
|
}
|
|
if t := strings.TrimSpace(f.Query); t != "" {
|
|
like := "%" + escapeLike(strings.ToLower(t)) + "%"
|
|
q += ` AND (email LIKE ? ESCAPE '\' OR lower(display_name) LIKE ? ESCAPE '\')`
|
|
args = append(args, like, like)
|
|
}
|
|
q += ` ORDER BY created_at DESC, id DESC LIMIT 1000`
|
|
rows, err := s.db.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []User
|
|
for rows.Next() {
|
|
u, err := scanUser(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func escapeLike(s string) string {
|
|
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
|
}
|
|
|
|
// CountActiveAdmins counts admins whose status is active.
|
|
func (s *Store) CountActiveAdmins() (int, error) {
|
|
var n int
|
|
err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// UsersByID loads the users with the given ids in one query. Ids that no
|
|
// longer exist are absent from the map.
|
|
func (s *Store) UsersByID(ids []int64) (map[int64]User, error) {
|
|
out := make(map[int64]User, len(ids))
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
args := make([]any, len(ids))
|
|
for i, id := range ids {
|
|
args[i] = id
|
|
}
|
|
ph := placeholders(len(ids))
|
|
rows, err := s.db.Query(`SELECT `+userCols+` FROM users WHERE id IN (`+ph+`)`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
u, err := scanUser(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[u.ID] = *u
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// PruneStalePending deletes pending accounts older than maxAge that have no
|
|
// unused, unexpired activation token left.
|
|
func (s *Store) PruneStalePending(maxAge time.Duration) (int64, error) {
|
|
now := unix(s.now())
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
rows, err := tx.Query(`SELECT id FROM users WHERE status = 'pending' AND created_at < ?
|
|
AND NOT EXISTS (SELECT 1 FROM tokens t WHERE t.user_id = users.id AND t.purpose = 'activate'
|
|
AND t.used_at IS NULL AND t.expires_at > ?)`, now-int64(maxAge/time.Second), now)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var ids []int64
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return 0, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
for _, id := range ids {
|
|
if err := hashMailLogTx(tx, id); err != nil {
|
|
return 0, err
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return int64(len(ids)), tx.Commit()
|
|
}
|
|
|
|
// hashMailLogTx replaces the plaintext address of every mail_log row of the
|
|
// user with HashedEmail of that row's own address (rows may predate an email
|
|
// change). Rows already hashed are skipped. The cursor is closed before any
|
|
// UPDATE because the store uses a single connection.
|
|
func hashMailLogTx(tx *sql.Tx, userID int64) error {
|
|
rows, err := tx.Query(`SELECT id, to_email FROM mail_log WHERE user_id = ? AND to_email NOT LIKE 'sha256:%'`, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
type row struct {
|
|
id int64
|
|
email string
|
|
}
|
|
var list []row
|
|
for rows.Next() {
|
|
var r row
|
|
if err := rows.Scan(&r.id, &r.email); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
list = append(list, r)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
for _, r := range list {
|
|
if _, err := tx.Exec(`UPDATE mail_log SET to_email = ? WHERE id = ?`, HashedEmail(r.email), r.id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|