Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 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
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
9af580d
Merge upstream/master (#2139 squash of the part D branch) into feat/n…
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
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,8 @@ jobs:
"adminEmails": ["admin@e2e.test"],
"publicBaseUrl": "http://localhost:13582",
"mail": { "provider": "fake", "fromEmail": "noreply@e2e.test" },
"channelProposals": { "enabled": true }
"channelProposals": { "enabled": true },
"notifications": { "enabled": true }
}
}
JSON
Expand Down
28 changes: 27 additions & 1 deletion cmd/server/admin_stats_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ type guessedAccountJSON struct {
Failed int `json:"failed"`
}

// adminNotifyJSON: notification figures, counts only (watch lists are
// private). Present only with notifications on.
type adminNotifyJSON struct {
MailsLast24h int `json:"mailsLast24h"`
MaxMailsPerDay int `json:"maxMailsPerDay"`
Watches int `json:"watches"`
WatchingUsers int `json:"watchingUsers"`
}

type adminStatsJSON struct {
Total int `json:"total"`
Active int `json:"active"`
Expand All @@ -47,6 +56,7 @@ type adminStatsJSON struct {
FailedLogins24h int `json:"failedLogins24h"`
Mail7d mailCountsJSON `json:"mail7d"`
Guessing []guessedAccountJSON `json:"guessing"`
Notify *adminNotifyJSON `json:"notify,omitempty"`
}

func adminStatsFrom(st users.Stats) adminStatsJSON {
Expand All @@ -72,5 +82,21 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, _ *http.Request, _ *use
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, adminStatsFrom(st))
out := adminStatsFrom(st)
if s.auth.notify != nil {
total, _, err := s.auth.st.NotifyMailCounts(s.auth.notify.now().Add(-24 * time.Hour))
if err != nil {
log.Printf("[users] admin stats notification mails: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
watches, watching, err := s.auth.st.NotifyWatchStats()
if err != nil {
log.Printf("[users] admin stats watches: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
out.Notify = &adminNotifyJSON{MailsLast24h: total, MaxMailsPerDay: s.auth.set.notify.maxMailsPerDay, Watches: watches, WatchingUsers: watching}
}
writeJSON(w, out)
}
23 changes: 22 additions & 1 deletion cmd/server/auth_e2e.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ package main

// E2E-only hooks, compiled only with -tags e2etest (CI's corescope-server-e2e):
// the "fake" mail provider and GET /__e2e/last-mail, which returns the newest
// fake mail so Playwright can follow its link. Never part of a release build.
// fake mail so Playwright can follow its link, and GET /__e2e/unsubscribe-link,
// which returns a user's notification unsubscribe link. Never part of a
// release build.

import (
"net/http"
Expand All @@ -19,6 +21,10 @@ type e2eMail struct {
Text string `json:"text"`
}

type e2eLink struct {
Link string `json:"link"`
}

func init() {
fakeMailerAllowed = true
e2eRoutes = func(s *Server, r *mux.Router) {
Expand All @@ -35,5 +41,20 @@ func init() {
}
writeJSON(w, e2eMail{To: m.To, Subject: m.Subject, Text: m.Text})
}).Methods("GET")
// The unsubscribe link a notification mail would carry, so the E2E
// can follow it without waiting for a real state change.
r.HandleFunc("/__e2e/unsubscribe-link", func(w http.ResponseWriter, req *http.Request) {
u, err := s.auth.st.GetByEmail(req.URL.Query().Get("email"))
if err != nil {
writeError(w, http.StatusNotFound, "no such user")
return
}
p, err := s.auth.st.NotifyPrefsFor(u.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, e2eLink{Link: s.auth.link("unsubscribe", p.UnsubToken)})
}).Methods("GET")
}
}
15 changes: 15 additions & 0 deletions cmd/server/auth_e2e_on_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,18 @@ func TestE2ELastMailReturnsNewestFakeMail(t *testing.T) {
t.Fatalf("unexpected mail: %+v", m)
}
}

func TestE2EUnsubscribeLink(t *testing.T) {
f := newAuthFixture(t)
expectStatus(t, f.do("GET", "/__e2e/unsubscribe-link?email=nobody@example.test", nil), 404)
c := f.registerAndActivate(t, "e2e@example.test", "E2E", "correct horse battery")
w := f.do("GET", "/__e2e/unsubscribe-link?email=e2e@example.test", nil)
expectStatus(t, w, 200)
p, err := f.st.NotifyPrefsFor(c.me.ID)
if err != nil {
t.Fatal(err)
}
if l := decode[e2eLink](t, w); l.Link != testBase+"/#/account/unsubscribe?token="+p.UnsubToken {
t.Fatalf("link = %q", l.Link)
}
}
45 changes: 41 additions & 4 deletions cmd/server/auth_mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,24 @@ import (
"regexp"
"strings"
"time"
"unicode"

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

// mailLine is one linked line of a mail: a list item in HTML, the text and
// the URL on their own lines in plain text.
type mailLine struct{ text, url string }

type mailContent struct {
subject string
greeting string
paragraphs []string
lines []mailLine // after the paragraphs
actionLabel string
actionURL string
footer *mailLine // replaces the default footer when set
}

// link builds {publicBaseUrl}/#/account/{page}?token=… (never from the
Expand All @@ -27,22 +34,52 @@ func (a *authService) link(page, token string) string {
return a.set.baseURL.String() + "/#/account/" + page + "?token=" + url.QueryEscape(token)
}

// mailSafeText makes a name chosen by someone else safe for one line of a
// mail: control characters (line breaks and tabs included) and bidi
// overrides and isolates (U+202A-U+202E, U+2066-U+2069) become a space,
// runs of spaces collapse and the ends are trimmed. The HTML part escapes
// the result as usual.
func mailSafeText(s string) string {
return strings.Join(strings.Fields(strings.Map(func(r rune) rune {
if unicode.IsControl(r) || (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) {
return ' '
}
return r
}, s)), " ")
}

func (a *authService) render(to, toName, tag string, c mailContent) mailer.Message {
footer := "You received this because this address was used on " + a.set.baseURL.Host +
". If that was not you, you can ignore this mail."
var text, h strings.Builder
text.WriteString(c.greeting + "\n\n")
h.WriteString("<p>" + html.EscapeString(c.greeting) + "</p>")
for _, p := range c.paragraphs {
text.WriteString(p + "\n\n")
h.WriteString("<p>" + html.EscapeString(p) + "</p>")
}
if len(c.lines) > 0 {
h.WriteString("<ul>")
for _, l := range c.lines {
l.text = mailSafeText(l.text)
text.WriteString("- " + l.text + "\n " + l.url + "\n")
h.WriteString(`<li><a href="` + html.EscapeString(l.url) + `">` + html.EscapeString(l.text) + `</a></li>`)
}
h.WriteString("</ul>")
text.WriteString("\n")
}
if c.actionURL != "" {
text.WriteString(c.actionLabel + ":\n" + c.actionURL + "\n\n")
h.WriteString(`<p><a href="` + html.EscapeString(c.actionURL) + `">` + html.EscapeString(c.actionLabel) + `</a></p>`)
}
text.WriteString(footer + "\n")
h.WriteString(`<p style="color:#666;font-size:12px">` + html.EscapeString(footer) + `</p>`)
if c.footer != nil {
text.WriteString(c.footer.text + "\n" + c.footer.url + "\n")
h.WriteString(`<p style="color:#666;font-size:12px">` + html.EscapeString(c.footer.text) + ` <a href="` +
html.EscapeString(c.footer.url) + `">` + html.EscapeString(c.footer.url) + `</a></p>`)
} else {
footer := "You received this because this address was used on " + a.set.baseURL.Host +
". If that was not you, you can ignore this mail."
text.WriteString(footer + "\n")
h.WriteString(`<p style="color:#666;font-size:12px">` + html.EscapeString(footer) + `</p>`)
}
return mailer.Message{To: to, ToName: toName, Subject: "[" + a.set.fromName + "] " + c.subject,
HTML: h.String(), Text: text.String(), Tag: tag}
}
Expand Down
14 changes: 14 additions & 0 deletions cmd/server/auth_mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,17 @@ func TestLogStartupShowsAbsoluteUsersDBPath(t *testing.T) {
t.Fatalf("log line = %q, want the absolute path %q", buf.String(), want)
}
}

func TestRenderLinesAndFooter(t *testing.T) {
a, _ := newTestAuthService(t)
m := a.render("x@example.org", "X", "notify", mailContent{subject: "S", greeting: "Hi",
lines: []mailLine{{text: "<b>n</b>: offline", url: "https://e.org/#/nodes/ab"}},
footer: &mailLine{text: "Stop these mails:", url: "https://e.org/#/account/unsubscribe?token=t"}})
if !strings.Contains(m.HTML, `<ul><li><a href="https://e.org/#/nodes/ab">&lt;b&gt;n&lt;/b&gt;: offline</a></li></ul>`) ||
!strings.Contains(m.Text, "- <b>n</b>: offline\n https://e.org/#/nodes/ab\n") ||
!strings.Contains(m.HTML, `Stop these mails: <a href="https://e.org/#/account/unsubscribe?token=t">`) ||
!strings.Contains(m.Text, "Stop these mails:\nhttps://e.org/#/account/unsubscribe?token=t\n") ||
strings.Contains(m.Text, "If that was not you") {
t.Fatalf("rendered = %q / %q", m.HTML, m.Text)
}
}
3 changes: 3 additions & 0 deletions cmd/server/auth_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ func (s *Server) registerAuthRoutes(r *mux.Router) {
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")
Expand Down
16 changes: 15 additions & 1 deletion cmd/server/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,14 @@ type authService struct {
approvedMu sync.Mutex
approved atomic.Pointer[[]string]

// notify evaluates node notifications; nil unless
// userManagement.notifications.enabled.
notify *notifier

warnIndistinct sync.Once
stop chan struct{}
stopOnce sync.Once
wg sync.WaitGroup // the janitor
wg sync.WaitGroup // the janitor and the notifier
auditWG sync.WaitGroup // in-flight auditAsync writes
}

Expand Down Expand Up @@ -81,6 +85,16 @@ func (s *Server) initUserManagement(measurementDBPath string) error {
defer s.auth.wg.Done()
s.auth.janitor(time.Hour)
}()
if set.notify.enabled {
s.auth.notify = newNotifier(s.auth, serverNotifySource{s: s}, time.Now)
s.auth.wg.Add(1)
go func() {
defer s.auth.wg.Done()
s.auth.notify.loop(s.auth.stop, set.notify.interval)
}()
log.Printf("[notify] node notifications enabled: every %s, at most %d mails per user and %d in total per 24 hours",
set.notify.interval, set.notify.perUserPerDay, set.notify.maxMailsPerDay)
}
return nil
}

Expand Down
Loading
Loading