mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 13:18:58 +00:00
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>
174 lines
5.4 KiB
Go
174 lines
5.4 KiB
Go
package users
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// Purpose is what a one-time link may be used for.
|
|
type Purpose string
|
|
|
|
const (
|
|
PurposeActivate Purpose = "activate"
|
|
PurposeReset Purpose = "reset"
|
|
PurposeEmailChange Purpose = "email_change"
|
|
)
|
|
|
|
// IssueToken creates a one-time token and invalidates the user's earlier
|
|
// unused tokens for the same purpose, so only the newest link works.
|
|
// newEmail is stored for PurposeEmailChange and ignored when empty.
|
|
func (s *Store) IssueToken(userID int64, p Purpose, ttl time.Duration, newEmail string) (string, error) {
|
|
raw, hash, err := NewToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
now := s.now()
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.Exec(`UPDATE tokens SET used_at = ? WHERE user_id = ? AND purpose = ? AND used_at IS NULL`,
|
|
unix(now), userID, string(p)); err != nil {
|
|
return "", err
|
|
}
|
|
var ne any
|
|
if newEmail != "" {
|
|
ne = newEmail
|
|
}
|
|
if _, err := tx.Exec(`INSERT INTO tokens (token_hash, user_id, purpose, new_email, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
|
hash, userID, string(p), ne, unix(now.Add(ttl))); err != nil {
|
|
return "", err
|
|
}
|
|
return raw, tx.Commit()
|
|
}
|
|
|
|
// tokenQuerier is satisfied by *sql.DB and *sql.Tx.
|
|
type tokenQuerier interface {
|
|
QueryRow(query string, args ...any) *sql.Row
|
|
}
|
|
|
|
// checkToken validates an unused, unexpired token of purpose p by hash
|
|
// without changing anything.
|
|
func (s *Store) checkToken(q tokenQuerier, hash string, p Purpose) (userID int64, newEmail string, err error) {
|
|
var purpose string
|
|
var expires int64
|
|
var ne sql.NullString
|
|
var used sql.NullInt64
|
|
err = q.QueryRow(`SELECT user_id, purpose, new_email, expires_at, used_at FROM tokens WHERE token_hash = ?`, hash).
|
|
Scan(&userID, &purpose, &ne, &expires, &used)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, "", ErrTokenInvalid
|
|
}
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
if purpose != string(p) || used.Valid {
|
|
return 0, "", ErrTokenInvalid
|
|
}
|
|
if unix(s.now()) >= expires {
|
|
return 0, "", ErrTokenExpired
|
|
}
|
|
return userID, ne.String, nil
|
|
}
|
|
|
|
// TokenUser returns the user a token belongs to with the same checks as
|
|
// ConsumeToken, but does not burn it.
|
|
func (s *Store) TokenUser(raw string, p Purpose) (int64, error) {
|
|
uid, _, err := s.checkToken(s.db, HashToken(raw), p)
|
|
return uid, err
|
|
}
|
|
|
|
// ConsumeToken validates and burns a token. A purpose mismatch returns
|
|
// ErrTokenInvalid without burning it. Expired tokens return ErrTokenExpired.
|
|
func (s *Store) ConsumeToken(raw string, p Purpose) (userID int64, newEmail string, err error) {
|
|
hash := HashToken(raw)
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
defer tx.Rollback()
|
|
userID, newEmail, err = s.checkToken(tx, hash, p)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
if err := expectOne(tx.Exec(`UPDATE tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL`, unix(s.now()), hash)); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return 0, "", ErrTokenInvalid
|
|
}
|
|
return 0, "", err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, "", err
|
|
}
|
|
return userID, newEmail, nil
|
|
}
|
|
|
|
// ActivateWithToken burns an activation token of user id and activates the
|
|
// account with role in one transaction, but only while the user is still
|
|
// pending with verifiedHash, the hash the caller checked the password
|
|
// against. Otherwise it returns ErrAccountChanged and burns nothing.
|
|
// A token of another purpose or user returns ErrTokenInvalid.
|
|
func (s *Store) ActivateWithToken(raw string, id int64, role Role, verifiedHash string) error {
|
|
hash := HashToken(raw)
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
uid, _, err := s.checkToken(tx, hash, PurposeActivate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if uid != id {
|
|
return ErrTokenInvalid
|
|
}
|
|
now := unix(s.now())
|
|
if err := expectOne(tx.Exec(`UPDATE tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL`, now, hash)); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return ErrTokenInvalid
|
|
}
|
|
return err
|
|
}
|
|
err = expectOne(tx.Exec(`UPDATE users SET status = 'active', role = ?, activated_at = ?, activated_by = NULL
|
|
WHERE id = ? AND status = 'pending' AND password_hash = ?`, string(role), now, id, verifiedHash))
|
|
if errors.Is(err, ErrNotFound) {
|
|
return ErrAccountChanged
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// InvalidateTokens burns all of a user's unused tokens for purpose p.
|
|
func (s *Store) InvalidateTokens(userID int64, p Purpose) error {
|
|
_, err := s.db.Exec(`UPDATE tokens SET used_at = ? WHERE user_id = ? AND purpose = ? AND used_at IS NULL`,
|
|
unix(s.now()), userID, string(p))
|
|
return err
|
|
}
|
|
|
|
// PendingEmailChange returns the new address of the user's unused,
|
|
// unexpired email change, or "" when there is none. Only the address is
|
|
// returned, never the token or its hash.
|
|
func (s *Store) PendingEmailChange(userID int64) (string, error) {
|
|
var ne sql.NullString
|
|
err := s.db.QueryRow(`SELECT new_email FROM tokens
|
|
WHERE user_id = ? AND purpose = ? AND used_at IS NULL AND expires_at > ?
|
|
ORDER BY expires_at DESC LIMIT 1`, userID, string(PurposeEmailChange), unix(s.now())).Scan(&ne)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return ne.String, err
|
|
}
|
|
|
|
// PruneTokens deletes tokens that expired more than keep ago.
|
|
func (s *Store) PruneTokens(keep time.Duration) (int64, error) {
|
|
res, err := s.db.Exec(`DELETE FROM tokens WHERE expires_at < ?`, unix(s.now())-int64(keep/time.Second))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|