mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 09:09:54 +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>
204 lines
5.6 KiB
Go
204 lines
5.6 KiB
Go
package users
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// MailEvent is one provider delivery event.
|
|
type MailEvent struct {
|
|
Event string
|
|
At time.Time
|
|
Reason string
|
|
}
|
|
|
|
// MailRecord is one sent mail with its delivery summary and history.
|
|
type MailRecord struct {
|
|
ID int64
|
|
UserID *int64
|
|
ToEmail string
|
|
Purpose string
|
|
ProviderMessageID string
|
|
SentAt time.Time
|
|
LastEvent string
|
|
LastEventAt time.Time
|
|
LastReason string
|
|
Events []MailEvent // chronological; filled by MailByID / MailForUser
|
|
}
|
|
|
|
const mailCols = `id, user_id, to_email, purpose, provider_message_id, sent_at, last_event, last_event_at, last_reason`
|
|
|
|
func scanMail(row rowScanner) (*MailRecord, error) {
|
|
var m MailRecord
|
|
var uid sql.NullInt64
|
|
var msgID sql.NullString
|
|
var sent, lastAt int64
|
|
if err := row.Scan(&m.ID, &uid, &m.ToEmail, &m.Purpose, &msgID, &sent, &m.LastEvent, &lastAt, &m.LastReason); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if uid.Valid {
|
|
v := uid.Int64
|
|
m.UserID = &v
|
|
}
|
|
m.ProviderMessageID = msgID.String
|
|
m.SentAt, m.LastEventAt = fromUnix(sent), fromUnix(lastAt)
|
|
return &m, nil
|
|
}
|
|
|
|
// LogMail records a sent mail. messageID may be empty if the provider gave none.
|
|
func (s *Store) LogMail(userID *int64, to, purpose, messageID string) (int64, error) {
|
|
now := unix(s.now())
|
|
var mid any
|
|
if messageID != "" {
|
|
mid = messageID
|
|
}
|
|
res, err := s.db.Exec(`INSERT INTO mail_log (user_id, to_email, purpose, provider_message_id, sent_at, last_event, last_event_at)
|
|
VALUES (?, ?, ?, ?, ?, 'sent', ?)`, nullInt(userID), to, purpose, mid, now, now)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// RecordMailEvent appends a provider event to the mail with that provider
|
|
// message id. Duplicates (same event and time) are ignored; the summary
|
|
// columns follow the newest event. found is false for unknown ids.
|
|
func (s *Store) RecordMailEvent(messageID, event string, at time.Time, reason string) (userID *int64, found bool, err error) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
defer tx.Rollback()
|
|
var id, lastAt int64
|
|
var uid sql.NullInt64
|
|
err = tx.QueryRow(`SELECT id, user_id, last_event_at FROM mail_log WHERE provider_message_id = ?`, messageID).
|
|
Scan(&id, &uid, &lastAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO mail_events (mail_id, event, at, reason) VALUES (?, ?, ?, ?)`,
|
|
id, event, unix(at), reason); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if unix(at) >= lastAt {
|
|
if _, err := tx.Exec(`UPDATE mail_log SET last_event = ?, last_event_at = ?, last_reason = ? WHERE id = ?`,
|
|
event, unix(at), reason, id); err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if uid.Valid {
|
|
v := uid.Int64
|
|
userID = &v
|
|
}
|
|
return userID, true, nil
|
|
}
|
|
|
|
func (s *Store) eventsFor(mailID int64) ([]MailEvent, error) {
|
|
rows, err := s.db.Query(`SELECT event, at, reason FROM mail_events WHERE mail_id = ? ORDER BY at, rowid`, mailID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []MailEvent
|
|
for rows.Next() {
|
|
var e MailEvent
|
|
var at int64
|
|
if err := rows.Scan(&e.Event, &at, &e.Reason); err != nil {
|
|
return nil, err
|
|
}
|
|
e.At = fromUnix(at)
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// MailByID returns one record with its events.
|
|
func (s *Store) MailByID(id int64) (*MailRecord, error) {
|
|
m, err := scanMail(s.db.QueryRow(`SELECT `+mailCols+` FROM mail_log WHERE id = ?`, id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if m.Events, err = s.eventsFor(m.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// MailForUser returns a user's mails, newest first, each with its events,
|
|
// at most limit (default 50).
|
|
func (s *Store) MailForUser(userID int64, limit int) ([]MailRecord, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
return s.mailForUser(userID, limit)
|
|
}
|
|
|
|
// MailAllForUser is MailForUser without a cap (the account export).
|
|
func (s *Store) MailAllForUser(userID int64) ([]MailRecord, error) {
|
|
return s.mailForUser(userID, noLimit)
|
|
}
|
|
|
|
func (s *Store) mailForUser(userID int64, limit int) ([]MailRecord, error) {
|
|
rows, err := s.db.Query(`SELECT `+mailCols+` FROM mail_log WHERE user_id = ? ORDER BY sent_at DESC, id DESC LIMIT ?`, userID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []MailRecord
|
|
for rows.Next() {
|
|
m, err := scanMail(rows)
|
|
if err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
out = append(out, *m)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range out {
|
|
if out[i].Events, err = s.eventsFor(out[i].ID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// LatestMailByUser maps user id → that user's most recent mail (no events).
|
|
func (s *Store) LatestMailByUser() (map[int64]MailRecord, error) {
|
|
rows, err := s.db.Query(`SELECT ` + mailCols + ` FROM mail_log m WHERE user_id IS NOT NULL
|
|
AND id = (SELECT MAX(id) FROM mail_log WHERE user_id = m.user_id)`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[int64]MailRecord{}
|
|
for rows.Next() {
|
|
m, err := scanMail(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[*m.UserID] = *m
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// PruneMail deletes mail records (and their events) older than maxAge.
|
|
func (s *Store) PruneMail(maxAge time.Duration) (int64, error) {
|
|
res, err := s.db.Exec(`DELETE FROM mail_log WHERE sent_at < ?`, unix(s.now())-int64(maxAge/time.Second))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|