Files
meshcore-analyzer/cmd/server/auth_routes.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

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)
}
}