mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 23:37:22 +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>
116 lines
3.8 KiB
Go
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
|
|
}
|