Files
meshcore-analyzer/internal/users/settings.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

116 lines
3.8 KiB
Go

package users
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
// ErrSettingsConflict: PutSettings was given a base version that is not
// the stored one (another device wrote first, or the document was deleted
// and started again).
var ErrSettingsConflict = errors.New("users: settings revision conflict")
// SettingsVersion identifies one state of a user's settings document.
// Revisions restart at 1 after DeleteSettings, so the generation, a random
// id created with the document, tells a new document from the old one.
// The zero value means "no document".
type SettingsVersion struct {
Revision int64
Generation string
}
func newGeneration() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("users: settings generation: %w", err)
}
return hex.EncodeToString(b), nil
}
// GetSettings returns the user's synced settings document and its version;
// "" and the zero version when the user has none.
func (s *Store) GetSettings(userID int64) (string, SettingsVersion, error) {
var doc string
var v SettingsVersion
err := s.db.QueryRow(`SELECT doc, revision, generation FROM user_settings WHERE user_id = ?`, userID).Scan(&doc, &v.Revision, &v.Generation)
if errors.Is(err, sql.ErrNoRows) {
return "", SettingsVersion{}, nil
}
if err != nil {
return "", SettingsVersion{}, err
}
return doc, v, nil
}
// SettingsRecord is a stored settings document with its version and the
// time of its last write.
type SettingsRecord struct {
Doc string
Version SettingsVersion
UpdatedAt time.Time
}
// SettingsRecordFor returns the user's stored settings, or nil when the
// user has none.
func (s *Store) SettingsRecordFor(userID int64) (*SettingsRecord, error) {
var r SettingsRecord
var updated int64
err := s.db.QueryRow(`SELECT doc, revision, generation, updated_at FROM user_settings WHERE user_id = ?`, userID).
Scan(&r.Doc, &r.Version.Revision, &r.Version.Generation, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
r.UpdatedAt = fromUnix(updated)
return &r, nil
}
// PutSettings stores doc and returns the new version. Without a stored
// document base.Revision must be 0, and the write starts a new generation.
// With one, base must equal the stored version; the new revision is
// base.Revision+1 in the same generation. Otherwise it returns the stored
// version and ErrSettingsConflict.
func (s *Store) PutSettings(userID int64, base SettingsVersion, doc string) (SettingsVersion, error) {
tx, err := s.db.Begin()
if err != nil {
return SettingsVersion{}, err
}
defer tx.Rollback()
var cur SettingsVersion
err = tx.QueryRow(`SELECT revision, generation FROM user_settings WHERE user_id = ?`, userID).Scan(&cur.Revision, &cur.Generation)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return SettingsVersion{}, err
}
next := cur
if cur.Revision == 0 {
if base.Revision != 0 {
return cur, ErrSettingsConflict
}
if next.Generation, err = newGeneration(); err != nil {
return SettingsVersion{}, err
}
} else if base != cur {
return cur, ErrSettingsConflict
}
next.Revision = cur.Revision + 1
if _, err := tx.Exec(`INSERT INTO user_settings (user_id, doc, revision, generation, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET doc = excluded.doc, revision = excluded.revision, generation = excluded.generation, updated_at = excluded.updated_at`,
userID, doc, next.Revision, next.Generation, unix(s.now())); err != nil {
return SettingsVersion{}, err
}
return next, tx.Commit()
}
// DeleteSettings removes the user's synced settings; the next write starts
// a new generation. No row is not an error.
func (s *Store) DeleteSettings(userID int64) error {
_, err := s.db.Exec(`DELETE FROM user_settings WHERE user_id = ?`, userID)
return err
}