Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
9ae870e
docs(specs): admin dashboard for optional user management (sub-projec…
efiten Oct 7, 2026
e5971e8
docs(specs): admin dashboard amendments from the implementation plan
efiten Oct 7, 2026
dd3bc4d
feat(users): global audit list with keyset paging, login-row pruning,…
efiten Oct 7, 2026
ca25630
feat(users): Stats for the admin overview
efiten Oct 7, 2026
32a8a11
feat(users): audit logins, keep login rows 90 days
efiten Oct 7, 2026
3a8341d
feat(users): GET /api/admin/audit, the global audit log
efiten Oct 7, 2026
41617f9
feat(users): GET /api/admin/stats for the admin overview
efiten Oct 7, 2026
4e4758a
fix(users): write login audit rows in the background
efiten Oct 7, 2026
e3f0114
feat(admin): audit tab module
efiten Oct 7, 2026
98286a5
fix(admin): audit tab clears its message and stale rows on filter change
efiten Oct 7, 2026
a63e37d
feat(admin): overview tab module
efiten Oct 7, 2026
ed6c634
feat(admin): admin area with Overview, Users and Audit tabs
efiten Oct 7, 2026
28e53cd
test(e2e): admin overview, audit tab, legacy users link and axe
efiten Oct 7, 2026
aa59a7b
fix(admin): AA contrast for green status text on the hovered row
efiten Oct 7, 2026
996a2b4
fix(admin): audit user filter accepts only account ids from 1 up
efiten Oct 7, 2026
c922edc
fix(admin): Users tab search debounce does nothing after unmount
efiten Oct 7, 2026
a0cb653
fix(admin): one full overview refresh at a time
efiten Oct 7, 2026
bc2e8e6
docs(admin): amendment 7 names the single-connection timing residual
efiten Oct 7, 2026
59c988a
feat(admin): filter bouncing addresses on the server
efiten Oct 7, 2026
48f5e09
docs(specs): proposals and approved hashtag channels (sub-project D)
efiten Oct 7, 2026
c8a39f2
feat(channel): hashtag channel name rules
efiten Oct 7, 2026
dc22e11
feat(users): proposals table and state machine (schema v4)
efiten Oct 7, 2026
f61895d
feat(server): channel proposal routes behind userManagement.channelPr…
efiten Oct 7, 2026
0155430
fix(server): ignore a non-object channelKeys instead of failing confi…
efiten Oct 7, 2026
947ac9c
feat(server): list approved hashtag channels in /api/channels
efiten Oct 7, 2026
9467fbf
feat(ingestor): decrypt approved hashtag channels from users.db
efiten Oct 7, 2026
fe8e34a
feat(ui): channel proposals module and My proposals
efiten Oct 7, 2026
7a5b932
feat(channels): propose for everyone, approved channels in the list
efiten Oct 7, 2026
09cef5a
feat(admin): Proposals tab
efiten Oct 7, 2026
610340c
fix(admin): guard proposal decisions against double submit and stale …
efiten Oct 7, 2026
57a32d2
test(e2e): channel proposals flow; docs and CI config
efiten Oct 7, 2026
71e6238
fix(packets): escape the channel name in the packet detail message line
efiten Oct 7, 2026
d9566c2
fix(channel): refuse invisible fillers and non-ASCII spaces in hashta…
efiten Oct 7, 2026
9770a0f
fix(users): log the absolute users.db path in the server and the inge…
efiten Oct 7, 2026
58bb569
fix(admin): hide Proposed by and Decision on narrow screens in Proposals
efiten Oct 7, 2026
161dd84
fix(admin): let the proposal note input shrink with its cell on narro…
efiten Oct 7, 2026
e92d025
docs(proposals): full 409 text, absolute log paths, tightened name rules
efiten Oct 7, 2026
93e04b9
docs(proposals): ZWNJ and tag characters stay refused in channel names
efiten Oct 7, 2026
c2e44a2
docs(spec): node notifications (user management part E)
efiten Oct 7, 2026
a30dddb
feat(users): notification prefs, watches and states (schema v5)
efiten Oct 7, 2026
8ca1024
feat(mailer): pass extra mail headers to Brevo
efiten Oct 7, 2026
d03faa8
feat(users): delete notification states by key
efiten Oct 7, 2026
4c400f2
feat(notify): pure evaluator for node notifications
efiten Oct 7, 2026
d2db023
feat(notify): analyzer snapshot for the notification evaluator
efiten Oct 7, 2026
95869db
feat(notify): evaluate on a timer and mail one summary per user
efiten Oct 7, 2026
6c7e14d
feat(notify): account routes, unsubscribe link and admin figures
efiten Oct 7, 2026
9ac0dbc
feat(notify): Notify me toggle on the node pages
efiten Oct 7, 2026
4cf7416
feat(notify): account section, unsubscribe page and admin figures
efiten Oct 7, 2026
169c89d
test(e2e): node notifications flow; docs and CI config
efiten Oct 7, 2026
1e08be8
fix(notify): wait for the whole startup load before the first check
efiten Oct 7, 2026
82b3aeb
fix(notify): strip control and bidi characters from names in mails
efiten Oct 7, 2026
3775359
fix(notify): default maxMailsPerDay to 100
efiten Oct 7, 2026
3ccb525
refactor(notify): one DefaultNotifyPrefs constructor
efiten Oct 7, 2026
4bfa373
fix(notify): stop mailing once the server shuts down
efiten Oct 7, 2026
1b1148c
fix(notify): pause offline checks while ingest is stale
efiten Oct 7, 2026
408ba2f
feat(notify): log one line per tick with the store lock time
efiten Oct 7, 2026
0a96e81
fix(notify): size the Notify me toggle like its neighbours; space the…
efiten Oct 7, 2026
813b4e1
test(e2e): wait for a positive signal before asserting absence
efiten Oct 7, 2026
64b69dc
fix(notify): wait one silent window after a feed outage before offline
efiten Oct 7, 2026
76ec62e
docs(notify): grace is also lost when the server starts after an outa…
efiten Oct 7, 2026
39148ed
docs(spec): account data export and users.db backup
efiten Oct 8, 2026
a07e5bb
feat(users): snapshot and uncapped per-user accessors
efiten Oct 8, 2026
79739e1
feat(users): account data export
efiten Oct 8, 2026
f603028
feat(users): scheduled users.db snapshots with rotation
efiten Oct 8, 2026
3cb8ff4
feat(users): admin download of a users.db snapshot
efiten Oct 8, 2026
7cd1122
feat(users): Download my data on the account page; backup docs
efiten Oct 8, 2026
6618060
fix(users): backup rotation never deletes the snapshot just written
efiten Oct 8, 2026
4ccf5d8
fix(users): sweep orphaned temporary snapshots in the backup dir
efiten Oct 8, 2026
dd69f05
feat(users): export the address of an unconfirmed email change
efiten Oct 8, 2026
07ef4d3
docs(users): what a users.db restore brings back, and how to undo it
efiten Oct 8, 2026
38d631e
fix(account): say that backups can hold a deleted account for a few days
efiten Oct 8, 2026
9f164df
Merge remote-tracking branch 'upstream/master' into feat/admin-dashboard
efiten Oct 8, 2026
258deb3
Merge branch 'feat/admin-dashboard' into feat/channel-proposals
efiten Oct 8, 2026
a92b576
Merge branch 'feat/channel-proposals' into feat/notifications
efiten Oct 8, 2026
7b77f89
Merge branch 'feat/notifications' into feat/account-export-backup
efiten Oct 8, 2026
5420087
Merge upstream/master (#2140 squash of the part E branch) into feat/a…
efiten Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions cmd/server/account_export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package main

import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"

"github.com/meshcore-analyzer/users"
)

// accountExport is GET /api/account/export: everything users.db holds about
// the logged-in account (docs/specs/2026-10-08-account-export-and-users-backup-design.md).
// Credentials are left out on purpose: the password hash, session, link and
// unsubscribe tokens; of a pending email change only the new address is
// exported. Other accounts appear as ids only (activatedBy, audit).
type accountExport struct {
FormatVersion int `json:"formatVersion"`
ExportedAt string `json:"exportedAt"`
Instance string `json:"instance"`
Profile exportProfile `json:"profile"`
Sessions []exportSession `json:"sessions"`
Settings *exportSettings `json:"settings"`
Proposals []exportProposal `json:"proposals"`
Notifications exportNotifications `json:"notifications"`
Audit []exportAuditEntry `json:"audit"`
Mail []exportMail `json:"mail"`
}

type exportProfile struct {
ID int64 `json:"id"`
Email string `json:"email"`
PendingEmail *string `json:"pendingEmail"` // unconfirmed email change: the address only
DisplayName string `json:"displayName"`
Role users.Role `json:"role"`
Status users.Status `json:"status"`
CreatedAt string `json:"createdAt"`
LastLoginAt *string `json:"lastLoginAt"`
EmailBouncing bool `json:"emailBouncing"`
ActivatedAt *string `json:"activatedAt"`
ActivatedBy *int64 `json:"activatedBy"`
}

type exportSession struct {
CreatedAt string `json:"createdAt"`
LastSeenAt string `json:"lastSeenAt"`
UserAgent string `json:"userAgent,omitempty"`
}

type exportSettings struct {
Revision int64 `json:"revision"`
UpdatedAt string `json:"updatedAt"`
Doc settingsDoc `json:"doc"`
}

type exportProposal struct {
Kind string `json:"kind"`
Subject string `json:"subject"`
Status users.ProposalStatus `json:"status"`
Note string `json:"note"`
CreatedAt string `json:"createdAt"`
DecidedAt *string `json:"decidedAt"`
}

type exportNotifications struct {
Prefs *exportNotifyPrefs `json:"prefs"`
Watches []exportWatch `json:"watches"`
}

type exportNotifyPrefs struct {
Enabled bool `json:"enabled"`
Events []string `json:"events"`
}

type exportWatch struct {
Pubkey string `json:"pubkey"`
CreatedAt string `json:"createdAt"`
}

type exportAuditEntry struct {
At string `json:"at"`
Action string `json:"action"`
ActorUserID *int64 `json:"actorUserId"`
TargetUserID *int64 `json:"targetUserId"`
Detail map[string]string `json:"detail"`
}

type exportMail struct {
To string `json:"to"`
Purpose string `json:"purpose"`
SentAt string `json:"sentAt"`
Status string `json:"status"`
StatusAt string `json:"statusAt"`
Reason string `json:"reason,omitempty"`
Events []mailEventJSON `json:"events"`
}

// buildAccountExport reads every section for u. It only reads users.db.
func (a *authService) buildAccountExport(u *users.User, now time.Time) (*accountExport, error) {
x := &accountExport{
FormatVersion: 1,
ExportedAt: rfc3339(now),
Instance: a.set.baseURL.String(),
Profile: exportProfile{ID: u.ID, Email: u.Email, DisplayName: u.DisplayName, Role: u.Role, Status: u.Status,
CreatedAt: rfc3339(u.CreatedAt), LastLoginAt: rfc3339Ptr(u.LastLoginAt), EmailBouncing: u.EmailBouncing,
ActivatedAt: rfc3339Ptr(u.ActivatedAt), ActivatedBy: u.ActivatedBy},
Sessions: []exportSession{},
Proposals: []exportProposal{},
Notifications: exportNotifications{Watches: []exportWatch{}},
Audit: []exportAuditEntry{},
Mail: []exportMail{},
}
pending, err := a.st.PendingEmailChange(u.ID)
if err != nil {
return nil, fmt.Errorf("pending email: %w", err)
}
if pending != "" {
x.Profile.PendingEmail = &pending
}
sessions, err := a.st.ListSessions(u.ID)
if err != nil {
return nil, fmt.Errorf("sessions: %w", err)
}
for _, s := range sessions {
x.Sessions = append(x.Sessions, exportSession{CreatedAt: rfc3339(s.CreatedAt), LastSeenAt: rfc3339(s.LastSeenAt), UserAgent: s.UserAgent})
}
rec, err := a.st.SettingsRecordFor(u.ID)
if err != nil {
return nil, fmt.Errorf("settings: %w", err)
}
if rec != nil {
var doc settingsDoc
if err := json.Unmarshal([]byte(rec.Doc), &doc); err != nil {
return nil, fmt.Errorf("settings decode (%d bytes): %w", len(rec.Doc), err)
}
x.Settings = &exportSettings{Revision: rec.Version.Revision, UpdatedAt: rfc3339(rec.UpdatedAt), Doc: doc}
}
props, err := a.st.AllProposalsByUser(u.ID)
if err != nil {
return nil, fmt.Errorf("proposals: %w", err)
}
for _, p := range props {
x.Proposals = append(x.Proposals, exportProposal{Kind: p.Kind, Subject: p.Subject, Status: p.Status, Note: p.Note,
CreatedAt: rfc3339(p.CreatedAt), DecidedAt: rfc3339Ptr(p.DecidedAt)})
}
prefs, err := a.st.StoredNotifyPrefs(u.ID)
if err != nil {
return nil, fmt.Errorf("notification prefs: %w", err)
}
if prefs != nil {
x.Notifications.Prefs = &exportNotifyPrefs{Enabled: prefs.Enabled, Events: prefs.Events}
}
watches, err := a.st.WatchesFor(u.ID)
if err != nil {
return nil, fmt.Errorf("watches: %w", err)
}
for _, w := range watches {
x.Notifications.Watches = append(x.Notifications.Watches, exportWatch{Pubkey: w.Pubkey, CreatedAt: rfc3339(w.CreatedAt)})
}
entries, err := a.st.AuditAllFor(u.ID)
if err != nil {
return nil, fmt.Errorf("audit: %w", err)
}
for _, e := range entries {
x.Audit = append(x.Audit, exportAuditEntry{At: rfc3339(e.At), Action: e.Action,
ActorUserID: e.ActorUserID, TargetUserID: e.TargetUserID, Detail: e.Detail})
}
mails, err := a.st.MailAllForUser(u.ID)
if err != nil {
return nil, fmt.Errorf("mail: %w", err)
}
for _, m := range mails {
em := exportMail{To: m.ToEmail, Purpose: m.Purpose, SentAt: rfc3339(m.SentAt), Status: m.LastEvent,
StatusAt: rfc3339(m.LastEventAt), Reason: m.LastReason, Events: []mailEventJSON{}}
for _, e := range m.Events {
em.Events = append(em.Events, mailEventJSON{Event: e.Event, At: rfc3339(e.At), Reason: e.Reason})
}
x.Mail = append(x.Mail, em)
}
return x, nil
}

// handleAccountExport answers the export as a download. GET, so a plain
// link works and no CSRF token is needed. The user.export audit row is
// written after the export is built, so the file shows the state before it.
func (s *Server) handleAccountExport(w http.ResponseWriter, _ *http.Request, u *users.User, _ *users.Session) {
a := s.auth
now := time.Now().UTC()
x, err := a.buildAccountExport(u, now)
if err != nil {
log.Printf("[users] export for user #%d: %v", u.ID, err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
a.audit(idPtr(u.ID), "user.export", idPtr(u.ID), nil)
w.Header().Set("Content-Disposition", `attachment; filename="corescope-account-`+now.Format("2006-01-02")+`.json"`)
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, x)
}
Loading
Loading