mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 05:39: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>
57 lines
3.4 KiB
Go
57 lines
3.4 KiB
Go
package main
|
|
|
|
import "github.com/gorilla/mux"
|
|
|
|
// e2eRoutes is set only by the e2etest build (auth_e2e.go).
|
|
var e2eRoutes func(s *Server, r *mux.Router)
|
|
|
|
// registerAuthRoutes adds every user-management route. Called by
|
|
// RegisterRoutes only when s.auth != nil, so with the feature off these
|
|
// paths are not registered and fall through to the SPA handler like any
|
|
// unknown path.
|
|
func (s *Server) registerAuthRoutes(r *mux.Router) {
|
|
r.HandleFunc("/api/auth/register", s.requireOrigin(s.handleRegister)).Methods("POST")
|
|
r.HandleFunc("/api/auth/activate", s.requireOrigin(s.handleActivate)).Methods("POST")
|
|
r.HandleFunc("/api/auth/login", s.requireOrigin(s.handleLogin)).Methods("POST")
|
|
r.HandleFunc("/api/auth/logout", s.requireOrigin(s.handleLogout)).Methods("POST")
|
|
r.HandleFunc("/api/auth/me", s.handleMe).Methods("GET")
|
|
r.HandleFunc("/api/auth/forgot", s.requireOrigin(s.handleForgot)).Methods("POST")
|
|
r.HandleFunc("/api/auth/reset", s.requireOrigin(s.handleReset)).Methods("POST")
|
|
r.HandleFunc("/api/account", s.withUser(s.handleAccountPatch)).Methods("PATCH")
|
|
r.HandleFunc("/api/account", s.withUser(s.handleAccountDelete)).Methods("DELETE")
|
|
r.HandleFunc("/api/account/password", s.withUser(s.handleAccountPassword)).Methods("POST")
|
|
r.HandleFunc("/api/account/email", s.withUser(s.handleAccountEmail)).Methods("POST")
|
|
r.HandleFunc("/api/account/confirm-email", s.requireOrigin(s.handleConfirmEmail)).Methods("POST")
|
|
r.HandleFunc("/api/account/sessions", s.withUser(s.handleAccountSessions)).Methods("GET")
|
|
r.HandleFunc("/api/account/sessions/{id}", s.withUser(s.handleAccountSessionDelete)).Methods("DELETE")
|
|
r.HandleFunc("/api/account/settings", s.withUser(s.handleSettingsGet)).Methods("GET")
|
|
r.HandleFunc("/api/account/settings", s.withUser(s.handleSettingsPut)).Methods("PUT")
|
|
r.HandleFunc("/api/account/settings", s.withUser(s.handleSettingsDelete)).Methods("DELETE")
|
|
r.HandleFunc("/api/account/export", s.withUser(s.handleAccountExport)).Methods("GET")
|
|
r.HandleFunc("/api/admin/users", s.withAdmin(s.handleAdminUsers)).Methods("GET")
|
|
r.HandleFunc("/api/admin/users/{id}", s.withAdmin(s.handleAdminUserDetail)).Methods("GET")
|
|
r.HandleFunc("/api/admin/users/{id}", s.withAdmin(s.handleAdminDelete)).Methods("DELETE")
|
|
r.HandleFunc("/api/admin/users/{id}/disable", s.withAdmin(s.handleAdminDisable)).Methods("POST")
|
|
r.HandleFunc("/api/admin/users/{id}/enable", s.withAdmin(s.handleAdminEnable)).Methods("POST")
|
|
r.HandleFunc("/api/admin/users/{id}/role", s.withAdmin(s.handleAdminRole)).Methods("POST")
|
|
r.HandleFunc("/api/admin/users/{id}/resend-activation", s.withAdmin(s.handleAdminResendActivation)).Methods("POST")
|
|
r.HandleFunc("/api/admin/users/{id}/activate", s.withAdmin(s.handleAdminActivate)).Methods("POST")
|
|
r.HandleFunc("/api/admin/users/{id}/mail/{mailId}/refresh", s.withAdmin(s.handleAdminMailRefresh)).Methods("POST")
|
|
r.HandleFunc("/api/admin/audit", s.withAdmin(s.handleAdminAudit)).Methods("GET")
|
|
r.HandleFunc("/api/admin/stats", s.withAdmin(s.handleAdminStats)).Methods("GET")
|
|
r.HandleFunc("/api/admin/users-backup", s.withAdmin(s.handleAdminUsersBackup)).Methods("GET")
|
|
if s.auth.set.proposals.enabled {
|
|
s.registerProposalRoutes(r)
|
|
}
|
|
if s.auth.notify != nil {
|
|
s.registerNotifyRoutes(r)
|
|
}
|
|
// The webhook exists only when a secret is configured.
|
|
if s.auth.set.webhookSecret != "" {
|
|
r.HandleFunc("/api/mail/brevo/webhook", s.handleBrevoWebhook).Methods("POST")
|
|
}
|
|
if e2eRoutes != nil {
|
|
e2eRoutes(s, r)
|
|
}
|
|
}
|