From abbe6846fffbaadc93dca11d17c3843ecc30ae2d Mon Sep 17 00:00:00 2001 From: efiten Date: Thu, 8 Oct 2026 21:51:42 +0200 Subject: [PATCH] feat: propose and approve hashtag channels (user management part D) (#2139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part D of #2128: logged-in users propose a hashtag channel, an admin approves, rejects or later revokes it, and the ingestor decrypts approved channels without a restart. It is the first consumer of a generic proposal table, and answers the request in #2092. Stacked on #2138 (part C). Review the commits after that branch; GitHub shows C's commits here until it is merged. ## The situation - An instance decrypts only the hashtag channels listed in `hashChannels`. Users who want a city or club channel shown have to ask the operator, who edits `config.json` and restarts the ingestor. - #2092 asked for a suggest-and-approve flow; a downstream fork (dborup/CoreScope#99) runs one with anonymous suggestions and a file queue. ## What this PR adds **Name rules (`internal/channel`).** `ValidateHashtagName`, mirrored in the browser: - at most 31 UTF-8 bytes including `#` (firmware `ChannelDetails.name[32]`), case preserved, `Public` refused in any case; - control, bidi, separator and format characters refused except ZWJ, plus invisible fillers (`Other_Default_Ignorable_Code_Point`, U+2800, non-ASCII spaces). Go and JS give the same verdict for every code point. **Store.** Schema v4, one generic `proposals` table (kind, subject, status pending/approved/rejected/revoked, proposer, reviewer, note). One row per (kind, subject); limits are checked in the same transaction as the change. **Server** (only with `userManagement.channelProposals.enabled`) - `POST /api/proposals`, `GET /api/account/proposals`, `GET /api/admin/proposals`, `POST /api/admin/proposals/{id}/approve|reject|revoke`. - 400 for an invalid name. 409 for a duplicate, a rejected name, or a name already in `hashChannels`/`channelKeys` ("this channel is already decrypted on this instance"). 429 over a limit (`maxPending` 100, `maxApproved` 128, `perUserPerDay` 5). - `GET /api/channels` gains `approvedChannels`, so approved channels are listed before they have traffic. With the feature off the response is byte-identical. **Ingestor.** Opens `users.db` with `mode=ro` every 60 seconds, re-validates the names, and swaps its key map through an `atomic.Pointer`. Configured channels always win, and a failed read keeps the last good set. The ingestor never writes `users.db`; AGENTS.md now says so. **Frontend.** "Propose for everyone" in the add-channel dialog, an "approved" marker in the channel list, "My proposals" on the account page, and an admin "Proposals" tab with a warning on approve. The packet detail pane now escapes the channel name: until now only the operator chose those names. Spec: [`docs/specs/2026-10-07-channel-proposals-design.md`](https://github.com/efiten/CoreScope/blob/feat/channel-proposals/docs/specs/2026-10-07-channel-proposals-design.md). ## Performance Every key is tried on each GRP_TXT that no key opens (`decodeGrpTxt`). Benchmark of that worst case: | Keys | Time per packet | |---|---| | 320 configured | 204 to 216 µs | | 320 configured + 128 approved | 272 to 320 µs | That is about +33%, linear in the key count and capped by `maxApproved`. With the feature off the decoder gets the identical map and no ticker runs. ## Verification - `internal/channel`, `internal/users` (with `-race`), `cmd/server` and `cmd/ingestor` pass locally, 53 new Go tests. The ingestor's `TestWriteStatsAtomic_SymlinkAtDestIsReplaced` needs symlink rights on Windows and fails on `master` too. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 21 of 21 steps locally (propose, approve, the channel appears, revoke removes it). - On our staging and production instance since 7 October 2026. ## Not in this PR - Private (PSK) channels: their keys stay in the browser (#725). - Anonymous proposals. - Names that need ZWNJ (some Persian and Urdu spellings) and subdivision flags are refused, because ZWNJ and tag characters are format characters. Allowing them later only widens the rule, so no approved name gets stranded. --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/deploy.yml | 3 +- AGENTS.md | 5 + Dockerfile | 2 + cmd/ingestor/approved_channels.go | 154 +++++++ cmd/ingestor/approved_channels_test.go | 381 ++++++++++++++++ cmd/ingestor/config.go | 50 +++ cmd/ingestor/go.mod | 4 + cmd/ingestor/main.go | 15 +- cmd/server/auth_mail_test.go | 20 + cmd/server/auth_routes.go | 3 + cmd/server/auth_service.go | 54 ++- cmd/server/config.go | 58 +++ cmd/server/go.mod | 4 + cmd/server/openapi.go | 8 +- cmd/server/proposal_channels_test.go | 119 +++++ cmd/server/proposal_handlers.go | 240 ++++++++++ cmd/server/proposal_handlers_test.go | 409 ++++++++++++++++++ cmd/server/routes.go | 18 +- cmd/server/types.go | 9 +- cmd/server/user_mgmt_config.go | 60 ++- cmd/server/user_mgmt_config_test.go | 31 ++ config.example.json | 7 + .../2026-10-07-channel-proposals-design.md | 255 +++++++++++ docs/user-guide/accounts.md | 50 ++- internal/channel/hashtag.go | 72 +++ internal/channel/hashtag_test.go | 96 ++++ internal/users/audit_list_test.go | 4 +- internal/users/proposals.go | 298 +++++++++++++ internal/users/proposals_test.go | 376 ++++++++++++++++ internal/users/schema.go | 15 + internal/users/validate.go | 1 + public/account.css | 5 + public/account.js | 4 +- public/admin-audit.js | 3 +- public/admin-proposals.js | 125 ++++++ public/admin.css | 4 + public/admin.js | 13 +- public/channel-proposals.js | 148 +++++++ public/channels.js | 28 +- public/index.html | 2 + public/packets.js | 2 +- public/roles.js | 2 +- public/style.css | 5 + test-all.sh | 3 + tests/e2e/test-user-management-e2e.js | 78 +++- tests/unit/test-admin-dashboard-ui.js | 151 ++++++- tests/unit/test-channel-proposals-ui.js | 220 ++++++++++ tests/unit/test-channels-approved-ui.js | 122 ++++++ .../unit/test-packet-detail-channel-escape.js | 112 +++++ tests/unit/test-user-management-ui.js | 17 + 50 files changed, 3827 insertions(+), 38 deletions(-) create mode 100644 cmd/ingestor/approved_channels.go create mode 100644 cmd/ingestor/approved_channels_test.go create mode 100644 cmd/server/proposal_channels_test.go create mode 100644 cmd/server/proposal_handlers.go create mode 100644 cmd/server/proposal_handlers_test.go create mode 100644 docs/specs/2026-10-07-channel-proposals-design.md create mode 100644 internal/channel/hashtag.go create mode 100644 internal/channel/hashtag_test.go create mode 100644 internal/users/proposals.go create mode 100644 internal/users/proposals_test.go create mode 100644 public/admin-proposals.js create mode 100644 public/channel-proposals.js create mode 100644 tests/unit/test-channel-proposals-ui.js create mode 100644 tests/unit/test-channels-approved-ui.js create mode 100644 tests/unit/test-packet-detail-channel-escape.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1b95826d..99894e10 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -528,7 +528,8 @@ jobs: "dbPath": "/tmp/cs-um/users.db", "adminEmails": ["admin@e2e.test"], "publicBaseUrl": "http://localhost:13582", - "mail": { "provider": "fake", "fromEmail": "noreply@e2e.test" } + "mail": { "provider": "fake", "fromEmail": "noreply@e2e.test" }, + "channelProposals": { "enabled": true } } } JSON diff --git a/AGENTS.md b/AGENTS.md index 1498d386..0ecf7a1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,11 @@ scripts/ — Tooling (coverage collector, fixture capture, frontend in `TestUsersOpenIsTheOnlyServerWritePath` pins the one call site. Account data never goes into the analyzer DB, and measurement writes never go through `internal/users`. +- **The ingestor reads `users.db`, never writes it.** With + `userManagement.channelProposals.enabled` the ingestor opens `users.db` + read-only (`mode=ro`, raw SQL, no `internal/users` import) once a minute + for the approved hashtag channel names. `TestChannelKeySetIsReadOnly` pins + the read-only open. - Enforcement: `cmd/server/readonly_invariant_test.go` reflect-asserts that `PruneOldPackets`, `PruneOldMetrics`, and `RemoveStaleObservers` are NOT methods on the server's `*DB`. If you need a new write, add it to diff --git a/Dockerfile b/Dockerfile index 147c9bbf..76514d2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,7 @@ COPY internal/perfio/ ../../internal/perfio/ COPY internal/mbcapqueue/ ../../internal/mbcapqueue/ COPY internal/lora/ ../../internal/lora/ COPY internal/users/ ../../internal/users/ +COPY internal/channel/ ../../internal/channel/ COPY internal/mailer/ ../../internal/mailer/ RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg/mod \ @@ -83,6 +84,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ # Build ingestor WORKDIR /build/ingestor COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./ +COPY internal/channel/ ../../internal/channel/ COPY internal/geofilter/ ../../internal/geofilter/ COPY internal/sigvalidate/ ../../internal/sigvalidate/ COPY internal/packetpath/ ../../internal/packetpath/ diff --git a/cmd/ingestor/approved_channels.go b/cmd/ingestor/approved_channels.go new file mode 100644 index 00000000..3cd42e96 --- /dev/null +++ b/cmd/ingestor/approved_channels.go @@ -0,0 +1,154 @@ +package main + +import ( + "database/sql" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "sync/atomic" + "time" + + "github.com/meshcore-analyzer/channel" +) + +// Approved hashtag channels (docs/specs/2026-10-07-channel-proposals-design.md). +// The server owns users.db; the ingestor only reads it, read-only, with raw +// SQL, once per approvedChannelsRefresh, and never writes it (AGENTS.md, +// read/write separation). + +const approvedChannelsRefresh = time.Minute + +// approvedChannelsQuery is pinned in internal/users/proposals_test.go +// (ingestorApprovedQuery), which checks it against ApprovedSubjects. +const approvedChannelsQuery = `SELECT subject FROM proposals WHERE kind = 'hashtag_channel' AND status = 'approved' ORDER BY decided_at, id LIMIT ?` + +var errUsersDBMissing = errors.New("users.db not found") + +// channelKeySet hands each message the current channel key map. Snapshots +// are never mutated after they are stored, so decoders read them without a +// lock. refresh is called from one goroutine only (startup, then the ticker). +type channelKeySet struct { + configured map[string]string + path string + max int + cur atomic.Pointer[map[string]string] + + db *sql.DB // opened lazily, read-only + failing bool // the last read failed; logged once per failure streak + approved int // approved keys in the current snapshot +} + +// newChannelKeySet starts with the configured keys (the very map +// loadChannelKeys built, so the feature off changes nothing). +func newChannelKeySet(configured map[string]string, usersDBPath string, max int) *channelKeySet { + s := &channelKeySet{configured: configured, path: usersDBPath, max: max} + s.cur.Store(&configured) + return s +} + +// Snapshot returns the key map to decode one message with. +func (s *channelKeySet) Snapshot() map[string]string { return *s.cur.Load() } + +// refresh re-reads the approved names and swaps in configured + approved. +// A failed read keeps the current snapshot: only a successful read changes +// the set, so a locked or missing users.db never drops a key. +func (s *channelKeySet) refresh() { + names, err := s.readApproved() + if err != nil { + if !s.failing { + log.Printf("[channels] approved channels unavailable (%v); keeping the %d keys in force", err, len(s.Snapshot())) + s.failing = true + } + return + } + if s.failing { + log.Printf("[channels] approved channels readable again from %s", s.path) + s.failing = false + } + next, added := mergeApprovedKeys(s.configured, names) + if added != s.approved { + log.Printf("[channels] %d approved hashtag channel(s) in force", added) + } + s.approved = added + s.cur.Store(&next) +} + +func (s *channelKeySet) readApproved() ([]string, error) { + if s.db == nil { + if _, err := os.Stat(s.path); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w at %s", errUsersDBMissing, s.path) + } + return nil, err + } + db, err := sql.Open("sqlite3", usersDBReadOnlyDSN(s.path)) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + s.db = db + } + rows, err := s.db.Query(approvedChannelsQuery, s.max) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + out = append(out, name) + } + return out, rows.Err() +} + +// usersDBReadOnlyDSN opens users.db read-only: this process must never +// take a write lock on the server's file. +func usersDBReadOnlyDSN(path string) string { + return "file:" + path + "?mode=ro&_busy_timeout=5000" +} + +// mergeApprovedKeys returns a new map: the configured keys plus a derived key +// per approved name that passes channel.ValidateHashtagName unchanged and is +// not configured already (a configured name always wins). The second result +// counts the approved keys added. +func mergeApprovedKeys(configured map[string]string, names []string) (map[string]string, int) { + next := make(map[string]string, len(configured)+len(names)) + for k, v := range configured { + next[k] = v + } + added := 0 + for _, raw := range names { + name, err := channel.ValidateHashtagName(raw) + if err != nil || name != raw { + continue + } + if _, ok := next[name]; ok { + continue + } + next[name] = deriveHashtagChannelKey(name) + added++ + } + return next, added +} + +// Close closes the read-only handle, if any. +func (s *channelKeySet) Close() { + if s.db != nil { + s.db.Close() + } +} + +// logApprovedChannelsSource logs the users.db file the approved channels are +// read from, resolved to an absolute path so a relative one shows which file +// it means; the path as given when it cannot be resolved. +func logApprovedChannelsSource(path string) { + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + log.Printf("[proposals] reading approved channels from %s", path) +} diff --git a/cmd/ingestor/approved_channels_test.go b/cmd/ingestor/approved_channels_test.go new file mode 100644 index 00000000..e97fb876 --- /dev/null +++ b/cmd/ingestor/approved_channels_test.go @@ -0,0 +1,381 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "log" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" +) + +// The columns cmd/server's users.db (internal/users schema v4) has; the +// ingestor reads kind, subject, status, decided_at and id. +const testProposalsDDL = `CREATE TABLE IF NOT EXISTS proposals ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, + proposer_id INTEGER, + reviewer_id INTEGER, + note TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + decided_at INTEGER +)` + +type testProposal struct { + subject, status string + decidedAt int64 +} + +func execUsersDB(t *testing.T, path string, stmts ...string) { + t.Helper() + db, err := sql.Open("sqlite3", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("%s: %v", s, err) + } + } +} + +func writeUsersDB(t *testing.T, path string, rows ...testProposal) { + t.Helper() + stmts := []string{testProposalsDDL, `DELETE FROM proposals`} + for i, r := range rows { + stmts = append(stmts, fmt.Sprintf(`INSERT INTO proposals (kind, subject, status, created_at, decided_at) VALUES ('hashtag_channel', '%s', '%s', %d, %d)`, + r.subject, r.status, i, r.decidedAt)) + } + execUsersDB(t, path, stmts...) +} + +func configuredKeys() map[string]string { + return map[string]string{"Public": "8b3387e9c5cdea6ac9e5edbaa115cd72", "#mesh": deriveHashtagChannelKey("#mesh")} +} + +func TestApprovedChannelsQueryMatchesUsersPackage(t *testing.T) { + src, err := os.ReadFile(filepath.Join("..", "..", "internal", "users", "proposals_test.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), approvedChannelsQuery) { + t.Fatalf("internal/users/proposals_test.go no longer pins %q; keep the two in step", approvedChannelsQuery) + } +} + +func TestChannelKeySetOffReturnsConfiguredMap(t *testing.T) { + cfgd := configuredKeys() + s := newChannelKeySet(cfgd, filepath.Join(t.TempDir(), "users.db"), 128) + if reflect.ValueOf(s.Snapshot()).Pointer() != reflect.ValueOf(cfgd).Pointer() { + t.Fatal("before any refresh the decoder must get the very map loadChannelKeys built") + } +} + +func TestChannelKeySetLoadsApproved(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + writeUsersDB(t, path, + testProposal{"#mycity", "approved", 10}, testProposal{"#waiting", "pending", 0}, + testProposal{"#nope", "rejected", 11}, testProposal{"#gone", "revoked", 12}) + s := newChannelKeySet(configuredKeys(), path, 128) + defer s.Close() + s.refresh() + snap := s.Snapshot() + if snap["#mycity"] != deriveHashtagChannelKey("#mycity") { + t.Fatalf("#mycity key = %q", snap["#mycity"]) + } + for _, n := range []string{"#waiting", "#nope", "#gone"} { + if _, ok := snap[n]; ok { + t.Errorf("%s decrypted without approval", n) + } + } + if snap["Public"] == "" || snap["#mesh"] == "" || len(snap) != 3 { + t.Fatalf("snapshot = %v; want the configured keys plus #mycity", snap) + } +} + +func TestChannelKeySetConfiguredNameWins(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + writeUsersDB(t, path, testProposal{"#mesh", "approved", 1}) + cfgd := configuredKeys() + cfgd["#mesh"] = "00112233445566778899aabbccddeeff" + s := newChannelKeySet(cfgd, path, 128) + defer s.Close() + s.refresh() + if got := s.Snapshot()["#mesh"]; got != "00112233445566778899aabbccddeeff" { + t.Fatalf("#mesh = %q; the configured key must win", got) + } + // Revoking the proposal leaves the configured channel decrypted. + execUsersDB(t, path, `UPDATE proposals SET status = 'revoked'`) + s.refresh() + if got := s.Snapshot()["#mesh"]; got != "00112233445566778899aabbccddeeff" { + t.Fatalf("after revoke #mesh = %q", got) + } +} + +func TestChannelKeySetRevokeRemovesKeyOnNextRefresh(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + writeUsersDB(t, path, testProposal{"#mycity", "approved", 1}) + s := newChannelKeySet(configuredKeys(), path, 128) + defer s.Close() + s.refresh() + before := s.Snapshot() + execUsersDB(t, path, `UPDATE proposals SET status = 'revoked' WHERE subject = '#mycity'`) + s.refresh() + if _, ok := s.Snapshot()["#mycity"]; ok { + t.Fatal("#mycity still decrypted after revoke") + } + if _, ok := before["#mycity"]; !ok { + t.Fatal("an earlier snapshot must stay unchanged (decoders may still hold it)") + } +} + +func TestChannelKeySetMissingFileKeepsConfiguredAndLogsOnce(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) + defer log.SetOutput(os.Stderr) + path := filepath.Join(t.TempDir(), "users.db") + s := newChannelKeySet(configuredKeys(), path, 128) + defer s.Close() + s.refresh() + s.refresh() + if len(s.Snapshot()) != 2 { + t.Fatalf("snapshot = %v; want the configured keys", s.Snapshot()) + } + if n := strings.Count(buf.String(), "approved channels unavailable"); n != 1 { + t.Fatalf("logged %d times; want once:\n%s", n, buf.String()) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("the ingestor created users.db") + } + writeUsersDB(t, path, testProposal{"#mycity", "approved", 1}) + s.refresh() + if _, ok := s.Snapshot()["#mycity"]; !ok { + t.Fatal("users.db appearing later is picked up") + } +} + +func TestChannelKeySetReadErrorKeepsLastGoodSet(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + writeUsersDB(t, path, testProposal{"#mycity", "approved", 1}) + s := newChannelKeySet(configuredKeys(), path, 128) + defer s.Close() + s.refresh() + execUsersDB(t, path, `DROP TABLE proposals`) + s.refresh() + if _, ok := s.Snapshot()["#mycity"]; !ok { + t.Fatal("a failing read removed an approved key") + } +} + +func TestChannelKeySetSkipsNamesThatFailTheRules(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + writeUsersDB(t, path, + testProposal{"#public", "approved", 1}, testProposal{"nohash", "approved", 2}, + testProposal{"#" + strings.Repeat("a", 31), "approved", 3}, testProposal{"#ok", "approved", 4}) + s := newChannelKeySet(configuredKeys(), path, 128) + defer s.Close() + s.refresh() + snap := s.Snapshot() + if len(snap) != 3 || snap["#ok"] == "" { + t.Fatalf("snapshot = %v; want configured + #ok only", snap) + } +} + +func TestChannelKeySetCapKeepsOldestApprovals(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + writeUsersDB(t, path, testProposal{"#c", "approved", 30}, testProposal{"#a", "approved", 10}, testProposal{"#b", "approved", 20}) + s := newChannelKeySet(configuredKeys(), path, 2) + defer s.Close() + s.refresh() + snap := s.Snapshot() + if snap["#a"] == "" || snap["#b"] == "" || snap["#c"] != "" { + t.Fatalf("snapshot = %v; want #a and #b (oldest decisions) only", snap) + } +} + +func TestChannelKeySetIsReadOnly(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + writeUsersDB(t, path, testProposal{"#mycity", "approved", 1}) + s := newChannelKeySet(configuredKeys(), path, 128) + defer s.Close() + s.refresh() + _, err := s.db.Exec(`INSERT INTO proposals (kind, subject, status, created_at) VALUES ('hashtag_channel', '#x', 'approved', 1)`) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "readonly") { + t.Fatalf("write through the ingestor's users.db handle: err = %v; want a read-only refusal", err) + } + if !strings.Contains(usersDBReadOnlyDSN(path), "mode=ro") { + t.Fatal("DSN without mode=ro") + } +} + +// The server keeps users.db in WAL mode with a writer connection open. +func TestChannelKeySetReadsWALWithOpenWriter(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + writeUsersDB(t, path, testProposal{"#mycity", "approved", 1}) + w, err := sql.Open("sqlite3", path) + if err != nil { + t.Fatal(err) + } + defer w.Close() + w.SetMaxOpenConns(1) + if _, err := w.Exec(`PRAGMA journal_mode=WAL`); err != nil { + t.Fatal(err) + } + if _, err := w.Exec(`INSERT INTO proposals (kind, subject, status, created_at, decided_at) VALUES ('hashtag_channel', '#walcity', 'approved', 9, 9)`); err != nil { + t.Fatal(err) + } + s := newChannelKeySet(configuredKeys(), path, 128) + defer s.Close() + s.refresh() + if s.Snapshot()["#walcity"] == "" || s.Snapshot()["#mycity"] == "" { + t.Fatalf("snapshot = %v; want both approved names", s.Snapshot()) + } +} + +func grpTxtFor(t testing.TB, channelName, text string) []byte { + key := deriveHashtagChannelKey(channelName) + inner := append([]byte{1, 0, 0, 0, 0}, []byte(text)...) + ctHex, macHex := buildChannelEncrypted(key, inner) + kb, _ := hex.DecodeString(key) + ct, _ := hex.DecodeString(ctHex) + mac, _ := hex.DecodeString(macHex) + h := sha256.Sum256(kb) + return append(append([]byte{h[0]}, mac...), ct...) +} + +// Run with -race: decoders read snapshots while refresh swaps them. +func TestChannelKeySetConcurrentDecodeWhileSwapping(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + writeUsersDB(t, path, testProposal{"#mycity", "approved", 1}) + s := newChannelKeySet(configuredKeys(), path, 128) + defer s.Close() + s.refresh() + buf := grpTxtFor(t, "#mycity", "alice: hi") + stop := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + if p := decodeGrpTxt(buf, s.Snapshot()); p.Type == "CHAN" && p.Channel != "#mycity" { + t.Errorf("decoded as %q", p.Channel) + return + } + } + }() + } + for i := 0; i < 50; i++ { + status := "revoked" + if i%2 == 1 { + status = "approved" + } + execUsersDB(t, path, `UPDATE proposals SET status = '`+status+`'`) + s.refresh() + } + close(stop) + wg.Wait() + if p := decodeGrpTxt(buf, s.Snapshot()); p.Type != "CHAN" || p.Channel != "#mycity" { + t.Fatalf("final decode = %+v; want #mycity (last state approved)", p) + } +} + +// Perf evidence for the PR: worst case (no key opens the packet), a +// realistic configured set (320 keys, the size of channel-rainbow.json plus +// Public) alone vs plus 128 approved keys (the maxApproved default). +func BenchmarkDecodeGrpTxtApprovedKeys(b *testing.B) { + buf := grpTxtFor(b, "#nobody-has-this", "x: y") + base := configuredKeys() + for i := len(base); i < 320; i++ { + name := fmt.Sprintf("#rainbow%03d", i) + base[name] = deriveHashtagChannelKey(name) + } + names := make([]string, 128) + for i := range names { + names[i] = fmt.Sprintf("#bench%03d", i) + } + withApproved, _ := mergeApprovedKeys(base, names) + for _, c := range []struct { + name string + keys map[string]string + }{{"configured", base}, {"plus128approved", withApproved}} { + b.Run(c.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + decodeGrpTxt(buf, c.keys) + } + }) + } +} + +func TestApprovedChannelsConfig(t *testing.T) { + t.Setenv("DB_PATH", "") + dir := t.TempDir() + dbPath := filepath.ToSlash(filepath.Join(dir, "data", "meshcore.db")) + load := func(um string) *Config { + t.Helper() + p := filepath.Join(dir, "config.json") + if err := os.WriteFile(p, []byte(`{"dbPath": "`+dbPath+`"`+um+`}`), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := LoadConfig(p) + if err != nil { + t.Fatal(err) + } + return cfg + } + off := load(``) + if off.ApprovedChannelsEnabled() || off.ApprovedChannelsMax() != 128 || off.UsersDBPath() != filepath.Join(dir, "data", "users.db") { + t.Fatalf("no block: enabled=%v max=%d path=%q", off.ApprovedChannelsEnabled(), off.ApprovedChannelsMax(), off.UsersDBPath()) + } + on := load(`, "userManagement": {"enabled": true, "adminEmails": ["a@example.org"], "mail": {"provider": "brevo"}, "channelProposals": {"enabled": true}}`) + if !on.ApprovedChannelsEnabled() || on.ApprovedChannelsMax() != 128 { + t.Fatalf("on: enabled=%v max=%d", on.ApprovedChannelsEnabled(), on.ApprovedChannelsMax()) + } + for _, um := range []string{ + `, "userManagement": {"enabled": false, "channelProposals": {"enabled": true}}`, + `, "userManagement": {"enabled": true}`, + `, "userManagement": {"enabled": true, "channelProposals": {"enabled": false}}`, + } { + if load(um).ApprovedChannelsEnabled() { + t.Errorf("%s resolved as on", um) + } + } + custom := load(`, "userManagement": {"enabled": true, "dbPath": "accounts/users.db", "channelProposals": {"enabled": true, "maxApproved": 7}}`) + if custom.UsersDBPath() != "accounts/users.db" || custom.ApprovedChannelsMax() != 7 { + t.Fatalf("custom: path=%q max=%d", custom.UsersDBPath(), custom.ApprovedChannelsMax()) + } + if load(`, "userManagement": {"enabled": true, "channelProposals": {"enabled": true, "maxApproved": -3}}`).ApprovedChannelsMax() != 128 { + t.Fatal("negative maxApproved must fall back to 128") + } +} + +// The startup line shows the absolute users.db path, so a relative path +// resolved against another working directory than the server's is visible. +func TestApprovedChannelsSourceLogIsAbsolute(t *testing.T) { + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(prev) + logApprovedChannelsSource("users.db") + want, err := filepath.Abs("users.db") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "[proposals] reading approved channels from "+want+"\n") { + t.Fatalf("log line = %q, want the absolute path %q", buf.String(), want) + } +} diff --git a/cmd/ingestor/config.go b/cmd/ingestor/config.go index 52b7f14f..7bb6932b 100644 --- a/cmd/ingestor/config.go +++ b/cmd/ingestor/config.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "os" + "path/filepath" "strings" "sync" "time" @@ -110,6 +111,11 @@ type Config struct { // (#1608). Received messages are drained once the write path is ready. // 0 / unset => default. Bounded memory. IngestBufferSize int `json:"ingestBufferSize,omitempty"` + + // UserManagement is the part of the shared userManagement block the + // ingestor reads: where users.db is and whether approved hashtag channels + // are decrypted. Everything else in the block belongs to the server. + UserManagement *UserManagementConfig `json:"userManagement,omitempty"` } // NeighborEdgesDaysOrDefault returns the configured pruning window or 5. @@ -613,6 +619,50 @@ const autoRegionKeysDefaultMaxDerived = 256 // is irrelevant and a tighter interval only burns queries. const autoRegionKeysDefaultRefreshMinutes = 15 +// UserManagementConfig is the ingestor's view of userManagement. +type UserManagementConfig struct { + Enabled bool `json:"enabled"` + DBPath string `json:"dbPath,omitempty"` + ChannelProposals *ChannelProposalsConfig `json:"channelProposals,omitempty"` +} + +// ChannelProposalsConfig is the ingestor's view of +// userManagement.channelProposals. +type ChannelProposalsConfig struct { + Enabled bool `json:"enabled"` + MaxApproved int `json:"maxApproved,omitempty"` +} + +const defaultMaxApprovedChannels = 128 + +// ApprovedChannelsEnabled reports whether approved hashtag channels from +// users.db are decrypted: user management and channel proposals both on. +func (c *Config) ApprovedChannelsEnabled() bool { + u := c.UserManagement + return u != nil && u.Enabled && u.ChannelProposals != nil && u.ChannelProposals.Enabled +} + +// ApprovedChannelsMax caps the approved keys read from users.db; absent, +// zero or negative means the default, as on the server. +func (c *Config) ApprovedChannelsMax() int { + u := c.UserManagement + if u == nil || u.ChannelProposals == nil || u.ChannelProposals.MaxApproved <= 0 { + return defaultMaxApprovedChannels + } + return u.ChannelProposals.MaxApproved +} + +// UsersDBPath resolves users.db with the server's rule: userManagement.dbPath, +// else users.db next to the analyzer database. +func (c *Config) UsersDBPath() string { + if c.UserManagement != nil { + if p := strings.TrimSpace(c.UserManagement.DBPath); p != "" { + return p + } + } + return filepath.Join(filepath.Dir(c.DBPath), "users.db") +} + // AutoRegionKeysEnabled reports whether region keys may be derived from // declared-region answers. Default false. func (c *Config) AutoRegionKeysEnabled() bool { diff --git a/cmd/ingestor/go.mod b/cmd/ingestor/go.mod index 1d5cc637..a758d56c 100644 --- a/cmd/ingestor/go.mod +++ b/cmd/ingestor/go.mod @@ -44,3 +44,7 @@ require ( ) replace github.com/meshcore-analyzer/mbcapqueue => ../../internal/mbcapqueue + +require github.com/meshcore-analyzer/channel v0.0.0 + +replace github.com/meshcore-analyzer/channel => ../../internal/channel diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index f6956863..d125601f 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -103,6 +103,19 @@ func main() { log.Printf("No channel keys loaded — GRP_TXT packets will not be decrypted") } + keySet := newChannelKeySet(channelKeys, cfg.UsersDBPath(), cfg.ApprovedChannelsMax()) + if cfg.ApprovedChannelsEnabled() { + logApprovedChannelsSource(cfg.UsersDBPath()) + keySet.refresh() + go func() { + t := time.NewTicker(approvedChannelsRefresh) + defer t.Stop() + for range t.C { + keySet.refresh() + } + }() + } + regionSet := newRegionKeySet(cfg) if cfg.AutoRegionKeysEnabled() { // Fill the derived tier before the first packet is matched, so a @@ -190,7 +203,7 @@ func main() { markReceiptForTag(tag, time.Now()) status.MarkPacket(time.Now()) ingestBuffer.Submit(func() { - handleMessage(store, tag, src, m, channelKeys, regionSet, cfg) + handleMessage(store, tag, src, m, keySet.Snapshot(), regionSet, cfg) }) }) diff --git a/cmd/server/auth_mail_test.go b/cmd/server/auth_mail_test.go index dad95fe2..b9e11f1a 100644 --- a/cmd/server/auth_mail_test.go +++ b/cmd/server/auth_mail_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "log" + "path/filepath" "strings" "testing" @@ -58,3 +59,22 @@ func TestActivationMailMentionsPassword(t *testing.T) { } } } + +// The startup line shows the absolute users.db path, so a relative path +// resolved against another working directory than the ingestor's is visible. +func TestLogStartupShowsAbsoluteUsersDBPath(t *testing.T) { + a, _ := newTestAuthService(t) + a.set.dbPath = "users.db" + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(prev) + a.logStartup() + want, err := filepath.Abs("users.db") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "[users] user management enabled: db="+want+",") { + t.Fatalf("log line = %q, want the absolute path %q", buf.String(), want) + } +} diff --git a/cmd/server/auth_routes.go b/cmd/server/auth_routes.go index e985d83f..013840b9 100644 --- a/cmd/server/auth_routes.go +++ b/cmd/server/auth_routes.go @@ -38,6 +38,9 @@ func (s *Server) registerAuthRoutes(r *mux.Router) { 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") + if s.auth.set.proposals.enabled { + s.registerProposalRoutes(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") diff --git a/cmd/server/auth_service.go b/cmd/server/auth_service.go index a87c37be..2ffa75c4 100644 --- a/cmd/server/auth_service.go +++ b/cmd/server/auth_service.go @@ -3,7 +3,9 @@ package main import ( "log" "os" + "path/filepath" "sync" + "sync/atomic" "time" "github.com/meshcore-analyzer/mailer" @@ -23,6 +25,13 @@ type authService struct { hook *rateLimiter settingsPut *rateLimiter // PUT /api/account/settings, per user + // approved is the snapshot of approved hashtag channel names, filled at + // construction and reloaded from users.db after every proposal decision. + // approvedMu serialises the reloads so an older read never replaces a + // newer one. + approvedMu sync.Mutex + approved atomic.Pointer[[]string] + warnIndistinct sync.Once stop chan struct{} stopOnce sync.Once @@ -31,7 +40,7 @@ type authService struct { } func newAuthService(set *userMgmtSettings, st *users.Store, m mailer.Mailer) *authService { - return &authService{ + a := &authService{ st: st, mail: m, set: set, ipr: &wsLimiter{trustedProxies: set.trustedProxies}, login: newRateLimiter(10, 15*time.Minute), @@ -40,6 +49,8 @@ func newAuthService(set *userMgmtSettings, st *users.Store, m mailer.Mailer) *au settingsPut: newRateLimiter(60, time.Hour), stop: make(chan struct{}), } + a.refreshApproved() + return a } // initUserManagement builds s.auth when the feature is on. Call it after @@ -90,7 +101,7 @@ func (s *Server) closeUserManagement() { func (a *authService) logStartup() { log.Printf("[users] user management enabled: db=%s, %d config admin(s), webhook=%v", - a.set.dbPath, len(a.set.adminEmails), a.set.webhookSecret != "") + absForLog(a.set.dbPath), len(a.set.adminEmails), a.set.webhookSecret != "") admins, err := a.st.List(users.ListFilter{Role: users.RoleAdmin}) if err != nil { log.Printf("[users] list admins: %v", err) @@ -133,6 +144,9 @@ func (a *authService) prune() { if _, err := a.st.PruneAudit([]string{"user.login", "user.login.failed"}, 90*24*time.Hour); err != nil { log.Printf("[users] prune login audit: %v", err) } + if _, err := a.st.PruneProposals(proposalRetention); err != nil { + log.Printf("[users] prune proposals: %v", err) + } a.login.gc() a.signup.gc() a.hook.gc() @@ -141,6 +155,33 @@ func (a *authService) prune() { func (a *authService) isConfigAdmin(email string) bool { return a.set.adminEmails[email] } +// refreshApproved reloads the approved hashtag channel names from users.db, +// oldest approval first and capped at maxApproved like the ingestor. The +// mutex spans the read and the store, so concurrent decisions cannot leave +// an older read in place. A failure is logged and keeps the previous +// snapshot. No-op with proposals off. +func (a *authService) refreshApproved() { + if !a.set.proposals.enabled { + return + } + a.approvedMu.Lock() + defer a.approvedMu.Unlock() + list, err := a.st.ApprovedSubjects(users.KindHashtagChannel, a.set.proposals.maxApproved) + if err != nil { + log.Printf("[users] load approved channels: %v", err) + return + } + a.approved.Store(&list) +} + +// approvedChannels returns the current snapshot; never nil. +func (a *authService) approvedChannels() []string { + if p := a.approved.Load(); p != nil { + return *p + } + return []string{} +} + // roleFor is the role an address gets on activation. func (a *authService) roleFor(email string) users.Role { if a.isConfigAdmin(email) { @@ -182,3 +223,12 @@ func (a *authService) invalidateTokens(op string, uid int64, ps ...users.Purpose } return nil } + +// absForLog resolves path for a log line, so a relative users.db path shows +// which file it means; the path as given when it cannot be resolved. +func absForLog(path string) string { + if abs, err := filepath.Abs(path); err == nil { + return abs + } + return path +} diff --git a/cmd/server/config.go b/cmd/server/config.go index 038a29cc..efa6ed27 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -185,6 +185,13 @@ type Config struct { // see UserManagementEnabled. UserManagement *UserManagementConfig `json:"userManagement,omitempty"` + // HashChannels and ChannelKeys are the ingestor's channel configuration, + // read here for the names only: a channel proposal for a configured name + // is refused (see configuredChannelNames). ChannelKeys drops the key + // values while unmarshalling, so the server never holds key material. + HashChannels []string `json:"hashChannels,omitempty"` + ChannelKeys channelKeyNames `json:"channelKeys,omitempty"` + ResolvedPath *ResolvedPathConfig `json:"resolvedPath,omitempty"` NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"` @@ -1107,3 +1114,54 @@ func (c *Config) AnalyticsRecomputeIntervals() AnalyticsRecomputeIntervals { out.NodesClockSkew = get("nodesClockSkew") return out } + +// channelKeyNames is config.json channelKeys ({name: hexKey}) reduced to the +// names; the key values are discarded during unmarshalling. A value that is +// not an object is ignored with a log line instead of failing: LoadConfig +// falls back to pure defaults on any parse error, and this field must not +// change config loading for deployments without channel proposals. +type channelKeyNames []string + +func (n *channelKeyNames) UnmarshalJSON(b []byte) error { + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + log.Printf("[config] channelKeys is not an object; ignoring it for channel-proposal checks") + *n = nil + return nil + } + names := make(channelKeyNames, 0, len(m)) + for name := range m { + names = append(names, name) + } + *n = names + return nil +} + +// configuredChannelNames is the set of channel names the ingestor already +// decrypts from config.json, normalised as cmd/ingestor's loadChannelKeys +// does: hashChannels trimmed and prefixed with '#', channelKeys names as +// written except the well-known casing fix (public becomes Public). +// Case-sensitive, like the ingestor's key map. +func (c *Config) configuredChannelNames() map[string]bool { + names := map[string]bool{} + if c == nil { + return names + } + for _, raw := range c.HashChannels { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + if !strings.HasPrefix(name, "#") { + name = "#" + name + } + names[name] = true + } + for _, name := range c.ChannelKeys { + if strings.EqualFold(name, "public") { + name = "Public" + } + names[name] = true + } + return names +} diff --git a/cmd/server/go.mod b/cmd/server/go.mod index a9955e08..badbe96d 100644 --- a/cmd/server/go.mod +++ b/cmd/server/go.mod @@ -52,6 +52,10 @@ require ( replace github.com/meshcore-analyzer/users => ../../internal/users +require github.com/meshcore-analyzer/channel v0.0.0 + +replace github.com/meshcore-analyzer/channel => ../../internal/channel + require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 71ad4b04..f7fd68f5 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -87,6 +87,12 @@ func routeDescriptions() map[string]routeMeta { "POST /api/admin/users/{id}/mail/{mailId}/refresh": {Summary: "Pull delivery events for one mail from the provider (admin)", Tag: "users", Session: true}, "GET /api/admin/audit": {Summary: "Global audit log (admin)", Description: "Newest first, keyset-paginated: {entries, next}. Pass next as before for the following page; next is null on the last page. Entry: {id, at, action, actor, target, detail}; actor and target are {id, displayName, email}, {id, deleted: true} for a removed account, or null for the system. Login rows (user.login, user.login.failed) are kept 90 days. 400 on an invalid parameter.", Tag: "users", Session: true, QueryParams: []paramMeta{{Name: "action", Description: "One action, or a group ending in .* (user.login.* matches user.login and user.login.failed)", Type: "string"}, {Name: "user", Description: "User id, as actor or target", Type: "integer"}, {Name: "from", Description: "RFC 3339 time, inclusive", Type: "string"}, {Name: "to", Description: "RFC 3339 time, inclusive", Type: "string"}, {Name: "before", Description: "Only entries with a smaller id (next of the previous page)", Type: "integer"}, {Name: "limit", Description: "Page size, default 100, capped at 500", Type: "integer"}}}, "GET /api/admin/stats": {Summary: "User figures for the admin overview (admin)", Description: "Accounts by status, active admins, pending accounts older than 24 hours (stuckPending), bouncing addresses, registrations in 7 and 30 days and per UTC day for 30 days (newPerDay, oldest first), users active in 7 and 30 days, logins and failed logins in 24 hours, mail sent in 7 days by latest delivery status (mail7d), and accounts with 5 or more failed logins in 24 hours (guessing: [{userId, displayName, failed}]).", Tag: "users", Session: true}, + "POST /api/proposals": {Summary: "Propose a hashtag channel", Description: "Body {kind: \"hashtag_channel\", subject}. subject is trimmed and gets a leading # when missing; at most 31 UTF-8 bytes including the #, no control or invisible characters (ZWJ allowed), not Public. 201 with the proposal {id, kind, subject, status, note, createdAt, decidedAt}. 400 invalid kind or name, 409 already pending, already approved or rejected, or already decrypted on this instance (a name in config.json hashChannels or channelKeys, case-sensitive), 429 when the caller created userManagement.channelProposals.perUserPerDay proposals in 24 hours or maxPending proposals wait for review. Registered only when userManagement.channelProposals.enabled.", Tag: "users", Session: true}, + "GET /api/account/proposals": {Summary: "List own proposals", Description: "The caller's proposals, newest first: [{id, kind, subject, status, note, createdAt, decidedAt}]. note is the reviewer's reason.", Tag: "users", Session: true}, + "GET /api/admin/proposals": {Summary: "List proposals (admin)", Description: "Newest first, at most 500: the proposal fields plus proposer and reviewer ({id, displayName, email}, or null once the account is deleted). 400 on an invalid filter.", Tag: "users", Session: true, QueryParams: []paramMeta{{Name: "status", Description: "pending | approved | rejected | revoked", Type: "string"}, {Name: "kind", Description: "hashtag_channel", Type: "string"}}}, + "POST /api/admin/proposals/{id}/approve": {Summary: "Approve a proposal (admin)", Description: "Body {note} (at most 500 characters). Pending only (409 otherwise). The ingestor decrypts the channel from its next refresh (at most a minute) and /api/channels lists it in approvedChannels. 409 when userManagement.channelProposals.maxApproved channels are approved.", Tag: "users", Session: true}, + "POST /api/admin/proposals/{id}/reject": {Summary: "Reject a proposal (admin)", Description: "Body {note}. Pending only (409 otherwise). The name cannot be proposed again until the row is pruned, 90 days after the decision.", Tag: "users", Session: true}, + "POST /api/admin/proposals/{id}/revoke": {Summary: "Revoke an approved proposal (admin)", Description: "Body {note}. Approved only (409 otherwise). The ingestor stops decrypting the channel from its next refresh unless the channel is configured; stored messages stay. The name can be proposed again.", Tag: "users", Session: true}, "POST /api/mail/brevo/webhook": {Summary: "Brevo delivery-event webhook", Description: "Authenticated with Authorization: Bearer . Registered only when the secret is set.", Tag: "users"}, // Packets @@ -161,7 +167,7 @@ func routeDescriptions() map[string]routeMeta { }}, // Channels - "GET /api/channels": {Summary: "List channels", Description: "Returns known mesh channels with message counts.", Tag: "channels"}, + "GET /api/channels": {Summary: "List channels", Description: "Returns known mesh channels with message counts. With userManagement.channelProposals.enabled the response also carries approvedChannels: the approved hashtag channel names, oldest approval first, at most maxApproved, listed also before they have traffic.", Tag: "channels"}, "GET /api/channels/{hash}/messages": {Summary: "Get channel messages", Description: "Returns messages for a specific channel.", Tag: "channels"}, // Observers diff --git a/cmd/server/proposal_channels_test.go b/cmd/server/proposal_channels_test.go new file mode 100644 index 00000000..0419014c --- /dev/null +++ b/cmd/server/proposal_channels_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/mux" + "github.com/meshcore-analyzer/mailer" + "github.com/meshcore-analyzer/users" +) + +func getBody(router *mux.Router, path string) string { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + return w.Body.String() +} + +func TestChannelsResponseUnchangedWithoutProposals(t *testing.T) { + srv, router := setupTestServer(t) + base := getBody(router, "/api/channels") + if strings.Contains(base, "approvedChannels") { + t.Fatalf("approvedChannels without user management: %s", base) + } + a, _ := newTestAuthService(t) // user management on, proposals off + srv.auth = a + if got := getBody(router, "/api/channels"); got != base { + t.Fatalf("/api/channels changed with proposals off:\n%s\nvs\n%s", got, base) + } +} + +func TestChannelsResponseListsApprovedChannels(t *testing.T) { + srv, router := setupTestServer(t) + a, _ := newTestAuthService(t) + a.set.proposals = defaultProposalSettings() + srv.auth = a + var resp ChannelListResponse + if err := json.Unmarshal([]byte(getBody(router, "/api/channels")), &resp); err != nil { + t.Fatal(err) + } + if resp.ApprovedChannels == nil || len(*resp.ApprovedChannels) != 0 { + t.Fatalf("approvedChannels with none approved = %v; want present and empty", resp.ApprovedChannels) + } + u, err := a.st.CreatePending("pat@example.org", "Pat", "x") + if err != nil { + t.Fatal(err) + } + p, err := a.st.Propose(users.KindHashtagChannel, "#mycity", u.ID, users.ProposalLimits{}) + if err != nil { + t.Fatal(err) + } + if _, err := a.st.Decide(p.ID, users.ProposalApprove, u.ID, "", 0); err != nil { + t.Fatal(err) + } + a.refreshApproved() + resp = ChannelListResponse{} + if err := json.Unmarshal([]byte(getBody(router, "/api/channels")), &resp); err != nil { + t.Fatal(err) + } + if resp.ApprovedChannels == nil || strings.Join(*resp.ApprovedChannels, ",") != "#mycity" { + t.Fatalf("approvedChannels = %v; want [#mycity]", resp.ApprovedChannels) + } +} + +func TestDecideRefreshesApprovedChannels(t *testing.T) { + f := newProposalFixture(t, defaultProposalSettings()) + admin, user := proposalPeople(t, f) + p := decode[proposalJSON](t, postProposal(t, f, user, "#a")) + expectStatus(t, f.do("POST", fmt.Sprintf("/api/admin/proposals/%d/approve", p.ID), decideRequest{}, as(admin)), 200) + if got := f.srv.auth.approvedChannels(); strings.Join(got, ",") != "#a" { + t.Fatalf("after approve = %v", got) + } + expectStatus(t, f.do("POST", fmt.Sprintf("/api/admin/proposals/%d/revoke", p.ID), decideRequest{}, as(admin)), 200) + if got := f.srv.auth.approvedChannels(); got == nil || len(got) != 0 { + t.Fatalf("after revoke = %#v; want empty", got) + } +} + +// The snapshot is filled at construction and capped at maxApproved, oldest +// approval first: the ingestor applies the same cap and order. +func TestNewAuthServiceLoadsApprovedChannelsCapped(t *testing.T) { + a, _ := newTestAuthService(t) + u, err := a.st.CreatePending("pat@example.org", "Pat", "x") + if err != nil { + t.Fatal(err) + } + for _, s := range []string{"#a", "#b"} { + p, err := a.st.Propose(users.KindHashtagChannel, s, u.ID, users.ProposalLimits{}) + if err != nil { + t.Fatal(err) + } + if _, err := a.st.Decide(p.ID, users.ProposalApprove, u.ID, "", 0); err != nil { + t.Fatal(err) + } + } + set := *a.set + set.proposals = defaultProposalSettings() + set.proposals.maxApproved = 1 + b := newAuthService(&set, a.st, &mailer.Fake{}) + if got := b.approvedChannels(); strings.Join(got, ",") != "#a" { + t.Fatalf("approvedChannels = %v; want [#a]", got) + } + off := newAuthService(a.set, a.st, &mailer.Fake{}) + if got := off.approvedChannels(); got == nil || len(got) != 0 { + t.Fatalf("proposals off = %#v; want empty", got) + } +} + +func TestClientConfigAdvertisesChannelProposals(t *testing.T) { + srv, router := setupTestServer(t) + a, _ := newTestAuthService(t) + a.set.proposals = defaultProposalSettings() + srv.auth = a + if body := getBody(router, "/api/config/client"); !strings.Contains(body, `"userManagement":{"enabled":true,"channelProposals":true}`) { + t.Fatalf("client config = %s", body) + } +} diff --git a/cmd/server/proposal_handlers.go b/cmd/server/proposal_handlers.go new file mode 100644 index 00000000..59b45f9c --- /dev/null +++ b/cmd/server/proposal_handlers.go @@ -0,0 +1,240 @@ +package main + +import ( + "errors" + "log" + "net/http" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/gorilla/mux" + "github.com/meshcore-analyzer/channel" + "github.com/meshcore-analyzer/users" +) + +// Proposals and approved hashtag channels +// (docs/specs/2026-10-07-channel-proposals-design.md). The routes exist only +// when userManagement.channelProposals.enabled (see registerAuthRoutes). + +const ( + maxProposalNote = 500 + proposalRetention = 90 * 24 * time.Hour // rejected and revoked rows, from the decision +) + +type proposalRequest struct { + Kind string `json:"kind"` + Subject string `json:"subject"` +} + +type decideRequest struct { + Note string `json:"note"` +} + +type proposalJSON struct { + ID int64 `json:"id"` + 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"` +} + +// adminProposalJSON adds who proposed and who decided; only admins see it. +type adminProposalJSON struct { + proposalJSON + Proposer *auditUserRef `json:"proposer"` + Reviewer *auditUserRef `json:"reviewer"` +} + +func (s *Server) registerProposalRoutes(r *mux.Router) { + r.HandleFunc("/api/proposals", s.withUser(s.proposalCreateHandler(s.cfg.configuredChannelNames()))).Methods("POST") + r.HandleFunc("/api/account/proposals", s.withUser(s.handleAccountProposals)).Methods("GET") + r.HandleFunc("/api/admin/proposals", s.withAdmin(s.handleAdminProposals)).Methods("GET") + r.HandleFunc("/api/admin/proposals/{id}/approve", s.withAdmin(s.decideHandler(users.ProposalApprove))).Methods("POST") + r.HandleFunc("/api/admin/proposals/{id}/reject", s.withAdmin(s.decideHandler(users.ProposalReject))).Methods("POST") + r.HandleFunc("/api/admin/proposals/{id}/revoke", s.withAdmin(s.decideHandler(users.ProposalRevoke))).Methods("POST") +} + +func proposalToJSON(p users.Proposal) proposalJSON { + return proposalJSON{ID: p.ID, Kind: p.Kind, Subject: p.Subject, Status: p.Status, Note: p.Note, + CreatedAt: rfc3339(p.CreatedAt), DecidedAt: rfc3339Ptr(p.DecidedAt)} +} + +// proposalUserIDs lists the distinct proposer and reviewer ids. +func proposalUserIDs(list []users.Proposal) []int64 { + seen := map[int64]bool{} + var ids []int64 + for _, p := range list { + for _, id := range []*int64{p.ProposerID, p.ReviewerID} { + if id != nil && !seen[*id] { + seen[*id] = true + ids = append(ids, *id) + } + } + } + return ids +} + +func (s *Server) adminProposalRows(list []users.Proposal) ([]adminProposalJSON, error) { + known, err := s.auth.st.UsersByID(proposalUserIDs(list)) + if err != nil { + return nil, err + } + out := make([]adminProposalJSON, 0, len(list)) + for _, p := range list { + out = append(out, adminProposalJSON{proposalJSON: proposalToJSON(p), + Proposer: auditRef(p.ProposerID, known), Reviewer: auditRef(p.ReviewerID, known)}) + } + return out, nil +} + +// validateProposalNote trims a reviewer's note and enforces at most 500 +// characters without control or invisible characters (newline and ZWJ allowed). +func validateProposalNote(raw string) (string, error) { + n := strings.TrimSpace(raw) + if utf8.RuneCountInString(n) > maxProposalNote { + return "", &users.ValidationError{Msg: "the note is at most 500 characters"} + } + for _, r := range n { + if r == '\n' || r == '\u200d' { + continue + } + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || r == '\u2028' || r == '\u2029' { + return "", &users.ValidationError{Msg: "the note contains invisible or control characters"} + } + } + return n, nil +} + +// writeProposalError answers a failed store call; unknown errors are logged +// and answered 500. +func writeProposalError(w http.ResponseWriter, op string, err error) { + switch { + case errors.Is(err, users.ErrNotFound): + writeError(w, http.StatusNotFound, "proposal not found") + case errors.Is(err, users.ErrProposalPending): + writeError(w, http.StatusConflict, "this channel is already proposed and waits for review") + case errors.Is(err, users.ErrProposalApproved): + writeError(w, http.StatusConflict, "this channel is already approved") + case errors.Is(err, users.ErrProposalRejected): + writeError(w, http.StatusConflict, "this channel was rejected by an admin") + case errors.Is(err, users.ErrProposalTransition): + writeError(w, http.StatusConflict, "not possible in the proposal's current state") + case errors.Is(err, users.ErrProposalApprovedLimit): + writeError(w, http.StatusConflict, "the limit of approved channels is reached; revoke one first") + case errors.Is(err, users.ErrProposalUserLimit): + writeError(w, http.StatusTooManyRequests, "you reached the daily proposal limit; try again tomorrow") + case errors.Is(err, users.ErrProposalPendingLimit): + writeError(w, http.StatusTooManyRequests, "too many proposals wait for review; try again later") + default: + log.Printf("[users] proposal %s: %v", op, err) + writeError(w, http.StatusInternalServerError, "internal error") + } +} + +// proposalCreateHandler serves POST /api/proposals. configured holds the +// channel names config.json already decrypts (Config.configuredChannelNames), +// built once at route registration; proposing one of them is refused. +func (s *Server) proposalCreateHandler(configured map[string]bool) authedHandler { + return func(w http.ResponseWriter, r *http.Request, u *users.User, _ *users.Session) { + var req proposalRequest + if !decodeJSON(w, r, &req) { + return + } + if req.Kind != users.KindHashtagChannel { + writeError(w, http.StatusBadRequest, "unsupported proposal kind") + return + } + name, err := channel.ValidateHashtagName(req.Subject) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if configured[name] { + writeError(w, http.StatusConflict, "this channel is already decrypted on this instance") + return + } + ps := s.auth.set.proposals + p, err := s.auth.st.Propose(req.Kind, name, u.ID, users.ProposalLimits{MaxPending: ps.maxPending, PerUserPerDay: ps.perUserPerDay}) + if err != nil { + writeProposalError(w, "create", err) + return + } + s.auth.audit(idPtr(u.ID), "proposal.create", nil, map[string]string{"kind": p.Kind, "subject": p.Subject}) + writeJSONStatus(w, http.StatusCreated, proposalToJSON(*p)) + } +} + +func (s *Server) handleAccountProposals(w http.ResponseWriter, _ *http.Request, u *users.User, _ *users.Session) { + list, err := s.auth.st.ProposalsByUser(u.ID) + if err != nil { + log.Printf("[users] list proposals for user #%d: %v", u.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + out := make([]proposalJSON, 0, len(list)) + for _, p := range list { + out = append(out, proposalToJSON(p)) + } + writeJSON(w, out) +} + +func (s *Server) handleAdminProposals(w http.ResponseWriter, r *http.Request, _ *users.User, _ *users.Session) { + q := r.URL.Query() + f := users.ProposalFilter{Status: users.ProposalStatus(q.Get("status")), Kind: q.Get("kind")} + if f.Status != "" && !f.Status.Valid() { + writeError(w, http.StatusBadRequest, "invalid status filter") + return + } + if f.Kind != "" && f.Kind != users.KindHashtagChannel { + writeError(w, http.StatusBadRequest, "invalid kind filter") + return + } + list, err := s.auth.st.ListProposals(f) + if err != nil { + writeProposalError(w, "list", err) + return + } + out, err := s.adminProposalRows(list) + if err != nil { + writeProposalError(w, "list users", err) + return + } + writeJSON(w, out) +} + +// decideHandler serves POST /api/admin/proposals/{id}/, body {note}. +func (s *Server) decideHandler(action users.ProposalAction) authedHandler { + return func(w http.ResponseWriter, r *http.Request, admin *users.User, _ *users.Session) { + id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "invalid proposal id") + return + } + var req decideRequest + if !decodeJSON(w, r, &req) { + return + } + note, err := validateProposalNote(req.Note) + if writeValidation(w, err) { + return + } + p, err := s.auth.st.Decide(id, action, admin.ID, note, s.auth.set.proposals.maxApproved) + if err != nil { + writeProposalError(w, string(action), err) + return + } + s.auth.audit(idPtr(admin.ID), "proposal."+string(action), p.ProposerID, map[string]string{"kind": p.Kind, "subject": p.Subject}) + s.auth.refreshApproved() + rows, err := s.adminProposalRows([]users.Proposal{*p}) + if err != nil { + writeProposalError(w, "reload", err) + return + } + writeJSON(w, rows[0]) + } +} diff --git a/cmd/server/proposal_handlers_test.go b/cmd/server/proposal_handlers_test.go new file mode 100644 index 00000000..4a4e6ebe --- /dev/null +++ b/cmd/server/proposal_handlers_test.go @@ -0,0 +1,409 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/meshcore-analyzer/users" +) + +func defaultProposalSettings() proposalSettings { + return proposalSettings{enabled: true, maxPending: 100, maxApproved: 128, perUserPerDay: 5} +} + +// newProposalFixture is newAuthFixture with channel proposals set to ps +// before the routes are registered. admin@example.org is a config admin. +func newProposalFixture(t *testing.T, ps proposalSettings) *authFixture { + t.Helper() + return newProposalFixtureWithConfig(t, ps, &Config{APIKey: testAPIKey}) +} + +func newProposalFixtureWithConfig(t *testing.T, ps proposalSettings, cfg *Config) *authFixture { + t.Helper() + a, fake := newTestAuthService(t, "admin@example.org") + a.set.proposals = ps + srv := &Server{cfg: cfg, perfStats: NewPerfStats(), auth: a} + r := mux.NewRouter() + srv.registerAuthRoutes(r) + return &authFixture{srv: srv, router: r, fake: fake, st: a.st} +} + +func proposalPeople(t *testing.T, f *authFixture) (admin, user *client) { + t.Helper() + return f.registerAndActivate(t, "admin@example.org", "Admin", pw), f.registerAndActivate(t, "pat@example.org", "Pat", pw) +} + +func postProposal(t *testing.T, f *authFixture, c *client, subject string) *httptest.ResponseRecorder { + t.Helper() + return f.do("POST", "/api/proposals", proposalRequest{Kind: users.KindHashtagChannel, Subject: subject}, as(c)) +} + +func TestProposalRoutesAbsentWhenProposalsOff(t *testing.T) { + f := newProposalFixture(t, proposalSettings{}) + for _, rt := range []struct{ method, path string }{ + {"POST", "/api/proposals"}, {"GET", "/api/account/proposals"}, + {"GET", "/api/admin/proposals"}, {"POST", "/api/admin/proposals/1/approve"}, + } { + if w := f.do(rt.method, rt.path, nil); w.Code != 404 { + t.Errorf("%s %s = %d with proposals off; want 404", rt.method, rt.path, w.Code) + } + } +} + +func TestProposeAndListOwn(t *testing.T) { + f := newProposalFixture(t, defaultProposalSettings()) + _, user := proposalPeople(t, f) + w := postProposal(t, f, user, " mycity ") + expectStatus(t, w, 201) + p := decode[proposalJSON](t, w) + if p.Subject != "#mycity" || p.Status != users.ProposalPending || p.Kind != users.KindHashtagChannel || p.DecidedAt != nil { + t.Fatalf("created = %+v", p) + } + w = f.do("GET", "/api/account/proposals", nil, as(user)) + expectStatus(t, w, 200) + if list := decode[[]proposalJSON](t, w); len(list) != 1 || list[0].ID != p.ID { + t.Fatalf("own list = %+v", list) + } + entries, err := f.st.AuditList(users.AuditFilter{Actions: []string{"proposal.create"}}) + if err != nil || len(entries) != 1 || *entries[0].ActorUserID != user.me.ID || + entries[0].Detail["subject"] != "#mycity" || entries[0].Detail["kind"] != users.KindHashtagChannel { + t.Fatalf("audit = %+v, %v", entries, err) + } +} + +func TestProposeRefusals(t *testing.T) { + f := newProposalFixture(t, defaultProposalSettings()) + admin, user := proposalPeople(t, f) + ok := proposalRequest{Kind: users.KindHashtagChannel, Subject: "#a"} + cases := []struct { + name string + body any + mods []reqMod + code int + msg string + }{ + {"logged out", ok, nil, 401, "not logged in"}, + {"no csrf", ok, []reqMod{as(&client{cookie: user.cookie})}, 403, "CSRF check failed"}, + {"kind", proposalRequest{Kind: "other", Subject: "#a"}, []reqMod{as(user)}, 400, "unsupported proposal kind"}, + {"public", proposalRequest{Kind: users.KindHashtagChannel, Subject: "Public"}, []reqMod{as(user)}, 400, "Public is the built-in channel"}, + {"too long", proposalRequest{Kind: users.KindHashtagChannel, Subject: "#" + strings.Repeat("a", 31)}, []reqMod{as(user)}, 400, "at most 31 bytes"}, + {"unknown field", map[string]string{"kind": users.KindHashtagChannel, "subject": "#a", "x": "1"}, []reqMod{as(user)}, 400, "invalid request body"}, + } + for _, c := range cases { + w := f.do("POST", "/api/proposals", c.body, c.mods...) + if w.Code != c.code || !strings.Contains(w.Body.String(), c.msg) { + t.Errorf("%s: %d %s; want %d containing %q", c.name, w.Code, w.Body.String(), c.code, c.msg) + } + } + a := decode[proposalJSON](t, postProposal(t, f, user, "#a")) + if w := postProposal(t, f, admin, "#a"); w.Code != 409 || !strings.Contains(w.Body.String(), "already proposed") { + t.Errorf("pending duplicate: %d %s", w.Code, w.Body.String()) + } + if _, err := f.st.Decide(a.ID, users.ProposalApprove, admin.me.ID, "", 0); err != nil { + t.Fatal(err) + } + if w := postProposal(t, f, admin, "#a"); w.Code != 409 || !strings.Contains(w.Body.String(), "already approved") { + t.Errorf("approved duplicate: %d %s", w.Code, w.Body.String()) + } + b := decode[proposalJSON](t, postProposal(t, f, user, "#b")) + if _, err := f.st.Decide(b.ID, users.ProposalReject, admin.me.ID, "", 0); err != nil { + t.Fatal(err) + } + if w := postProposal(t, f, user, "#b"); w.Code != 409 || !strings.Contains(w.Body.String(), "was rejected") { + t.Errorf("rejected: %d %s", w.Code, w.Body.String()) + } + if entries, _ := f.st.AuditList(users.AuditFilter{Actions: []string{"proposal.create"}}); len(entries) != 2 { + t.Fatalf("proposal.create rows = %d; want 2 (refusals are not audited)", len(entries)) + } +} + +func TestProposeLimitsAnswer429(t *testing.T) { + ps := defaultProposalSettings() + ps.perUserPerDay, ps.maxPending = 1, 2 + f := newProposalFixture(t, ps) + admin, user := proposalPeople(t, f) + expectStatus(t, postProposal(t, f, user, "#a"), 201) + if w := postProposal(t, f, user, "#b"); w.Code != 429 || !strings.Contains(w.Body.String(), "daily proposal limit") { + t.Fatalf("per-user limit: %d %s", w.Code, w.Body.String()) + } + expectStatus(t, postProposal(t, f, admin, "#b"), 201) + sam := f.registerAndActivate(t, "sam@example.org", "Sam", pw) + if w := postProposal(t, f, sam, "#c"); w.Code != 429 || !strings.Contains(w.Body.String(), "wait for review") { + t.Fatalf("pending cap: %d %s", w.Code, w.Body.String()) + } +} + +func TestAdminProposalList(t *testing.T) { + f := newProposalFixture(t, defaultProposalSettings()) + admin, user := proposalPeople(t, f) + expectStatus(t, postProposal(t, f, user, "#a"), 201) + w := f.do("GET", "/api/admin/proposals?status=pending&kind=hashtag_channel", nil, as(admin)) + expectStatus(t, w, 200) + list := decode[[]adminProposalJSON](t, w) + if len(list) != 1 || list[0].Subject != "#a" || list[0].Proposer == nil || list[0].Proposer.DisplayName != "Pat" || list[0].Reviewer != nil { + t.Fatalf("admin list = %+v", list) + } + if got := decode[[]adminProposalJSON](t, f.do("GET", "/api/admin/proposals?status=approved", nil, as(admin))); len(got) != 0 { + t.Fatalf("approved list = %+v", got) + } + for _, q := range []string{"?status=bogus", "?kind=other"} { + if w := f.do("GET", "/api/admin/proposals"+q, nil, as(admin)); w.Code != 400 { + t.Errorf("%s = %d; want 400", q, w.Code) + } + } + if w := f.do("GET", "/api/admin/proposals", nil, as(user)); w.Code != 403 { + t.Errorf("non-admin = %d; want 403", w.Code) + } +} + +func TestAdminDecideFlow(t *testing.T) { + f := newProposalFixture(t, defaultProposalSettings()) + admin, user := proposalPeople(t, f) + p := decode[proposalJSON](t, postProposal(t, f, user, "#a")) + path := fmt.Sprintf("/api/admin/proposals/%d/", p.ID) + if w := f.do("POST", path+"approve", decideRequest{Note: "ok"}, as(user)); w.Code != 403 { + t.Fatalf("non-admin approve = %d", w.Code) + } + w := f.do("POST", path+"approve", decideRequest{Note: " welcome "}, as(admin)) + expectStatus(t, w, 200) + got := decode[adminProposalJSON](t, w) + if got.Status != users.ProposalApproved || got.Note != "welcome" || got.Reviewer == nil || got.Reviewer.ID != admin.me.ID || + got.Proposer == nil || got.Proposer.ID != user.me.ID || got.DecidedAt == nil { + t.Fatalf("approved = %+v", got) + } + if w := f.do("POST", path+"reject", decideRequest{}, as(admin)); w.Code != 409 { + t.Fatalf("reject an approved proposal = %d; want 409", w.Code) + } + expectStatus(t, f.do("POST", path+"revoke", decideRequest{}, as(admin)), 200) + entries, _ := f.st.AuditList(users.AuditFilter{Actions: []string{"proposal.approve", "proposal.revoke"}}) + if len(entries) != 2 { + t.Fatalf("decision audit rows = %d; want 2", len(entries)) + } + for _, e := range entries { + if *e.ActorUserID != admin.me.ID || e.TargetUserID == nil || *e.TargetUserID != user.me.ID || e.Detail["subject"] != "#a" || e.Detail["kind"] != users.KindHashtagChannel { + t.Fatalf("audit entry = %+v", e) + } + } + if mine := decode[[]proposalJSON](t, f.do("GET", "/api/account/proposals", nil, as(user))); mine[0].Status != users.ProposalRevoked { + t.Fatalf("proposer sees %+v", mine) + } + for _, c := range []struct { + path string + code int + }{{"/api/admin/proposals/999/approve", 404}, {"/api/admin/proposals/x/approve", 400}, {"/api/admin/proposals/0/approve", 400}} { + if w := f.do("POST", c.path, decideRequest{}, as(admin)); w.Code != c.code { + t.Errorf("%s = %d; want %d", c.path, w.Code, c.code) + } + } +} + +func TestAdminApproveRespectsMaxApproved(t *testing.T) { + ps := defaultProposalSettings() + ps.maxApproved = 1 + f := newProposalFixture(t, ps) + admin, user := proposalPeople(t, f) + a := decode[proposalJSON](t, postProposal(t, f, user, "#a")) + b := decode[proposalJSON](t, postProposal(t, f, user, "#b")) + expectStatus(t, f.do("POST", fmt.Sprintf("/api/admin/proposals/%d/approve", a.ID), decideRequest{}, as(admin)), 200) + w := f.do("POST", fmt.Sprintf("/api/admin/proposals/%d/approve", b.ID), decideRequest{}, as(admin)) + if w.Code != 409 || !strings.Contains(w.Body.String(), "limit of approved channels") { + t.Fatalf("approve over the cap: %d %s", w.Code, w.Body.String()) + } +} + +func TestAdminDecideNoteValidation(t *testing.T) { + f := newProposalFixture(t, defaultProposalSettings()) + admin, user := proposalPeople(t, f) + p := decode[proposalJSON](t, postProposal(t, f, user, "#a")) + path := fmt.Sprintf("/api/admin/proposals/%d/reject", p.ID) + for _, c := range []struct{ note, msg string }{ + {strings.Repeat("a", 501), "at most 500 characters"}, + {"a\u202eb", "invisible or control characters"}, + } { + if w := f.do("POST", path, decideRequest{Note: c.note}, as(admin)); w.Code != 400 || !strings.Contains(w.Body.String(), c.msg) { + t.Errorf("note %q: %d %s", c.note, w.Code, w.Body.String()) + } + } + expectStatus(t, f.do("POST", path, decideRequest{Note: "line one\nline two"}, as(admin)), 200) +} + +func TestValidateProposalNote(t *testing.T) { + for in, want := range map[string]string{"": "", " fine ": "fine", "a\nb": "a\nb", "👩\u200d💻": "👩\u200d💻", strings.Repeat("é", 500): strings.Repeat("é", 500)} { + if got, err := validateProposalNote(in); err != nil || got != want { + t.Errorf("validateProposalNote(%q) = %q, %v; want %q", in, got, err, want) + } + } + for _, in := range []string{strings.Repeat("é", 501), "a\tb", "a\u200bb", "a\u2029b"} { + if _, err := validateProposalNote(in); err == nil { + t.Errorf("validateProposalNote(%q) accepted", in) + } + } +} + +func TestJanitorPrunesOldDecisions(t *testing.T) { + f := newProposalFixture(t, defaultProposalSettings()) + admin, user := proposalPeople(t, f) + p := decode[proposalJSON](t, postProposal(t, f, user, "#a")) + expectStatus(t, f.do("POST", fmt.Sprintf("/api/admin/proposals/%d/reject", p.ID), decideRequest{}, as(admin)), 200) + f.st.SetClock(func() time.Time { return time.Now().Add(91 * 24 * time.Hour) }) + f.srv.auth.prune() + if list, _ := f.st.ListProposals(users.ProposalFilter{}); len(list) != 0 { + t.Fatalf("after prune = %+v; want the 91-day-old rejection gone", list) + } +} + +const testChannelKeyHex = "0123456789abcdef0123456789abcdef" + +func configuredChannelsConfig(t *testing.T) *Config { + t.Helper() + var cfg Config + raw := `{"apiKey":"` + testAPIKey + `","hashChannels":[" mesh","#Club",""],"channelKeys":{"#secret":"` + testChannelKeyHex + `","public":"` + testChannelKeyHex + `"}}` + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatal(err) + } + return &cfg +} + +// The server reads channelKeys for its names only; the key values are +// dropped while unmarshalling, so the server never holds key material. +func TestChannelKeyNamesKeepsNoKeyMaterial(t *testing.T) { + cfg := configuredChannelsConfig(t) + got := append([]string(nil), cfg.ChannelKeys...) + if len(got) != 2 || !((got[0] == "#secret" && got[1] == "public") || (got[1] == "#secret" && got[0] == "public")) { + t.Fatalf("channelKeys names = %v", got) + } + out, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), testChannelKeyHex) { + t.Fatalf("server config retains a channel key: %s", out) + } + var none Config + if err := json.Unmarshal([]byte(`{"channelKeys":null}`), &none); err != nil || len(none.ChannelKeys) != 0 { + t.Fatalf("null channelKeys = %v, %v", none.ChannelKeys, err) + } + // A non-object channelKeys is ignored, never a parse error: LoadConfig + // would otherwise drop the whole config and start on defaults. + for _, bad := range []string{`["#x"]`, `"#x"`, `42`} { + var c Config + raw := `{"port":8123,"apiKey":"` + testAPIKey + `","channelKeys":` + bad + `,"hashChannels":["mesh"]}` + if err := json.Unmarshal([]byte(raw), &c); err != nil { + t.Fatalf("channelKeys %s: %v", bad, err) + } + if c.Port != 8123 || c.APIKey != testAPIKey || len(c.HashChannels) != 1 || len(c.ChannelKeys) != 0 { + t.Fatalf("channelKeys %s: config = port %d, apiKey %q, hashChannels %v, channelKeys %v", bad, c.Port, c.APIKey, c.HashChannels, c.ChannelKeys) + } + } +} + +func TestConfiguredChannelNames(t *testing.T) { + got := configuredChannelsConfig(t).configuredChannelNames() + want := map[string]bool{"#mesh": true, "#Club": true, "#secret": true, "Public": true} + if len(got) != len(want) { + t.Fatalf("configured names = %v; want %v", got, want) + } + for n := range want { + if !got[n] { + t.Fatalf("configured names = %v; missing %q", got, n) + } + } + var nilCfg *Config + if len(nilCfg.configuredChannelNames()) != 0 { + t.Fatal("nil config has configured names") + } +} + +func TestProposeRefusesConfiguredChannels(t *testing.T) { + f := newProposalFixtureWithConfig(t, defaultProposalSettings(), configuredChannelsConfig(t)) + _, user := proposalPeople(t, f) + for _, subject := range []string{"mesh", "#mesh", " #Club ", "#secret"} { + if w := postProposal(t, f, user, subject); w.Code != 409 || !strings.Contains(w.Body.String(), "already decrypted on this instance") { + t.Errorf("%q: %d %s; want 409 already decrypted", subject, w.Code, w.Body.String()) + } + } + expectStatus(t, postProposal(t, f, user, "#club"), 201) // case-sensitive, like the ingestor's key map + if entries, _ := f.st.AuditList(users.AuditFilter{Actions: []string{"proposal.create"}}); len(entries) != 1 { + t.Fatalf("proposal.create rows = %d; want 1 (refusals are not audited)", len(entries)) + } +} + +// Every decision reloads the approved-name snapshot from users.db under a +// mutex, so concurrent decisions cannot leave an older read in place. Run +// with -race. +func TestConcurrentDecisionsKeepApprovedSnapshotFresh(t *testing.T) { + ps := defaultProposalSettings() + ps.perUserPerDay = 100 + f := newProposalFixture(t, ps) + admin, user := proposalPeople(t, f) + f.srv.auth.refreshApproved() + if got := f.srv.auth.approvedChannels(); got == nil || len(got) != 0 { + t.Fatalf("initial snapshot = %#v; want empty", got) + } + const n = 8 + ids := make([]int64, n) + for i := range ids { + ids[i] = decode[proposalJSON](t, postProposal(t, f, user, fmt.Sprintf("#c%d", i))).ID + } + decideAll := func(action string) { + var wg sync.WaitGroup + codes := make([]int, n) + for i, id := range ids { + wg.Add(1) + go func(i int, id int64) { + defer wg.Done() + codes[i] = f.serve("POST", fmt.Sprintf("/api/admin/proposals/%d/%s", id, action), decideRequest{}, as(admin)).Code + }(i, id) + } + wg.Wait() + f.srv.auth.waitAudits() + for i, c := range codes { + if c != 200 { + t.Fatalf("%s #%d = %d", action, ids[i], c) + } + } + } + decideAll("approve") + want, err := f.st.ApprovedSubjects(users.KindHashtagChannel, 0) + if err != nil { + t.Fatal(err) + } + if got := f.srv.auth.approvedChannels(); len(want) != n || strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("snapshot after concurrent approvals = %v; users.db has %v", got, want) + } + decideAll("revoke") + if got := f.srv.auth.approvedChannels(); got == nil || len(got) != 0 { + t.Fatalf("snapshot after concurrent revocations = %#v; want empty", got) + } +} + +// The concurrency test above cannot force the bad interleaving (users.db +// serialises the reads), so this pins the serialisation itself: a refresh +// waits for one already in progress. +func TestRefreshApprovedIsSerialised(t *testing.T) { + f := newProposalFixture(t, defaultProposalSettings()) + a := f.srv.auth + a.approvedMu.Lock() + done := make(chan struct{}) + go func() { + a.refreshApproved() + close(done) + }() + select { + case <-done: + t.Fatal("refreshApproved ran while another refresh held the lock") + case <-time.After(50 * time.Millisecond): + } + a.approvedMu.Unlock() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("refreshApproved did not finish after the lock was released") + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 7a52a5c8..97901292 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -534,7 +534,7 @@ func (s *Server) clientUserManagement() *ClientUserManagement { if s.auth == nil { return nil } - return &ClientUserManagement{Enabled: true} + return &ClientUserManagement{Enabled: true, ChannelProposals: s.auth.set.proposals.enabled} } func (s *Server) handleConfigAreas(w http.ResponseWriter, r *http.Request) { @@ -2760,6 +2760,16 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) { writeJSON(w, ResolveHopsResponse{Resolved: resolved}) } +// approvedChannelsField is the approvedChannels value of /api/channels: nil +// (absent) unless channel proposals are on. +func (s *Server) approvedChannelsField() *[]string { + if s.auth == nil || !s.auth.set.proposals.enabled { + return nil + } + list := s.auth.approvedChannels() + return &list +} + func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { region := r.URL.Query().Get("region") includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true" @@ -2778,7 +2788,7 @@ func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { channels = append(channels, encrypted...) } } - writeJSON(w, ChannelListResponse{Channels: channels}) + writeJSON(w, ChannelListResponse{Channels: channels, ApprovedChannels: s.approvedChannelsField()}) return } if s.store != nil { @@ -2786,10 +2796,10 @@ func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { if includeEncrypted { channels = append(channels, s.store.GetEncryptedChannels(region)...) } - writeJSON(w, ChannelListResponse{Channels: channels}) + writeJSON(w, ChannelListResponse{Channels: channels, ApprovedChannels: s.approvedChannelsField()}) return } - writeJSON(w, ChannelListResponse{Channels: []map[string]interface{}{}}) + writeJSON(w, ChannelListResponse{Channels: []map[string]interface{}{}, ApprovedChannels: s.approvedChannelsField()}) } func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/server/types.go b/cmd/server/types.go index 318e3d9b..2ab41f8e 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -930,6 +930,9 @@ type ChannelResp struct { type ChannelListResponse struct { Channels []map[string]interface{} `json:"channels"` + // ApprovedChannels is set only when channel proposals are on (then + // always, [] included); nil keeps the response unchanged when off. + ApprovedChannels *[]string `json:"approvedChannels,omitempty"` } type ChannelMessageResp struct { @@ -1089,9 +1092,11 @@ type ClientConfigResponse struct { UserManagement *ClientUserManagement `json:"userManagement,omitempty"` } -// ClientUserManagement tells the frontend that accounts exist. +// ClientUserManagement tells the frontend that accounts exist, and whether +// channel proposals are on. type ClientUserManagement struct { - Enabled bool `json:"enabled"` + Enabled bool `json:"enabled"` + ChannelProposals bool `json:"channelProposals,omitempty"` } // CustomizerClientConfig is the operator-side customizer-modal knobs that diff --git a/cmd/server/user_mgmt_config.go b/cmd/server/user_mgmt_config.go index 13b93f66..6349e68a 100644 --- a/cmd/server/user_mgmt_config.go +++ b/cmd/server/user_mgmt_config.go @@ -14,13 +14,14 @@ import ( // UserManagementConfig is the "userManagement" block of config.json. type UserManagementConfig struct { - Enabled bool `json:"enabled"` - DBPath string `json:"dbPath,omitempty"` - AdminEmails []string `json:"adminEmails,omitempty"` - PublicBaseURL string `json:"publicBaseUrl,omitempty"` - SessionDays int `json:"sessionDays,omitempty"` - TrustedProxies []string `json:"trustedProxies,omitempty"` - Mail UserMailConfig `json:"mail"` + Enabled bool `json:"enabled"` + DBPath string `json:"dbPath,omitempty"` + AdminEmails []string `json:"adminEmails,omitempty"` + PublicBaseURL string `json:"publicBaseUrl,omitempty"` + SessionDays int `json:"sessionDays,omitempty"` + TrustedProxies []string `json:"trustedProxies,omitempty"` + Mail UserMailConfig `json:"mail"` + ChannelProposals *ChannelProposalsConfig `json:"channelProposals,omitempty"` } // UserMailConfig is userManagement.mail. @@ -32,6 +33,49 @@ type UserMailConfig struct { WebhookSecret string `json:"webhookSecret,omitempty"` } +// ChannelProposalsConfig is userManagement.channelProposals +// (docs/specs/2026-10-07-channel-proposals-design.md). Off by default. +type ChannelProposalsConfig struct { + Enabled bool `json:"enabled"` + MaxPending int `json:"maxPending,omitempty"` + MaxApproved int `json:"maxApproved,omitempty"` + PerUserPerDay int `json:"perUserPerDay,omitempty"` +} + +// proposalSettings is the resolved form; the zero value means off. +type proposalSettings struct { + enabled bool + maxPending int // pending proposals of every kind + maxApproved int // approved hashtag channels; bounds the ingestor's key set + perUserPerDay int +} + +const ( + defaultMaxPendingProposals = 100 + defaultMaxApprovedChannels = 128 + defaultProposalsPerUserPerDay = 5 +) + +func positiveOr(v, def int) int { + if v <= 0 { + return def + } + return v +} + +// resolveProposals fills the defaults for absent, zero or negative limits. +func resolveProposals(c *ChannelProposalsConfig) proposalSettings { + if c == nil || !c.Enabled { + return proposalSettings{} + } + return proposalSettings{ + enabled: true, + maxPending: positiveOr(c.MaxPending, defaultMaxPendingProposals), + maxApproved: positiveOr(c.MaxApproved, defaultMaxApprovedChannels), + perUserPerDay: positiveOr(c.PerUserPerDay, defaultProposalsPerUserPerDay), + } +} + // UserManagementEnabled reports whether optional accounts are on. Nil config // or absent section means off (the default). func (c *Config) UserManagementEnabled() bool { @@ -51,6 +95,7 @@ type userMgmtSettings struct { fromEmail string fromName string webhookSecret string + proposals proposalSettings } const defaultSessionDays = 30 @@ -126,6 +171,7 @@ func resolveUserManagement(u *UserManagementConfig, measurementDBPath string, ge if set.webhookSecret != "" && len(set.webhookSecret) < 16 { return nil, errors.New("userManagement.mail.webhookSecret must be at least 16 characters") } + set.proposals = resolveProposals(u.ChannelProposals) return set, nil } diff --git a/cmd/server/user_mgmt_config_test.go b/cmd/server/user_mgmt_config_test.go index 150a742f..49a8ba24 100644 --- a/cmd/server/user_mgmt_config_test.go +++ b/cmd/server/user_mgmt_config_test.go @@ -81,3 +81,34 @@ func TestUserManagementEnabled(t *testing.T) { t.Fatal("enabled not reported") } } + +func TestResolveChannelProposals(t *testing.T) { + u := validUM() + set, err := resolveUserManagement(u, "meshcore.db", noEnv) + if err != nil { + t.Fatal(err) + } + if set.proposals != (proposalSettings{}) { + t.Fatalf("absent block = %+v; want off", set.proposals) + } + u.ChannelProposals = &ChannelProposalsConfig{Enabled: true} + set, _ = resolveUserManagement(u, "meshcore.db", noEnv) + if set.proposals != (proposalSettings{enabled: true, maxPending: 100, maxApproved: 128, perUserPerDay: 5}) { + t.Fatalf("defaults = %+v", set.proposals) + } + u.ChannelProposals = &ChannelProposalsConfig{Enabled: true, MaxPending: 3, MaxApproved: 7, PerUserPerDay: 1} + set, _ = resolveUserManagement(u, "meshcore.db", noEnv) + if set.proposals != (proposalSettings{enabled: true, maxPending: 3, maxApproved: 7, perUserPerDay: 1}) { + t.Fatalf("explicit = %+v", set.proposals) + } + u.ChannelProposals = &ChannelProposalsConfig{Enabled: true, MaxPending: -1, MaxApproved: -1, PerUserPerDay: -1} + set, _ = resolveUserManagement(u, "meshcore.db", noEnv) + if set.proposals != (proposalSettings{enabled: true, maxPending: 100, maxApproved: 128, perUserPerDay: 5}) { + t.Fatalf("negative values = %+v; want defaults", set.proposals) + } + u.ChannelProposals = &ChannelProposalsConfig{Enabled: false, MaxApproved: 7} + set, _ = resolveUserManagement(u, "meshcore.db", noEnv) + if set.proposals.enabled { + t.Fatal("enabled false resolved as on") + } +} diff --git a/config.example.json b/config.example.json index f8823610..72b99784 100644 --- a/config.example.json +++ b/config.example.json @@ -442,9 +442,16 @@ "fromEmail": "noreply@example.org", "fromName": "CoreScope", "webhookSecret": "" + }, + "channelProposals": { + "enabled": false, + "maxPending": 100, + "maxApproved": 128, + "perUserPerDay": 5 } }, "_comment_userManagement": "Optional accounts (off by default; see docs/user-guide/accounts.md). When enabled: visitors can register with email + password and activate through a mailed link; addresses in adminEmails become admins; admins manage users at #/admin/users and can use the operator endpoints without the apiKey. The dashboard stays public. dbPath defaults to users.db next to the analyzer database: a separate file, the server never writes measurement data. publicBaseUrl is required and is used for every link in every mail. Mail goes through Brevo's transactional API (a free account is enough); brevoApiKey and webhookSecret may instead come from CORESCOPE_BREVO_API_KEY and CORESCOPE_BREVO_WEBHOOK_SECRET. Set webhookSecret (16+ chars) and point a Brevo transactional webhook at /api/mail/brevo/webhook with bearer auth to see delivery status per user. trustedProxies (CIDRs) lets login rate limits use X-Forwarded-For behind a reverse proxy. Startup fails if enabled with an incomplete mail setup.", + "_comment_channelProposals": "Optional, needs userManagement.enabled (docs/user-guide/accounts.md, Channel proposals). Logged-in users propose hashtag channels; admins approve, reject or revoke them on the Proposals tab. An approved channel is decrypted by the ingestor from its next refresh (once a minute, no restart) and listed for every visitor on the Channels page. maxApproved bounds the decryption work (every approved key is tried on every group message); perUserPerDay and maxPending limit proposals. The ingestor reads users.db read-only from userManagement.dbPath, or next to the analyzer database; set dbPath explicitly when the server and the ingestor are not given the same analyzer database path.", "customizer": { "disabledTabs": [], "_comment_disabledTabs": "Issue #1508. List of customizer-modal tab ids to hide from end users. Useful when operators want to expose only daily-use viewer controls and keep one-shot admin chrome out of the way. Recognized ids: branding, theme, nodes, home, display, geofilter, export. Example: [\"branding\", \"geofilter\", \"export\"] hides the admin tabs and leaves theme/colors/home/display visible. Default [] keeps the legacy behavior (all tabs visible). Operators edit this in config.json directly — there is no in-app UI for this list (by design)." diff --git a/docs/specs/2026-10-07-channel-proposals-design.md b/docs/specs/2026-10-07-channel-proposals-design.md new file mode 100644 index 00000000..bedba918 --- /dev/null +++ b/docs/specs/2026-10-07-channel-proposals-design.md @@ -0,0 +1,255 @@ +# Optional User Management, Sub-project D: Proposals and Approved Hashtag Channels, Design Spec + +**Status:** written 2026-10-07 while the operator was away. The decisions in the table +below were taken by Claude with its recommended option, as the operator asked; each one +is open for review and can be reversed. +**Builds on:** A (`docs/specs/2026-10-06-user-management-design.md`), B +(`docs/specs/2026-10-06-user-settings-sync-design.md`) and C +(`docs/specs/2026-10-07-admin-dashboard-design.md`). Roadmap #2128, part D. +**Prior art:** #2092 describes a suggestion flow that runs in a downstream fork +(dborup/CoreScope#99). This spec reuses its state machine, name rules and the rule that +configured channels win, and replaces its anonymous suggestions and file queue with +accounts and `users.db`. + +## Problem + +Hashtag channels are public by construction: the key is `sha256("#name")[:16]`, so anyone +who knows the name can read the channel. An instance decrypts only the hashtag channels +the operator lists in `hashChannels`. Users who want a channel shown (a city, a club) have +to ask the operator out of band, and the operator edits `config.json` and restarts the +ingestor. #2092 asked for a way to suggest a channel in the UI and have an admin approve +it. + +## Goals + +1. A logged-in user can propose a hashtag channel; an admin approves, rejects, or later + revokes it. +2. An approved channel is decrypted by the ingestor without a restart and is listed for + everyone on the Channels page, also before it has traffic. +3. The proposal mechanism is generic enough that a second kind can be added without a new + table, but this spec ships exactly one kind. +4. Off by default. With the feature off, nothing changes. + +## Non-goals + +- Private (PSK) channels. Their keys are secrets and stay in the browser (#725). +- Anonymous proposals. Proposals need an account (attribution, per-user limits). +- Moderating message content. +- Any other proposal kind. + +## Decisions (taken by Claude, open for review) + +| # | Topic | Decision | Alternative not taken | +|---|---|---|---| +| 1 | Who proposes | Logged-in active users only. | Anonymous visitors with a global rate limit (#2092's model). | +| 2 | Generic vs specific | One generic `proposals` table with a `kind` column; one kind, `hashtag_channel`. | A `channel_proposals` table. | +| 3 | Storage | `users.db` (schema v4), written by the server through `internal/users`. | Analyzer DB through a file queue to the ingestor (#2092). | +| 4 | How the ingestor learns approved channels | The ingestor opens `users.db` read-only and re-reads the approved names every 60 seconds. | Server writes a JSON file the ingestor watches. | +| 5 | Feature flag | `userManagement.channelProposals.enabled`, default false, plus limits. | Always on with user management. | +| 6 | Re-proposal | Revoked can be proposed again (back to pending); rejected stays blocked until pruned; pending/approved duplicates are refused. | Free re-proposal. | +| 7 | Retention | Rejected and revoked proposals are deleted after 90 days; approved and pending are kept. | 30 days (#2092). | + +## Name rules (one Go function, mirrored in the browser) + +`internal/channel.ValidateHashtagName(s string) (string, error)`: +- Trim surrounding whitespace; prefix `#` if missing. +- At most 31 UTF-8 bytes including `#` (firmware `ChannelDetails.name[32]`, one byte for + the terminating NUL; `src/helpers/ChannelDetails.h:8` in MeshCore). +- At least one character after `#`. +- No control characters, no bidi controls, no line or paragraph separators, no Unicode + format characters (Cf) except ZWJ (U+200D), so emoji sequences keep working. +- Case is preserved: the key is derived from the exact string. +- `#public` and `Public` are refused (the built-in Public channel). + +The browser mirrors the rules for instant feedback; the server is the authority, and the +ingestor re-validates every name it loads. + +## Storage (`internal/users`, schema v4) + +``` +CREATE TABLE proposals ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL, -- 'hashtag_channel' + subject TEXT NOT NULL, -- the validated name, e.g. '#mycity' + status TEXT NOT NULL CHECK (status IN ('pending','approved','rejected','revoked')), + proposer_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + reviewer_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + note TEXT NOT NULL DEFAULT '', -- reviewer's reason, shown to the proposer + created_at INTEGER NOT NULL, + decided_at INTEGER +); +CREATE UNIQUE INDEX proposals_kind_subject ON proposals(kind, subject); +CREATE INDEX proposals_status ON proposals(status); +``` + +One row per (kind, subject). State changes update the row; history is in the audit log. + +State machine (enforced in one transaction per change): + +| From | Action | To | +|---|---|---| +| (none) | propose | pending | +| revoked | propose | pending (proposer replaced by the new one) | +| rejected | propose | refused until the row is pruned | +| pending | approve | approved | +| pending | reject | rejected | +| approved | revoke | revoked | +| pending, approved | propose | refused ("already proposed" / "already approved") | + +Store methods: `Propose(kind, subject, userID)`, `Decide(id, action, reviewerID, note)`, +`ListProposals(filter)`, `ApprovedSubjects(kind)`, `ProposalsByUser(userID)`, +`PruneProposals(maxAge)`. + +## Config + +```json +"userManagement": { + "channelProposals": { + "enabled": false, + "maxPending": 100, + "maxApproved": 128, + "perUserPerDay": 5 + } +} +``` + +`maxApproved` bounds the ingestor's key set: every extra key is tried on every GRP_TXT +packet (`decodeGrpTxt` iterates the whole map). + +## Server (`cmd/server`, routes only when user management and proposals are on) + +- `POST /api/proposals` `{kind, subject}` (withUser): validates the name, applies the + per-user and global limits, returns the proposal. Errors: 400 invalid name, 409 + duplicate or rejected, 429 limit. +- `GET /api/account/proposals` (withUser): the caller's own proposals and their status. +- `GET /api/admin/proposals?status=&kind=` (withAdmin): the review list with proposer + and reviewer display names. +- `POST /api/admin/proposals/{id}/{approve|reject|revoke}` `{note}` (withAdmin). Approve + refuses when `maxApproved` is reached. +- `GET /api/channels` gains `approvedChannels: ["#name", ...]` when the feature is on, so + the page lists them before they have traffic. Off: the response is unchanged. +- Audit actions: `proposal.create`, `proposal.approve`, `proposal.reject`, + `proposal.revoke`, with `{kind, subject}` in detail. +- The janitor prunes rejected and revoked proposals older than 90 days. +- All routes in OpenAPI under the `users` tag. + +## Ingestor (`cmd/ingestor`) + +- Config: parse `userManagement.enabled`, `userManagement.dbPath` and + `userManagement.channelProposals.enabled` from the shared `config.json`; resolve the + `users.db` path with the server's rule (`dbPath`, else `users.db` next to the analyzer + DB). +- When enabled and the file exists: open it with `mode=ro`, and every 60 seconds read + `ApprovedSubjects('hashtag_channel')` with raw SQL (no `internal/users` import), re-run + `ValidateHashtagName`, derive keys, and swap a new key map in through an + `atomic.Pointer[map[string]string]`. Each message decodes against the current snapshot. +- Merge order: approved names are added below built-in, `channel-rainbow.json`, + `hashChannels` and `channelKeys`. A configured name always wins, so revoking a proposal + never stops decryption of a configured channel. +- Revoke removes the key from the next snapshot: future packets are no longer decrypted; + messages already stored stay as they are. +- A missing or unreadable `users.db` logs once and keeps the configured keys. +- Read/write invariant: the ingestor gains a read-only dependency on `users.db`; it never + writes it. AGENTS.md is updated to say so. + +## Frontend + +- Channels page, add-channel dialog: a "Propose for everyone" button next to the existing + hashtag "Monitor" action, shown only to logged-in users when the feature is on. It + validates the name live and shows the result ("Proposed, an admin will review it"). +- Channel list: approved channels show even without traffic, with a small "approved" + marker. +- Account page: a "My proposals" section with status and the reviewer's note. +- Admin area: a fourth tab "Proposals" (`#/admin?tab=proposals&status=pending`) with + approve / reject / revoke, an optional note, and a warning on approve that the channel + becomes readable for everyone on this instance. + +## Security and privacy + +- Approving makes a channel's messages readable by every visitor. Nothing is + auto-approved; the approve dialog states this. +- Proposals carry the proposer's account; only admins see who proposed what. +- PSK channels are out of scope; no secret key ever reaches the server (#725). +- Rate limits: per user per day, plus the global pending cap. + +## Performance + +Proposals live in `users.db` and are small. The ingestor reads at most `maxApproved` +names once a minute and swaps a map; decode cost grows with the number of keys, bounded +by `maxApproved` (default 128). + +## Testing + +- `internal/channel`: name rules table (31-byte limit with multibyte characters, ZWJ + emoji accepted, bidi and Cf refused, Public refused, case preserved). +- `internal/users`: migration v3 → v4; every state transition, refused transitions, + duplicates, prune. +- `cmd/server`: each route (auth, CSRF, limits, 400/409/429), `approvedChannels` on and + off, audit rows, feature off: routes absent and `/api/channels` unchanged. +- `cmd/ingestor`: loads approved names from a read-only `users.db`, configured names win, + revoke removes the key from the next snapshot, missing file keeps configured keys, + concurrent decode while swapping (race detector). +- Frontend unit (vm): propose flow states, admin tab actions, escaping of names. +- Playwright (e2etest build): a user proposes `#e2e-test`, the admin approves it, the + channel appears in the list; revoke removes it from the approved list. + +## Amendments from the implementation plan + +1. Store signatures carry the limits: `Propose(kind, subject, userID, ProposalLimits)`, + `Decide(id, action, reviewerID, note, maxApproved)` and `ApprovedSubjects(kind, limit)`. + The limit checks run inside the state-change transaction. +2. The per-user daily limit counts the proposals a user created or re-opened in the last + 24 hours in `users.db`, so it survives a restart. +3. Name rules: a name of only ZWJ is refused like an empty one; Public is refused in any + case (`#public`, `#Public`, `public`, `Public`). +4. With `maxApproved` lowered below the number of approved channels, the server and the + ingestor both use the oldest approvals (by decision time) up to the cap. +5. An ingestor read error never removes keys: only a successful read changes the set. +6. The channel list escapes channel names in its data attributes and CSS selectors, + since approved names may contain `"`, `<` and `\`. +7. `users.db` path: the server and the ingestor keep their existing `DB_PATH` + precedence. Both log the resolved (absolute) path at startup (server + `[users] user management enabled: db=`, ingestor + `[proposals] reading approved channels from `), and the operator docs advise + setting `userManagement.dbPath` explicitly. If the two disagree on a non-Docker setup, + the ingestor reads another `users.db`; the log lines make that visible. +8. A proposal for a name the instance already decrypts from `config.json` is refused + with 409 "this channel is already decrypted on this instance". The server reads only + the names: `hashChannels` normalised as the ingestor does (trimmed, `#` prefixed) and + the names of `channelKeys` (key values are discarded while parsing). Since a proposal + subject always starts with `#`, only `#`-prefixed `channelKeys` names can match. The + comparison is case-sensitive, like the ingestor's key map. +9. A non-object `channelKeys` is ignored for this check with the log line + `[config] channelKeys is not an object; ignoring it for channel-proposal checks`, + instead of failing config loading. +10. Proposing a revoked name again replaces the proposer; the original proposer stays in + the audit log and no longer sees the proposal under My proposals. +11. Approved channels without traffic are listed in `/api/channels` and on the Channels + page regardless of the region filter, so a regional view may show a channel with no + regional traffic. +12. The server reloads its approved-name snapshot from `users.db` under a mutex after + every decision (approve, reject and revoke), instead of changing it in place, so two + concurrent decisions cannot leave it stale. The reject case costs one small read and + keeps a single code path. +13. The hidden and control character check exists twice by design: in `internal/users` + (display names) and in `internal/channel` (channel names). They are separate Go + modules and the ingestor must not import `internal/users`. Tests pin both copies; + the browser mirror in `public/channel-proposals.js` is inherent to live validation. + Since amendment 14 the channel copy is stricter than the display-name copy. +14. Name rules, tightened before release: besides control, bidi, separator and Cf + characters, a channel name may not contain the invisible fillers of Unicode + `Other_Default_Ignorable_Code_Point` (for example U+3164, U+115F, U+FFA0, U+034F), + U+2800 BRAILLE PATTERN BLANK, or any space separator (Zs) other than the ASCII space, + so interior NBSP and U+3000 are refused. ZWJ (U+200D) and variation selectors such + as U+FE0F stay allowed, so emoji like `#❤️` keep working. The message is the existing + "the name contains invisible or control characters". Reason: `#mesh` followed by + U+3164 passed for a configured `#mesh`, and the ingestor drops any stored name the + current rule refuses, so tightening after release would silently stop decrypting + already approved channels. The browser mirror lists Go's + `Other_Default_Ignorable_Code_Point` table explicitly (JS has no property escape for + it); Go and JS give the same verdict for every code point. +15. ZWNJ and tag characters stay refused (operator decision, 2026-10-07). Both are Cf, so + names that need the zero-width non-joiner (U+200C, some Persian and Urdu spellings) + and subdivision flags built from tag characters (England, Scotland, Wales) cannot be + proposed. The user guide says so. Allowing them later only widens the rule, so no + approved name is stranded by that change. diff --git a/docs/user-guide/accounts.md b/docs/user-guide/accounts.md index d392eed0..c4c8de8e 100644 --- a/docs/user-guide/accounts.md +++ b/docs/user-guide/accounts.md @@ -79,7 +79,7 @@ tracking pixels automatically, and others block them. ### The admin area -*Admin* in the account menu (on phones: the account page) opens three tabs: +*Admin* in the account menu (on phones: the account page) opens three tabs (four with channel proposals on): - **Overview**: a *Needs attention* list when something applies (pending accounts older than 24 hours, addresses whose mail bounces, accounts with 5 or more failed logins in @@ -92,9 +92,53 @@ tracking pixels automatically, and others block them. Successful and failed logins are recorded without IP address and deleted after 90 days; a failed login for an address that has no account is not recorded. Other actions are kept. +- **Proposals** (only with channel proposals on): proposed hashtag channels by status, + with *Approve*, *Reject* and *Revoke* and an optional note that the proposer sees. + Approving asks first, because the channel becomes readable for every visitor. Old `#/admin/users` links still work and open the Users tab. +### Channel proposals (optional) + +Hashtag channels are public by construction: the key is derived from the name, so +anyone who knows the name can read the channel. CoreScope decrypts only the hashtag +channels in `hashChannels`. With channel proposals on, logged-in users propose a +hashtag channel from *Channels, Add channel, Propose for everyone*, and admins decide +on the *Proposals* tab. + +```json +"userManagement": { + "channelProposals": { "enabled": true, "maxPending": 100, "maxApproved": 128, "perUserPerDay": 5 } +} +``` + +- An approved channel is decrypted by the ingestor from its next refresh (once a + minute, no restart) and listed for every visitor on the Channels page, also before + it has traffic and regardless of the region filter. Messages received before the + approval stay encrypted. +- Revoking stops decryption of new messages from the next refresh; stored messages + stay. A channel that is also in `hashChannels` or `channelKeys` stays decrypted. +- A name in `hashChannels`, or a `channelKeys` name written with its leading `#`, cannot + be proposed: the answer is "this channel is already decrypted on this instance". The + comparison is case-sensitive. +- A rejected name cannot be proposed again until 90 days after the decision; a revoked + one can be proposed again at once, and the new proposer then replaces the old one (the + audit log keeps both). Rejected and revoked proposals are deleted 90 days after the + decision. +- `maxApproved` bounds the decryption work: every approved key is tried on every group + message. `perUserPerDay` and `maxPending` limit proposals (HTTP 429). +- The ingestor reads `users.db` read-only, from `userManagement.dbPath` or next to the + analyzer database. Set `dbPath` explicitly when the server and the ingestor are not + given the same analyzer database path (for example `DB_PATH` set for one of them). + Both log the absolute path they use at startup (server: `[users] user management + enabled: db=...`, ingestor: `[proposals] reading approved channels from ...`). +- Names: at most 31 bytes including the `#` (MeshCore stores 32 with the terminator), + no invisible or control characters (blank fillers such as U+3164 and spaces other than + the plain space count as invisible), case-sensitive, not Public. Emoji work, except + subdivision flags (England, Scotland, Wales), which use invisible tag characters. + Names that need the zero-width non-joiner (U+200C), as some Persian and Urdu + spellings do, are refused as well. + ### Backups `users.db` holds password hashes and addresses. Back it up together with the analyzer @@ -118,6 +162,10 @@ database, and protect it the same way. Deleting it removes all accounts and noth return to the tab. A node or favorite added on one device is never dropped by another, and one you removed stays removed. Saved packet filters are stored in your account as you typed them. +- **Propose a channel** (when the operator turned channel proposals on): *Channels, Add + channel*, type the hashtag name, *Propose for everyone*. *My account, My proposals* + shows the status and the admin's note. Approved channels are readable for every + visitor of the instance. - **Never synced:** channel keys and decrypted messages, the API key, panel and column sizes, collapsed panels and map positions. They stay in the browser where you set them. - **Logging out** asks whether to keep your settings on this device (the default) or diff --git a/internal/channel/hashtag.go b/internal/channel/hashtag.go new file mode 100644 index 00000000..6731f42a --- /dev/null +++ b/internal/channel/hashtag.go @@ -0,0 +1,72 @@ +package channel + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +// MaxHashtagNameBytes is the longest hashtag channel name in bytes, "#" +// included: firmware keeps the name in ChannelDetails.name[32] (MeshCore +// src/helpers/ChannelDetails.h:8), one byte of which is the terminating NUL. +const MaxHashtagNameBytes = 31 + +// NameError is a problem with a hashtag channel name. Msg is safe to show. +type NameError struct{ Msg string } + +func (e *NameError) Error() string { return e.Msg } + +const zwj = '\u200d' + +const ( + nameMsgInvalid = "the name is not valid text" + nameMsgEmpty = "enter a channel name after #" + nameMsgLong = "a channel name is at most 31 bytes including the # (MeshCore stores 32 with the terminator)" + nameMsgPublic = "Public is the built-in channel and cannot be proposed" + nameMsgInvisible = "the name contains invisible or control characters" +) + +// ValidateHashtagName trims raw, prefixes a missing "#" and checks the name +// rules of docs/specs/2026-10-07-channel-proposals-design.md. It returns the +// exact name the channel key is derived from (case preserved). +// public/channel-proposals.js mirrors these rules; this function decides. +func ValidateHashtagName(raw string) (string, error) { + s := strings.TrimSpace(raw) + if !strings.HasPrefix(s, "#") { + s = "#" + s + } + if !utf8.ValidString(s) { + return "", &NameError{Msg: nameMsgInvalid} + } + if strings.TrimSpace(strings.ReplaceAll(s[1:], string(zwj), "")) == "" { + return "", &NameError{Msg: nameMsgEmpty} + } + if len(s) > MaxHashtagNameBytes { + return "", &NameError{Msg: nameMsgLong} + } + if strings.EqualFold(s, "#public") { + return "", &NameError{Msg: nameMsgPublic} + } + for _, r := range s { + if r == zwj { + continue + } + if invisibleNameRune(r) { + return "", &NameError{Msg: nameMsgInvisible} + } + } + return s, nil +} + +// invisibleNameRune is a stricter variant of the display-name rule in +// internal/users/validate.go: on top of control, format (Cf) and line or +// paragraph separators it refuses the invisible fillers that are not Cf +// (Other_Default_Ignorable_Code_Point such as U+3164, and U+2800 BRAILLE +// PATTERN BLANK) and every space separator other than ASCII space, so a name +// cannot pass for another one. Variation selectors such as U+FE0F stay +// allowed for emoji. The caller skips ZWJ before asking. +func invisibleNameRune(r rune) bool { + return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || r == '\u2028' || r == '\u2029' || + unicode.Is(unicode.Other_Default_Ignorable_Code_Point, r) || r == '\u2800' || + (r != ' ' && unicode.Is(unicode.Zs, r)) +} diff --git a/internal/channel/hashtag_test.go b/internal/channel/hashtag_test.go new file mode 100644 index 00000000..3df05f5b --- /dev/null +++ b/internal/channel/hashtag_test.go @@ -0,0 +1,96 @@ +package channel + +import ( + "errors" + "strings" + "testing" +) + +const ( + wantEmpty = "enter a channel name after #" + wantLong = "a channel name is at most 31 bytes including the # (MeshCore stores 32 with the terminator)" + wantPublic = "Public is the built-in channel and cannot be proposed" + wantInvisible = "the name contains invisible or control characters" + wantInvalid = "the name is not valid text" +) + +// The same accept and refuse lists are mirrored in +// tests/unit/test-channel-proposals-ui.js for public/channel-proposals.js. +func TestValidateHashtagNameAccepts(t *testing.T) { + cases := []struct{ in, want string }{ + {"mycity", "#mycity"}, + {" #MyCity ", "#MyCity"}, + {"\u3000#tokyo\u00a0", "#tokyo"}, + {"# a", "# a"}, + {"#" + strings.Repeat("a", 30), "#" + strings.Repeat("a", 30)}, + {"#" + strings.Repeat("é", 15), "#" + strings.Repeat("é", 15)}, + {"#" + strings.Repeat("€", 10), "#" + strings.Repeat("€", 10)}, + {"#👩\u200d💻", "#👩\u200d💻"}, + {"#publicity", "#publicity"}, + {`#a"`, `#a"`}, + {"#❤️", "#❤️"}, + {"#a️b", "#a️b"}, + } + for _, c := range cases { + got, err := ValidateHashtagName(c.in) + if err != nil || got != c.want { + t.Errorf("ValidateHashtagName(%q) = %q, %v; want %q", c.in, got, err, c.want) + } + } + if n := len("#👩\u200d💻"); n != 12 { + t.Fatalf("emoji fixture is %d bytes, want 12", n) + } +} + +func TestValidateHashtagNameRefuses(t *testing.T) { + cases := []struct{ in, msg string }{ + {"", wantEmpty}, + {" ", wantEmpty}, + {"#", wantEmpty}, + {" # ", wantEmpty}, + {"#\u200d", wantEmpty}, + {"#" + strings.Repeat("a", 31), wantLong}, + {"#" + strings.Repeat("é", 15) + "a", wantLong}, + {"#public", wantPublic}, + {"public", wantPublic}, + {"Public", wantPublic}, + {"#PUBLIC", wantPublic}, + {"#a\u202eb", wantInvisible}, + {"#a\u200bb", wantInvisible}, + {"#a\ufeffb", wantInvisible}, + {"\ufeffabc", wantInvisible}, + {"#a\tb", wantInvisible}, + {"#a\u2028b", wantInvisible}, + {"#a\u0007b", wantInvisible}, + {"#\u200e", wantInvisible}, + {"#mesh\u3164", wantInvisible}, + {"#\u3164", wantInvisible}, + {"#\u2800", wantInvisible}, + {"#a\u115fb", wantInvisible}, + {"#a\u1160b", wantInvisible}, + {"#\uffa0", wantInvisible}, + {"#me\u034fsh", wantInvisible}, + {"#a\u17b4b", wantInvisible}, + {"#my\u00a0city", wantInvisible}, + {"#a\u3000b", wantInvisible}, + {"#a\u2009b", wantInvisible}, + {"#a\u1680b", wantInvisible}, + {"#a\u202fb", wantInvisible}, + {"#\u00a0a", wantInvisible}, + {"#a\xffb", wantInvalid}, + } + for _, c := range cases { + got, err := ValidateHashtagName(c.in) + var ne *NameError + if !errors.As(err, &ne) || ne.Msg != c.msg { + t.Errorf("ValidateHashtagName(%q) = %q, %v; want NameError %q", c.in, got, err, c.msg) + } + } +} + +func TestMaxHashtagNameBytesMatchesFirmware(t *testing.T) { + // ChannelDetails.name[32] minus the terminating NUL. + if MaxHashtagNameBytes != 31 { + t.Fatalf("MaxHashtagNameBytes = %d, want 31", MaxHashtagNameBytes) + } +} diff --git a/internal/users/audit_list_test.go b/internal/users/audit_list_test.go index 67559827..a74f33ea 100644 --- a/internal/users/audit_list_test.go +++ b/internal/users/audit_list_test.go @@ -186,8 +186,8 @@ func TestMigrateV2DatabaseToV3(t *testing.T) { t.Fatalf("Open v2 db: %v", err) } defer st.Close() - if v, err := st.SchemaVersion(); err != nil || v != 3 { - t.Fatalf("SchemaVersion = %d, %v; want 3", v, err) + if v, err := st.SchemaVersion(); err != nil || v != len(migrations) { + t.Fatalf("SchemaVersion = %d, %v; want %d", v, err, len(migrations)) } if !hasIndex(t, st, "audit_at") { t.Fatal("audit_at index missing after migration") diff --git a/internal/users/proposals.go b/internal/users/proposals.go new file mode 100644 index 00000000..9e096fba --- /dev/null +++ b/internal/users/proposals.go @@ -0,0 +1,298 @@ +package users + +import ( + "database/sql" + "errors" + "time" +) + +// Proposals (docs/specs/2026-10-07-channel-proposals-design.md): one row per +// (kind, subject); a state change updates the row, history is in the audit log. + +// KindHashtagChannel is the one proposal kind of this version. Subject is a +// name accepted by channel.ValidateHashtagName. +const KindHashtagChannel = "hashtag_channel" + +type ProposalStatus string + +const ( + ProposalPending ProposalStatus = "pending" + ProposalApproved ProposalStatus = "approved" + ProposalRejected ProposalStatus = "rejected" + ProposalRevoked ProposalStatus = "revoked" +) + +// Valid reports whether st is a known status. +func (st ProposalStatus) Valid() bool { + switch st { + case ProposalPending, ProposalApproved, ProposalRejected, ProposalRevoked: + return true + } + return false +} + +type ProposalAction string + +const ( + ProposalApprove ProposalAction = "approve" + ProposalReject ProposalAction = "reject" + ProposalRevoke ProposalAction = "revoke" +) + +var ( + ErrProposalPending = errors.New("users: already proposed") + ErrProposalApproved = errors.New("users: already approved") + ErrProposalRejected = errors.New("users: proposal was rejected") + ErrProposalTransition = errors.New("users: action not allowed in the proposal's state") + ErrProposalUserLimit = errors.New("users: daily proposal limit reached") + ErrProposalPendingLimit = errors.New("users: pending proposal limit reached") + ErrProposalApprovedLimit = errors.New("users: approved proposal limit reached") +) + +// ProposalListMax caps ListProposals and ProposalsByUser. +const ProposalListMax = 500 + +// Proposal is one row of the proposals table. +type Proposal struct { + ID int64 + Kind string + Subject string + Status ProposalStatus + ProposerID *int64 // nil once the proposer's account is deleted + ReviewerID *int64 + Note string // the reviewer's reason, shown to the proposer + CreatedAt time.Time + DecidedAt *time.Time +} + +// ProposalLimits bound Propose. Zero means no limit. +type ProposalLimits struct { + MaxPending int // pending proposals of every kind + PerUserPerDay int // proposals one user created or re-opened in the last 24 hours +} + +// ProposalFilter narrows ListProposals. Zero values mean "any". +type ProposalFilter struct { + Status ProposalStatus + Kind string +} + +const proposalCols = `id, kind, subject, status, proposer_id, reviewer_id, note, created_at, decided_at` + +func scanProposal(row rowScanner) (*Proposal, error) { + var p Proposal + var status string + var created int64 + var proposer, reviewer, decided sql.NullInt64 + if err := row.Scan(&p.ID, &p.Kind, &p.Subject, &status, &proposer, &reviewer, &p.Note, &created, &decided); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + p.Status = ProposalStatus(status) + p.ProposerID = nullableID(proposer) + p.ReviewerID = nullableID(reviewer) + p.CreatedAt = fromUnix(created) + p.DecidedAt = fromNullUnix(decided) + return &p, nil +} + +func nullableID(v sql.NullInt64) *int64 { + if !v.Valid { + return nil + } + id := v.Int64 + return &id +} + +// Propose opens a proposal for (kind, subject) in one transaction. A revoked +// row becomes pending again under the new proposer; pending, approved and +// rejected rows refuse. Duplicates are checked before the limits, so a +// duplicate is reported as one. +func (s *Store) Propose(kind, subject string, userID int64, lim ProposalLimits) (*Proposal, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + now := unix(s.now()) + cur, err := scanProposal(tx.QueryRow(`SELECT `+proposalCols+` FROM proposals WHERE kind = ? AND subject = ?`, kind, subject)) + switch { + case errors.Is(err, ErrNotFound): + cur = nil + case err != nil: + return nil, err + case cur.Status == ProposalPending: + return nil, ErrProposalPending + case cur.Status == ProposalApproved: + return nil, ErrProposalApproved + case cur.Status == ProposalRejected: + return nil, ErrProposalRejected + } + if lim.PerUserPerDay > 0 { + var n int + if err := tx.QueryRow(`SELECT COUNT(*) FROM proposals WHERE proposer_id = ? AND created_at > ?`, + userID, now-secs(24*time.Hour)).Scan(&n); err != nil { + return nil, err + } + if n >= lim.PerUserPerDay { + return nil, ErrProposalUserLimit + } + } + if lim.MaxPending > 0 { + var n int + if err := tx.QueryRow(`SELECT COUNT(*) FROM proposals WHERE status = 'pending'`).Scan(&n); err != nil { + return nil, err + } + if n >= lim.MaxPending { + return nil, ErrProposalPendingLimit + } + } + var id int64 + if cur == nil { + res, err := tx.Exec(`INSERT INTO proposals (kind, subject, status, proposer_id, created_at) VALUES (?, ?, 'pending', ?, ?)`, + kind, subject, userID, now) + if err != nil { + return nil, err + } + if id, err = res.LastInsertId(); err != nil { + return nil, err + } + } else { + id = cur.ID + if _, err := tx.Exec(`UPDATE proposals SET status = 'pending', proposer_id = ?, reviewer_id = NULL, note = '', + created_at = ?, decided_at = NULL WHERE id = ?`, userID, now, id); err != nil { + return nil, err + } + } + p, err := scanProposal(tx.QueryRow(`SELECT `+proposalCols+` FROM proposals WHERE id = ?`, id)) + if err != nil { + return nil, err + } + return p, tx.Commit() +} + +// Decide applies action in one transaction: approve and reject from +// pending, revoke from approved; anything else is ErrProposalTransition. +// Approve refuses with ErrProposalApprovedLimit when maxApproved (> 0) +// proposals of the same kind are approved already. +func (s *Store) Decide(id int64, action ProposalAction, reviewerID int64, note string, maxApproved int) (*Proposal, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + p, err := scanProposal(tx.QueryRow(`SELECT `+proposalCols+` FROM proposals WHERE id = ?`, id)) + if err != nil { + return nil, err + } + var to ProposalStatus + switch { + case action == ProposalApprove && p.Status == ProposalPending: + to = ProposalApproved + case action == ProposalReject && p.Status == ProposalPending: + to = ProposalRejected + case action == ProposalRevoke && p.Status == ProposalApproved: + to = ProposalRevoked + default: + return nil, ErrProposalTransition + } + if to == ProposalApproved && maxApproved > 0 { + var n int + if err := tx.QueryRow(`SELECT COUNT(*) FROM proposals WHERE kind = ? AND status = 'approved'`, p.Kind).Scan(&n); err != nil { + return nil, err + } + if n >= maxApproved { + return nil, ErrProposalApprovedLimit + } + } + now := s.now() + if _, err := tx.Exec(`UPDATE proposals SET status = ?, reviewer_id = ?, note = ?, decided_at = ? WHERE id = ?`, + string(to), reviewerID, note, unix(now), id); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + decided := fromUnix(unix(now)) + p.Status, p.ReviewerID, p.Note, p.DecidedAt = to, &reviewerID, note, &decided + return p, nil +} + +// ListProposals returns at most ProposalListMax proposals, newest first. +func (s *Store) ListProposals(f ProposalFilter) ([]Proposal, error) { + q := `SELECT ` + proposalCols + ` FROM proposals WHERE 1=1` + var args []any + if f.Status != "" { + q += ` AND status = ?` + args = append(args, string(f.Status)) + } + if f.Kind != "" { + q += ` AND kind = ?` + args = append(args, f.Kind) + } + q += ` ORDER BY created_at DESC, id DESC LIMIT ?` + args = append(args, ProposalListMax) + return s.queryProposals(q, args...) +} + +// ProposalsByUser returns the user's proposals, newest first. +func (s *Store) ProposalsByUser(userID int64) ([]Proposal, error) { + return s.queryProposals(`SELECT `+proposalCols+` FROM proposals WHERE proposer_id = ? + ORDER BY created_at DESC, id DESC LIMIT ?`, userID, ProposalListMax) +} + +func (s *Store) queryProposals(q string, args ...any) ([]Proposal, error) { + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Proposal + for rows.Next() { + p, err := scanProposal(rows) + if err != nil { + return nil, err + } + out = append(out, *p) + } + return out, rows.Err() +} + +// ApprovedSubjects returns the approved subjects of kind, oldest decision +// first, at most limit (0 = all). Never nil. cmd/ingestor runs the same +// query as raw SQL (see ingestorApprovedQuery in proposals_test.go). +func (s *Store) ApprovedSubjects(kind string, limit int) ([]string, error) { + q := `SELECT subject FROM proposals WHERE kind = ? AND status = 'approved' ORDER BY decided_at, id` + args := []any{kind} + if limit > 0 { + q += ` LIMIT ?` + args = append(args, limit) + } + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []string{} + for rows.Next() { + var subject string + if err := rows.Scan(&subject); err != nil { + return nil, err + } + out = append(out, subject) + } + return out, rows.Err() +} + +// PruneProposals deletes rejected and revoked proposals decided more than +// maxAge ago. Pending and approved rows are kept however old they are. +func (s *Store) PruneProposals(maxAge time.Duration) (int64, error) { + res, err := s.db.Exec(`DELETE FROM proposals WHERE status IN ('rejected','revoked') AND decided_at < ?`, + unix(s.now())-secs(maxAge)) + if err != nil { + return 0, err + } + return res.RowsAffected() +} diff --git a/internal/users/proposals_test.go b/internal/users/proposals_test.go new file mode 100644 index 00000000..32e61894 --- /dev/null +++ b/internal/users/proposals_test.go @@ -0,0 +1,376 @@ +package users + +import ( + "database/sql" + "errors" + "path/filepath" + "strings" + "testing" + "time" +) + +// cmd/ingestor/approved_channels.go reads the approved names with this exact +// literal (it does not import this package). Its test checks the literal is +// still in this file; TestApprovedSubjects checks it still agrees with +// ApprovedSubjects. +const ingestorApprovedQuery = `SELECT subject FROM proposals WHERE kind = 'hashtag_channel' AND status = 'approved' ORDER BY decided_at, id LIMIT ?` + +func mustPropose(t *testing.T, st *Store, subject string, uid int64) *Proposal { + t.Helper() + p, err := st.Propose(KindHashtagChannel, subject, uid, ProposalLimits{}) + if err != nil { + t.Fatalf("Propose(%s): %v", subject, err) + } + return p +} + +func mustDecide(t *testing.T, st *Store, id int64, a ProposalAction, reviewer int64) *Proposal { + t.Helper() + p, err := st.Decide(id, a, reviewer, "", 0) + if err != nil { + t.Fatalf("Decide(%d, %s): %v", id, a, err) + } + return p +} + +// reach moves a fresh pending proposal to status. +func reach(t *testing.T, st *Store, id int64, status ProposalStatus, reviewer int64) { + t.Helper() + switch status { + case ProposalApproved: + mustDecide(t, st, id, ProposalApprove, reviewer) + case ProposalRejected: + mustDecide(t, st, id, ProposalReject, reviewer) + case ProposalRevoked: + mustDecide(t, st, id, ProposalApprove, reviewer) + mustDecide(t, st, id, ProposalRevoke, reviewer) + } +} + +func TestProposeCreatesPending(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "pat@example.org", "Pat") + p := mustPropose(t, st, "#mycity", u.ID) + if p.ID == 0 || p.Kind != KindHashtagChannel || p.Subject != "#mycity" || p.Status != ProposalPending || + p.ProposerID == nil || *p.ProposerID != u.ID || p.ReviewerID != nil || p.Note != "" || + p.DecidedAt != nil || !p.CreatedAt.Equal(clk.Now()) { + t.Fatalf("unexpected proposal: %+v", p) + } +} + +func TestProposeRefusesDuplicatesByState(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "pat@example.org", "Pat") + admin := mustCreate(t, st, "ada@example.org", "Ada") + a := mustPropose(t, st, "#a", u.ID) + if _, err := st.Propose(KindHashtagChannel, "#a", admin.ID, ProposalLimits{}); !errors.Is(err, ErrProposalPending) { + t.Fatalf("pending duplicate err = %v; want ErrProposalPending", err) + } + mustDecide(t, st, a.ID, ProposalApprove, admin.ID) + if _, err := st.Propose(KindHashtagChannel, "#a", admin.ID, ProposalLimits{}); !errors.Is(err, ErrProposalApproved) { + t.Fatalf("approved duplicate err = %v; want ErrProposalApproved", err) + } + b := mustPropose(t, st, "#b", u.ID) + mustDecide(t, st, b.ID, ProposalReject, admin.ID) + if _, err := st.Propose(KindHashtagChannel, "#b", u.ID, ProposalLimits{}); !errors.Is(err, ErrProposalRejected) { + t.Fatalf("rejected re-proposal err = %v; want ErrProposalRejected", err) + } + // The same subject under another kind is another row. + if _, err := st.Propose("other_kind", "#a", u.ID, ProposalLimits{}); err != nil { + t.Fatalf("other kind: %v", err) + } + // Case matters: the key is derived from the exact name. + if _, err := st.Propose(KindHashtagChannel, "#A", u.ID, ProposalLimits{}); err != nil { + t.Fatalf("#A next to #a: %v", err) + } +} + +func TestRevokedCanBeProposedAgain(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "pat@example.org", "Pat") + v := mustCreate(t, st, "val@example.org", "Val") + admin := mustCreate(t, st, "ada@example.org", "Ada") + p := mustPropose(t, st, "#a", u.ID) + clk.Advance(time.Hour) + mustDecide(t, st, p.ID, ProposalApprove, admin.ID) + if _, err := st.Decide(p.ID, ProposalRevoke, admin.ID, "spam", 0); err != nil { + t.Fatal(err) + } + clk.Advance(time.Hour) + again, err := st.Propose(KindHashtagChannel, "#a", v.ID, ProposalLimits{}) + if err != nil || again.ID != p.ID || again.Status != ProposalPending || again.ProposerID == nil || *again.ProposerID != v.ID || + again.ReviewerID != nil || again.Note != "" || again.DecidedAt != nil || !again.CreatedAt.Equal(clk.Now()) { + t.Fatalf("re-proposal = %+v, %v; want the same row pending under Val", again, err) + } +} + +func TestDecideTransitions(t *testing.T) { + cases := []struct { + from ProposalStatus + action ProposalAction + to ProposalStatus // "" = refused + }{ + {ProposalPending, ProposalApprove, ProposalApproved}, + {ProposalPending, ProposalReject, ProposalRejected}, + {ProposalPending, ProposalRevoke, ""}, + {ProposalApproved, ProposalRevoke, ProposalRevoked}, + {ProposalApproved, ProposalApprove, ""}, + {ProposalApproved, ProposalReject, ""}, + {ProposalRejected, ProposalApprove, ""}, + {ProposalRejected, ProposalRevoke, ""}, + {ProposalRevoked, ProposalApprove, ""}, + {ProposalRevoked, ProposalReject, ""}, + } + for _, c := range cases { + st, clk := newTestStore(t) + u := mustCreate(t, st, "pat@example.org", "Pat") + admin := mustCreate(t, st, "ada@example.org", "Ada") + p := mustPropose(t, st, "#x", u.ID) + reach(t, st, p.ID, c.from, admin.ID) + clk.Advance(time.Minute) + got, err := st.Decide(p.ID, c.action, admin.ID, "because", 0) + if c.to == "" { + if !errors.Is(err, ErrProposalTransition) { + t.Errorf("%s from %s: err = %v; want ErrProposalTransition", c.action, c.from, err) + } + continue + } + if err != nil || got.Status != c.to || got.ReviewerID == nil || *got.ReviewerID != admin.ID || + got.Note != "because" || got.DecidedAt == nil || !got.DecidedAt.Equal(clk.Now()) { + t.Errorf("%s from %s = %+v, %v; want %s", c.action, c.from, got, err, c.to) + continue + } + list, err := st.ListProposals(ProposalFilter{}) + if err != nil || len(list) != 1 || list[0].Status != c.to || list[0].Note != "because" { + t.Errorf("%s from %s: stored %+v, %v", c.action, c.from, list, err) + } + } +} + +func TestDecideUnknownID(t *testing.T) { + st, _ := newTestStore(t) + admin := mustCreate(t, st, "ada@example.org", "Ada") + if _, err := st.Decide(999, ProposalApprove, admin.ID, "", 0); !errors.Is(err, ErrNotFound) { + t.Fatalf("Decide(missing) err = %v; want ErrNotFound", err) + } +} + +func TestDecideApproveRespectsMaxApproved(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "pat@example.org", "Pat") + admin := mustCreate(t, st, "ada@example.org", "Ada") + for _, s := range []string{"#a", "#b"} { + mustDecide(t, st, mustPropose(t, st, s, u.ID).ID, ProposalApprove, admin.ID) + } + c := mustPropose(t, st, "#c", u.ID) + if _, err := st.Decide(c.ID, ProposalApprove, admin.ID, "", 2); !errors.Is(err, ErrProposalApprovedLimit) { + t.Fatalf("third approve err = %v; want ErrProposalApprovedLimit", err) + } + if got, _ := st.ListProposals(ProposalFilter{Status: ProposalPending}); len(got) != 1 || got[0].ID != c.ID { + t.Fatalf("refused approve changed the row: %+v", got) + } + // Other kinds do not count, and reject is never limited. + o, err := st.Propose("other_kind", "#a", u.ID, ProposalLimits{}) + if err != nil { + t.Fatal(err) + } + if _, err := st.Decide(o.ID, ProposalApprove, admin.ID, "", 2); err != nil { + t.Fatalf("other kind approve: %v", err) + } + if _, err := st.Decide(c.ID, ProposalReject, admin.ID, "", 2); err != nil { + t.Fatalf("reject at the cap: %v", err) + } +} + +func TestProposeLimits(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "pat@example.org", "Pat") + v := mustCreate(t, st, "val@example.org", "Val") + day := ProposalLimits{PerUserPerDay: 2} + for _, s := range []string{"#a", "#b"} { + if _, err := st.Propose(KindHashtagChannel, s, u.ID, day); err != nil { + t.Fatalf("Propose(%s): %v", s, err) + } + } + if _, err := st.Propose(KindHashtagChannel, "#c", u.ID, day); !errors.Is(err, ErrProposalUserLimit) { + t.Fatalf("third in a day err = %v; want ErrProposalUserLimit", err) + } + if _, err := st.Propose(KindHashtagChannel, "#c", v.ID, day); err != nil { + t.Fatalf("another user has its own budget: %v", err) + } + clk.Advance(24*time.Hour + time.Second) + if _, err := st.Propose(KindHashtagChannel, "#d", u.ID, day); err != nil { + t.Fatalf("next day: %v", err) + } + // Four pending now: #a #b #c #d. + capped := ProposalLimits{MaxPending: 4} + if _, err := st.Propose(KindHashtagChannel, "#e", v.ID, capped); !errors.Is(err, ErrProposalPendingLimit) { + t.Fatalf("pending cap err = %v; want ErrProposalPendingLimit", err) + } + // A duplicate is reported as such, not as a limit. + if _, err := st.Propose(KindHashtagChannel, "#a", v.ID, capped); !errors.Is(err, ErrProposalPending) { + t.Fatalf("duplicate at the cap err = %v; want ErrProposalPending", err) + } +} + +func TestListProposalsFilterAndOrder(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "pat@example.org", "Pat") + admin := mustCreate(t, st, "ada@example.org", "Ada") + a := mustPropose(t, st, "#a", u.ID) + clk.Advance(time.Minute) + b := mustPropose(t, st, "#b", u.ID) + clk.Advance(time.Minute) + mustDecide(t, st, a.ID, ProposalApprove, admin.ID) + all, err := st.ListProposals(ProposalFilter{}) + if err != nil || len(all) != 2 || all[0].ID != b.ID || all[1].ID != a.ID { + t.Fatalf("ListProposals(all) = %+v, %v; want #b then #a", all, err) + } + if pend, _ := st.ListProposals(ProposalFilter{Status: ProposalPending}); len(pend) != 1 || pend[0].ID != b.ID { + t.Fatalf("pending = %+v", pend) + } + if other, _ := st.ListProposals(ProposalFilter{Kind: "other_kind"}); len(other) != 0 { + t.Fatalf("other kind = %+v", other) + } +} + +func TestProposalsByUser(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "pat@example.org", "Pat") + v := mustCreate(t, st, "val@example.org", "Val") + a := mustPropose(t, st, "#a", u.ID) + clk.Advance(time.Minute) + b := mustPropose(t, st, "#b", u.ID) + mustPropose(t, st, "#c", v.ID) + list, err := st.ProposalsByUser(u.ID) + if err != nil || len(list) != 2 || list[0].ID != b.ID || list[1].ID != a.ID { + t.Fatalf("ProposalsByUser = %+v, %v", list, err) + } +} + +func TestApprovedSubjects(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "pat@example.org", "Pat") + admin := mustCreate(t, st, "ada@example.org", "Ada") + for _, s := range []string{"#c", "#a", "#b"} { + p := mustPropose(t, st, s, u.ID) + clk.Advance(time.Minute) + mustDecide(t, st, p.ID, ProposalApprove, admin.ID) + } + mustPropose(t, st, "#pending", u.ID) + o, err := st.Propose("other_kind", "#x", u.ID, ProposalLimits{}) + if err != nil { + t.Fatal(err) + } + mustDecide(t, st, o.ID, ProposalApprove, admin.ID) + + got, err := st.ApprovedSubjects(KindHashtagChannel, 0) + if err != nil || strings.Join(got, ",") != "#c,#a,#b" { + t.Fatalf("ApprovedSubjects = %v, %v; want decision order #c,#a,#b", got, err) + } + if capped, _ := st.ApprovedSubjects(KindHashtagChannel, 2); strings.Join(capped, ",") != "#c,#a" { + t.Fatalf("capped = %v", capped) + } + if empty, err := st.ApprovedSubjects("nothing", 0); err != nil || empty == nil || len(empty) != 0 { + t.Fatalf("no rows = %#v, %v; want a non-nil empty slice", empty, err) + } + rows, err := st.db.Query(ingestorApprovedQuery, 2) + if err != nil { + t.Fatalf("ingestor query: %v", err) + } + defer rows.Close() + var viaIngestor []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + t.Fatal(err) + } + viaIngestor = append(viaIngestor, s) + } + if strings.Join(viaIngestor, ",") != "#c,#a" { + t.Fatalf("ingestor query = %v; want #c,#a (same order and cap as ApprovedSubjects)", viaIngestor) + } +} + +func TestPruneProposals(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "pat@example.org", "Pat") + admin := mustCreate(t, st, "ada@example.org", "Ada") + old := map[string]ProposalStatus{"#rej": ProposalRejected, "#rev": ProposalRevoked, "#app": ProposalApproved, "#pen": ProposalPending} + for s, status := range old { + reach(t, st, mustPropose(t, st, s, u.ID).ID, status, admin.ID) + } + clk.Advance(91 * 24 * time.Hour) + reach(t, st, mustPropose(t, st, "#young", u.ID).ID, ProposalRejected, admin.ID) + n, err := st.PruneProposals(90 * 24 * time.Hour) + if err != nil || n != 2 { + t.Fatalf("PruneProposals = %d, %v; want 2", n, err) + } + left, _ := st.ListProposals(ProposalFilter{}) + got := map[string]bool{} + for _, p := range left { + got[p.Subject] = true + } + if len(left) != 3 || !got["#app"] || !got["#pen"] || !got["#young"] { + t.Fatalf("left after prune = %v", got) + } + // A pruned rejection can be proposed again. + if _, err := st.Propose(KindHashtagChannel, "#rej", u.ID, ProposalLimits{}); err != nil { + t.Fatalf("re-propose after prune: %v", err) + } +} + +func TestProposalSurvivesProposerDeletion(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "pat@example.org", "Pat") + mustPropose(t, st, "#a", u.ID) + if err := st.Delete(u.ID); err != nil { + t.Fatal(err) + } + list, err := st.ListProposals(ProposalFilter{}) + if err != nil || len(list) != 1 || list[0].ProposerID != nil { + t.Fatalf("after deleting the proposer = %+v, %v; want the row with no proposer", list, err) + } +} + +// A users.db written by a v3 binary gains the proposals table and keeps its rows. +func TestMigrateV3DatabaseToV4(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + stmts := []string{ + `CREATE TABLE schema_version (version INTEGER NOT NULL)`, + `INSERT INTO schema_version (version) VALUES (3)`, + } + for _, m := range migrations[:3] { + stmts = append(stmts, m...) + } + stmts = append(stmts, `INSERT INTO users (email, display_name, password_hash, created_at) VALUES ('old@example.org', 'Old', 'x', 1)`) + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("build v3 db: %v", err) + } + } + db.Close() + + st, err := Open(path) + if err != nil { + t.Fatalf("Open v3 db: %v", err) + } + defer st.Close() + if v, err := st.SchemaVersion(); err != nil || v != 4 { + t.Fatalf("SchemaVersion = %d, %v; want 4", v, err) + } + if !hasIndex(t, st, "proposals_kind_subject") || !hasIndex(t, st, "proposals_status") { + t.Fatal("proposals indexes missing after migration") + } + u, err := st.GetByEmail("old@example.org") + if err != nil { + t.Fatalf("v3 user lost: %v", err) + } + if _, err := st.Propose(KindHashtagChannel, "#after", u.ID, ProposalLimits{}); err != nil { + t.Fatalf("Propose after migration: %v", err) + } +} diff --git a/internal/users/schema.go b/internal/users/schema.go index 3724df7d..d885b880 100644 --- a/internal/users/schema.go +++ b/internal/users/schema.go @@ -86,6 +86,21 @@ var migrations = [][]string{ { // v3: sub-project C, global audit list ordered and filtered by time `CREATE INDEX audit_at ON audit_log(at)`, }, + { // v4: sub-project D, proposals (one generic table; this version ships the kind 'hashtag_channel') + `CREATE TABLE proposals ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending','approved','rejected','revoked')), + proposer_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + reviewer_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + note TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + decided_at INTEGER + )`, + `CREATE UNIQUE INDEX proposals_kind_subject ON proposals(kind, subject)`, + `CREATE INDEX proposals_status ON proposals(status)`, + }, } func (s *Store) migrate() error { diff --git a/internal/users/validate.go b/internal/users/validate.go index 431d2aba..03bfe33f 100644 --- a/internal/users/validate.go +++ b/internal/users/validate.go @@ -35,6 +35,7 @@ func NormalizeEmail(raw string) (string, error) { // ValidateDisplayName trims a display name and enforces 2–32 characters // without control, format (bidi, zero-width) or line/paragraph separator // characters. ZWJ (U+200D) is allowed so emoji sequences survive. +// internal/channel/hashtag.go applies a stricter variant of this rule to channel names. func ValidateDisplayName(raw string) (string, error) { n := strings.TrimSpace(raw) if !utf8.ValidString(n) { diff --git a/public/account.css b/public/account.css index a8920a5f..8620b205 100644 --- a/public/account.css +++ b/public/account.css @@ -60,6 +60,11 @@ .um-status-pending, .um-chip-sent, .um-chip-deferred, .um-chip-soft_bounce { color: var(--status-yellow-text); } .um-status-disabled, .um-chip-hard_bounce, .um-chip-invalid_email, .um-chip-blocked, .um-chip-spam, .um-chip-error, .um-chip-bouncing { color: var(--status-red-text); } .um-chip-unsubscribed { color: var(--text-muted); } +.um-status-approved { color: var(--status-green-tint-text); } +.um-status-rejected, .um-status-revoked { color: var(--status-red-text); } +.account-proposals { list-style: none; padding: 0; margin: 0; } +.account-proposals li { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: var(--fs-sm); } +.account-proposals li .account-hint { flex-basis: 100%; margin: 0; } .um-detail { margin-top: 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 16px; } .um-detail ol { padding-left: 18px; } @media (max-width: 760px) { .um-col-optional { display: none; } } diff --git a/public/account.js b/public/account.js index 633ac0ec..3af83fa1 100644 --- a/public/account.js +++ b/public/account.js @@ -1,6 +1,6 @@ /* Account pages for optional user management. * #/account/login | register | activate?token= | forgot | reset?token= | confirm-email?token= | check-mail - * #/account : profile, password, address, sessions, delete + * #/account : profile, password, address, sessions, my proposals, delete * Every dynamic string goes through escapeHtml; messages use textContent. */ (function () { 'use strict'; @@ -110,6 +110,7 @@ field('emailPw', 'Current password', 'password', 'current-password') + submitBtn('Change address') + msgBox('emailMsg') + '' + '

Devices

' + msgBox('sessMsg') + + (window.CSProposals && window.CSProposals.enabled() ? '

My proposals

' + msgBox('propMsg') : '') + (window.CSSettingsSync ? '

Settings sync

' : '') + '

Delete account