Files
meshcore-analyzer/internal/users/notify.go
T
efitenandClaude Opus 5.5 2456762179 feat: account data export and users.db backup (#2141)
A small follow-up to parts A to E of #2128: users can download all data
the instance holds about them, and the server backs up `users.db`.

Stacked on #2140. Review the commits after that branch.

## The situation

- An instance with accounts holds personal data, but a user has no way
to get a copy (GDPR articles 15 and 20). Self-delete already exists.
- `users.db` is the only file the server writes, and nothing backs it
up: `GET /api/backup` snapshots the analyzer database only.

## What this PR adds

**Data export.** `GET /api/account/export` and a "Download my data"
button on the account page give one JSON file with:
- the profile, including activation and a pending email change;
- sessions (times and user agent);
- synced settings, own proposals, notification prefs and watches;
- audit rows where the user is actor or target, with other accounts as
an id only;
- the mail log.

Password hash, session and link tokens and the unsubscribe token are
left out: they are credentials. Each export is audited as `user.export`.

**users.db backup**
- Daily `VACUUM INTO` snapshots through `internal/users`, on by default
when user management is on. They go to `backups/` next to `users.db`,
the 7 newest are kept, the directory is 0700 and the files 0600.
Rotation only touches `users-<timestamp>.db` files and never the
snapshot it just wrote. Config: `userManagement.backup {enabled, dir,
keep}`.
- `GET /api/admin/users-backup`: an admin downloads a fresh snapshot to
keep off the server; audited as `user.backup`.
- `docs/user-guide/accounts.md` gains a restore procedure. It covers
what a restore brings back (accounts deleted after the snapshot, old
passwords, revoked sessions) and how to clean that up afterwards.

Spec:
[`docs/specs/2026-10-08-account-export-and-users-backup-design.md`](https://github.com/efiten/CoreScope/blob/feat/account-export-backup/docs/specs/2026-10-08-account-export-and-users-backup-design.md).

## Verification

- `internal/users` with `-race` and the `cmd/server` suite pass locally,
30 new Go tests. The file-mode tests skip on Windows.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E: 27 of 27 steps locally.
- On our production instance since 8 October 2026; the first snapshot
there was 143 kB.

## Not in this PR

- Importing an export into another instance.
- Off-site upload of the snapshots. The admin download covers that by
hand.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:16:49 +02:00

460 lines
14 KiB
Go

package users
import (
"database/sql"
"errors"
"strings"
"time"
)
// Node notifications (docs/specs/2026-10-07-node-notifications-design.md):
// per-user preferences, the watch list, and the last state per (user, event,
// subject) that the server's evaluator compares against.
// Event types.
const (
NotifyNodeOffline = "node.offline"
NotifyNodeBattery = "node.battery"
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
}
// StoredNotifyPrefs returns the user's stored preferences, or nil when
// none were ever created. Unlike NotifyPrefsFor it never writes.
func (s *Store) StoredNotifyPrefs(userID int64) (*NotifyPrefs, error) {
p, err := s.getPrefs(userID)
if errors.Is(err, ErrNotFound) {
return nil, nil
}
return p, err
}
// SetNotifyPrefs stores enabled and events (unknown events dropped,
// canonical order) and deletes the state rows of events no longer chosen,
// so choosing an event again starts with a silent first evaluation. The
// caller checks which events the user may choose.
func (s *Store) SetNotifyPrefs(userID int64, enabled bool, events []string) (NotifyPrefs, error) {
if _, err := s.NotifyPrefsFor(userID); err != nil {
return NotifyPrefs{}, err
}
ev := canonicalEvents(events)
en := 0
if enabled {
en = 1
}
tx, err := s.db.Begin()
if err != nil {
return NotifyPrefs{}, err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE notification_prefs SET enabled = ?, events = ?, updated_at = ? WHERE user_id = ?`,
en, strings.Join(ev, ","), unix(s.now()), userID); err != nil {
return NotifyPrefs{}, err
}
q := `DELETE FROM notification_state WHERE user_id = ?`
args := []any{userID}
if len(ev) > 0 {
q += ` AND event NOT IN (` + placeholders(len(ev)) + `)`
for _, e := range ev {
args = append(args, e)
}
}
if _, err := tx.Exec(q, args...); err != nil {
return NotifyPrefs{}, err
}
if err := tx.Commit(); err != nil {
return NotifyPrefs{}, err
}
return s.NotifyPrefsFor(userID)
}
// DisableNotifyByToken turns notifications off for the user holding the
// unsubscribe token. wasEnabled reports whether they were on.
// ErrTokenInvalid for an empty or unknown token.
func (s *Store) DisableNotifyByToken(token string) (userID int64, wasEnabled bool, err error) {
if token == "" {
return 0, false, ErrTokenInvalid
}
var en int
err = s.db.QueryRow(`SELECT user_id, enabled FROM notification_prefs WHERE unsub_token = ?`, token).Scan(&userID, &en)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, ErrTokenInvalid
}
if err != nil {
return 0, false, err
}
if en != 0 {
if _, err := s.db.Exec(`UPDATE notification_prefs SET enabled = 0, updated_at = ? WHERE user_id = ?`, unix(s.now()), userID); err != nil {
return 0, false, err
}
}
return userID, en != 0, nil
}
// AllNotifyPrefs returns every preferences row, by user id.
func (s *Store) AllNotifyPrefs() ([]NotifyPrefs, error) {
rows, err := s.db.Query(`SELECT ` + prefsCols + ` FROM notification_prefs ORDER BY user_id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NotifyPrefs
for rows.Next() {
p, err := scanPrefs(rows)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, rows.Err()
}
// AddWatches adds pubkeys (lowercase hex, checked by the caller) to the
// user's watch list in one transaction, in order, while the list holds
// fewer than max entries (max <= 0: no limit). It reports how many were
// added, how many were already watched and how many the limit left out.
func (s *Store) AddWatches(userID int64, pubkeys []string, max int) (added, already, overLimit int, err error) {
tx, err := s.db.Begin()
if err != nil {
return 0, 0, 0, err
}
defer tx.Rollback()
have := map[string]bool{}
rows, err := tx.Query(`SELECT pubkey FROM notification_watches WHERE user_id = ?`, userID)
if err != nil {
return 0, 0, 0, err
}
for rows.Next() {
var pk string
if err := rows.Scan(&pk); err != nil {
rows.Close()
return 0, 0, 0, err
}
have[pk] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, 0, 0, err
}
now := unix(s.now())
for _, pk := range pubkeys {
switch {
case have[pk]:
already++
case max > 0 && len(have) >= max:
overLimit++
default:
if _, err := tx.Exec(`INSERT INTO notification_watches (user_id, pubkey, created_at) VALUES (?, ?, ?)`, userID, pk, now); err != nil {
return 0, 0, 0, err
}
have[pk] = true
added++
}
}
if err := tx.Commit(); err != nil {
return 0, 0, 0, err
}
return added, already, overLimit, nil
}
// AddWatch watches one node. Watching an already watched node is a no-op;
// ErrWatchLimit when the list is full.
func (s *Store) AddWatch(userID int64, pubkey string, max int) error {
_, _, over, err := s.AddWatches(userID, []string{pubkey}, max)
if err != nil {
return err
}
if over > 0 {
return ErrWatchLimit
}
return nil
}
// RemoveWatch stops watching a node and deletes its node.* state rows.
// 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
}