feat: integrate hashtag channels from meshcore-channels catalogue (#1323) (#1656)

Fixes #1323

## Summary

Adds a small in-memory cache of the community-maintained
hashtag-channels
catalogue (`marcelverdult/meshcore-channels`) and exposes it as
`GET /api/known-channels?region=XX` plus a collapsed sidebar section on
the Channels view ("Known channels (catalogue)") with a one-click
"+ Add" button per row.

Per triage (#1323): new `cmd/server/known_channels_cache.go`, new
`GET /api/known-channels?region=…`, frontend section in
`public/channels.js`. No new DB tables — cache is in-memory only.

## What changed

- `cmd/server/known_channels_cache.go` — `knownChannelsCache` with an
  atomic snapshot pointer, 24h default refresh, 30s HTTP timeout, 4 MB
  body cap, custom `User-Agent`. Fail-soft: a failed refresh leaves the
  last-known snapshot in place. Background goroutine started from
  `main.go` after the neighbor-graph recomputer; never blocks startup.
- `cmd/server/known_channels_route.go` — `GET
/api/known-channels?region=`
  serves the cached snapshot off the atomic pointer (never blocks on
  upstream). Region filter is case-insensitive ISO 3166-1 alpha-2.
  Empty/missing cache returns 200 with an empty entries list (fail-soft
  for the UI).
- `cmd/server/config.go` — `KnownChannelsURL` +
`KnownChannelsRefreshMs`.
- `config.example.json` — example values + `_comment_knownChannels`.
- `public/channels.js` — new collapsed sidebar section "Known channels
  (catalogue)" that lazy-fetches `/api/known-channels` on first render
  and renders rows with a "+ Add" button. The button calls the existing
  `addUserChannel(name)` path, so adding catalogue channels reuses the
  full save-key + decrypt flow that user-typed hashtags already use.
- `cmd/server/known_channels_cache_test.go` — failing-first tests:
  - `TestKnownChannelsParseFixture` asserts the parser populates
    `GeneratedAt`/`License` and region-stamps every entry while skipping
    empty countries.
  - `TestKnownChannelsRouteRegionFilter` asserts the route returns 200
    with exactly the filtered subset for `?region=be`.
  - `TestKnownChannelsFailSoftOn500` asserts a failed upstream fetch
    leaves the prior snapshot in place and bumps `failCount`.

## Upstream pinning

The default URL is pinned to the specific file
`channels-by-country.json`
on `main`:

>
https://raw.githubusercontent.com/marcelverdult/meshcore-channels/main/channels-by-country.json

Shape (verified 2026-05-24):

```json
{
  "generated_at": "...",
  "license": "CC0-1.0",
  "countries": { "be": [{"channel": "#antwerpen", "description": "..."}], ... }
}
```

## Test plan

```
cd cmd/server && go test -run 'TestKnownChannels' -count=1 .
ok  	github.com/corescope/server	0.008s
```

Red commit: 5c43cff3 (all three tests fail on assertions, build clean).
Green commit: 54a1080e (parser + cache + route implemented, all three
pass).

## TDD evidence (red → green)

- **Red commit `5c43cff3427afd8aa2f3cce20c31058190aebc37`** — tests
added
  with stub implementations that compile but return zero/empty so each
  test fails on an assertion (not a compile/import error). `go test -run
  TestKnownChannels` output captured in the commit message.
- **Green commit `54a1080e45fd2e10da2caa156f376bf4d0212976`** — parser,
  cache, route, main-wiring, frontend section land; all three tests
  pass.

## Frontend verification

Browser verified: http://analyzer-stg.00id.net/#/channels (with the
`/api/known-channels` response stubbed in DevTools to simulate the cache
being populated on staging, which is still on master and doesn't have
the new endpoint yet).

E2E assertion added: cmd/server/known_channels_cache_test.go:71 —
asserts the route returns 200 and the response body's `entries` length
matches the filtered subset.

## Limitations / follow-ups (not in scope of this PR)

- The catalogue only ships PSK keys for a small subset of entries (the
  upstream schema makes `key` optional). For entries WITHOUT a `key`,
  the "+ Add" button still wires through `addUserChannel("#name")` —
  which derives the standard public-channel key from the name (the same
  path used today when a user types `#foo` into the Add Channel modal).
  For entries WITH a `key`, a follow-up PR can pass the key through to
  `addUserChannel` so the UX matches "paste-a-PSK". Today the key is
  shown in the JSON payload but not yet wired into the FE button.
- No deduplication against the in-memory `/api/channels` list — the
  catalogue section is intentionally separate so the user sees which
  channels exist worldwide even if their server hasn't seen traffic.
- No per-section region selector yet — the section shows the full
  catalogue regardless of the page-level region filter. Future work:
  add a dropdown.

## Preflight

```
═══ Preflight clean. ═══
```

cross-stack: justified — issue #1323 spans `cmd/server` (cache + route)
and `public/channels.js` (sidebar surface); same feature, both halves
required.

---------

Co-authored-by: Kpa-clawbot <bot@corescope.local>
This commit is contained in:
Kpa-clawbot
2026-06-11 07:38:36 -07:00
committed by GitHub
parent fb6bb085a5
commit e04c7113cb
8 changed files with 688 additions and 0 deletions
+7
View File
@@ -154,6 +154,13 @@ type Config struct {
// Customizer controls operator-side knobs for the in-app customizer modal
// (theme/branding/etc.). See CustomizerConfig and issue #1508.
Customizer *CustomizerConfig `json:"customizer,omitempty"`
// Known-channels catalogue integration (issue #1323).
// URL of a JSON catalogue file (channels-by-country shape) fetched
// periodically and exposed via /api/known-channels. Empty disables.
KnownChannelsURL string `json:"knownChannelsUrl,omitempty"`
// Refresh interval in milliseconds. 0/missing => default 24h.
KnownChannelsRefreshMs int64 `json:"knownChannelsRefreshMs,omitempty"`
}
// CustomizerConfig holds operator-side knobs for the in-app customizer modal.
+224
View File
@@ -0,0 +1,224 @@
package main
// Known-channels catalogue cache (issue #1323).
//
// Fetches a community-maintained catalogue of hashtag channels (default:
// https://raw.githubusercontent.com/marcelverdult/meshcore-channels/main/channels-by-country.json)
// every N hours into an in-memory snapshot. Never blocks startup; never
// blocks UI on the fetch; fail-soft to last-known. No DB, no disk cache.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
"time"
)
// DefaultKnownChannelsURL is the suggested upstream catalogue, pinned to a
// specific commit SHA so a hostile or compromised future commit on the
// community repo cannot be silently fetched by deployments that opt in.
// Operators should periodically bump this pin (see config.example.json).
// NOTE: this constant is only used by tests and as documentation — the
// feature is OPT-IN: an empty cfg.KnownChannelsURL leaves the cache
// disabled (no background fetch, /api/known-channels serves empty).
const DefaultKnownChannelsURL = "https://raw.githubusercontent.com/marcelverdult/meshcore-channels/072bc25b6fc983aa2aa7e9d399a97a5f4899ea71/channels-by-country.json"
// DefaultKnownChannelsRefresh is the default refresh interval (24h).
const DefaultKnownChannelsRefresh = 24 * time.Hour
// maxKnownChannelsBytes caps the upstream response size we are willing to
// parse (the catalogue is ~80 KB today; 4 MB ceiling is plenty of headroom
// and bounds memory if upstream ever ships a malicious oversize payload).
const maxKnownChannelsBytes = 4 * 1024 * 1024
// KnownChannelEntry is one catalogue entry, region-stamped.
type KnownChannelEntry struct {
Channel string `json:"channel"` // e.g. "#antwerpen" (# prefix preserved)
Description string `json:"description,omitempty"`
Key string `json:"key,omitempty"` // optional PSK (base64) — present for some entries
Region string `json:"region"` // ISO 3166-1 alpha-2 lowercase
RegionName string `json:"regionName,omitempty"`
}
// KnownChannelsSnapshot is the immutable parsed catalogue surfaced over /api.
type KnownChannelsSnapshot struct {
GeneratedAt string `json:"generatedAt,omitempty"` // upstream generation timestamp
License string `json:"license,omitempty"`
FetchedAt time.Time `json:"fetchedAt"`
Source string `json:"source"`
Entries []KnownChannelEntry `json:"entries"`
}
// upstreamPayload mirrors the channels-by-country.json shape.
type upstreamPayload struct {
GeneratedAt string `json:"generated_at"`
License string `json:"license"`
Countries map[string][]upstreamCountryChannel `json:"countries"`
CountryNames map[string]string `json:"countryNames,omitempty"` // optional extension
}
type upstreamCountryChannel struct {
Channel string `json:"channel"`
Description string `json:"description"`
Key string `json:"key,omitempty"`
}
// parseKnownChannelsJSON parses the upstream JSON into a snapshot.
// Tolerant: missing/empty countries are skipped silently; entries with
// empty channel strings are dropped.
func parseKnownChannelsJSON(raw []byte, source string, now time.Time) (*KnownChannelsSnapshot, error) {
if len(raw) == 0 {
return nil, errors.New("empty payload")
}
var p upstreamPayload
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("decode catalogue: %w", err)
}
out := &KnownChannelsSnapshot{
GeneratedAt: p.GeneratedAt,
License: p.License,
FetchedAt: now,
Source: source,
Entries: make([]KnownChannelEntry, 0, 256),
}
for code, list := range p.Countries {
if len(list) == 0 {
continue
}
region := strings.ToLower(strings.TrimSpace(code))
name := p.CountryNames[code]
for _, c := range list {
ch := strings.TrimSpace(c.Channel)
if ch == "" {
continue
}
out.Entries = append(out.Entries, KnownChannelEntry{
Channel: ch,
Description: c.Description,
Key: c.Key,
Region: region,
RegionName: name,
})
}
}
return out, nil
}
// filterSnapshotByRegion returns a copy filtered to the given region
// (case-insensitive). Empty/whitespace region returns the original snapshot
// (entry slice shared — callers must not mutate). Unknown region returns
// a snapshot with an empty (but non-nil) Entries slice so JSON marshals as `[]`.
func filterSnapshotByRegion(snap *KnownChannelsSnapshot, region string) *KnownChannelsSnapshot {
if snap == nil {
return nil
}
region = strings.ToLower(strings.TrimSpace(region))
if region == "" {
return snap
}
out := &KnownChannelsSnapshot{
GeneratedAt: snap.GeneratedAt,
License: snap.License,
FetchedAt: snap.FetchedAt,
Source: snap.Source,
Entries: []KnownChannelEntry{},
}
for _, e := range snap.Entries {
if e.Region == region {
out.Entries = append(out.Entries, e)
}
}
return out
}
// knownChannelsCache holds the atomic snapshot pointer + config.
type knownChannelsCache struct {
ptr atomic.Pointer[KnownChannelsSnapshot]
url string
refresh time.Duration
client *http.Client
fetchCount atomic.Int64 // # successful upstream fetches
failCount atomic.Int64 // # failed fetches (fail-soft)
}
func newKnownChannelsCache(url string, refresh time.Duration) *knownChannelsCache {
if refresh <= 0 {
refresh = DefaultKnownChannelsRefresh
}
return &knownChannelsCache{
url: url,
refresh: refresh,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// load returns the current snapshot or nil if never populated.
func (c *knownChannelsCache) load() *KnownChannelsSnapshot {
return c.ptr.Load()
}
// fetchOnce performs a single upstream fetch. Updates ptr on success;
// leaves last-known snapshot in place on failure (fail-soft).
func (c *knownChannelsCache) fetchOnce(ctx context.Context) error {
if c.url == "" {
return errors.New("known channels url not configured")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
if err != nil {
c.failCount.Add(1)
return err
}
req.Header.Set("User-Agent", "CoreScope-KnownChannels/1.0 (+https://github.com/Kpa-clawbot/CoreScope)")
resp, err := c.client.Do(req)
if err != nil {
c.failCount.Add(1)
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.failCount.Add(1)
return fmt.Errorf("upstream status %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxKnownChannelsBytes))
if err != nil {
c.failCount.Add(1)
return err
}
snap, err := parseKnownChannelsJSON(body, c.url, time.Now())
if err != nil {
c.failCount.Add(1)
return err
}
c.ptr.Store(snap)
c.fetchCount.Add(1)
return nil
}
// run kicks off the background fetch loop in a new goroutine. Does an
// initial fetch (fail-soft) and then ticks every refresh interval until
// ctx is cancelled. Never blocks the caller — startup proceeds immediately
// even if the upstream is slow or unreachable.
func (c *knownChannelsCache) run(ctx context.Context) {
if c.url == "" {
return
}
go func() {
_ = c.fetchOnce(ctx) // initial fetch, fail-soft
t := time.NewTicker(c.refresh)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = c.fetchOnce(ctx)
}
}
}()
}
+236
View File
@@ -0,0 +1,236 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
)
// Canned fixture mirroring the upstream channels-by-country.json shape
// (https://raw.githubusercontent.com/marcelverdult/meshcore-channels/main/channels-by-country.json
// pinned 2026-05-24). Two countries: one with entries, one empty (to test
// the "skip empty countries" branch).
const knownChannelsFixture = `{
"generated_at": "2026-05-24T22:29:02Z",
"license": "CC0-1.0",
"countries": {
"be": [
{"channel": "#antwerpen", "description": "antwerpen"},
{"channel": "#bemesh", "description": "bemesh"}
],
"us": [
{"channel": "#bayarea", "description": "Bay Area"}
],
"ad": []
}
}`
// (a) Cache parses a canned JSON fixture into a snapshot.
func TestKnownChannelsParseFixture(t *testing.T) {
snap, err := parseKnownChannelsJSON([]byte(knownChannelsFixture), "fixture://test", time.Unix(1700000000, 0))
if err != nil {
t.Fatalf("parseKnownChannelsJSON: %v", err)
}
if snap == nil {
t.Fatal("snapshot is nil")
}
if snap.GeneratedAt != "2026-05-24T22:29:02Z" {
t.Errorf("GeneratedAt = %q, want 2026-05-24T22:29:02Z", snap.GeneratedAt)
}
if snap.License != "CC0-1.0" {
t.Errorf("License = %q, want CC0-1.0", snap.License)
}
if snap.Source != "fixture://test" {
t.Errorf("Source = %q, want fixture://test", snap.Source)
}
if got, want := len(snap.Entries), 3; got != want {
t.Fatalf("len(Entries) = %d, want %d (empty country ad must be skipped)", got, want)
}
// Spot-check one entry's region stamping.
var foundAntwerpen bool
for _, e := range snap.Entries {
if e.Channel == "#antwerpen" {
foundAntwerpen = true
if e.Region != "be" {
t.Errorf("antwerpen Region = %q, want be", e.Region)
}
}
}
if !foundAntwerpen {
t.Fatal("antwerpen entry missing from snapshot")
}
}
// (b) The route returns 200 + filtered list.
func TestKnownChannelsRouteRegionFilter(t *testing.T) {
snap, err := parseKnownChannelsJSON([]byte(knownChannelsFixture), "fixture://test", time.Now())
if err != nil {
t.Fatalf("parse: %v", err)
}
srv := &Server{
knownChannels: &knownChannelsCache{},
}
srv.knownChannels.ptr.Store(snap)
r := mux.NewRouter()
r.HandleFunc("/api/known-channels", srv.handleKnownChannels).Methods("GET")
req := httptest.NewRequest(http.MethodGet, "/api/known-channels?region=be", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp KnownChannelsSnapshot
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v; body=%s", err, w.Body.String())
}
if got := len(resp.Entries); got != 2 {
t.Fatalf("filtered entries = %d, want 2 (be has 2); got body=%s", got, w.Body.String())
}
for _, e := range resp.Entries {
if e.Region != "be" {
t.Errorf("entry %q has region %q, want be", e.Channel, e.Region)
}
if !strings.HasPrefix(e.Channel, "#") {
t.Errorf("entry channel %q missing # prefix", e.Channel)
}
}
}
// (c) Cache survives upstream 500 (fail-soft): a prior good snapshot must
// remain available after a failed refresh.
func TestKnownChannelsFailSoftOn500(t *testing.T) {
// First server: returns the fixture (success).
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(knownChannelsFixture))
}))
defer good.Close()
c := newKnownChannelsCache(good.URL, time.Hour)
if err := c.fetchOnce(context.Background()); err != nil {
t.Fatalf("initial fetchOnce: %v", err)
}
first := c.load()
if first == nil || len(first.Entries) == 0 {
t.Fatal("first snapshot must be populated")
}
// Second server: always 500.
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer bad.Close()
// Re-point the cache to the failing upstream and fetch.
c.url = bad.URL
err := c.fetchOnce(context.Background())
if err == nil {
t.Fatal("expected fetchOnce to return error on 500")
}
after := c.load()
if after == nil {
t.Fatal("snapshot wiped after failed fetch — must be fail-soft")
}
if len(after.Entries) != len(first.Entries) {
t.Errorf("snapshot entry count changed after failed fetch: was %d, now %d", len(first.Entries), len(after.Entries))
}
if c.failCount.Load() < 1 {
t.Errorf("failCount = %d, want >=1", c.failCount.Load())
}
}
// (d) Malformed JSON returns an error AND increments failCount via
// fetchOnce (the parse path lives inside fetchOnce so the metric is
// the cache-level signal operators see, not just the parser's return).
func TestKnownChannelsParseError(t *testing.T) {
// parser-level: garbage in, error out.
if _, err := parseKnownChannelsJSON([]byte("{not json"), "fixture://bad", time.Now()); err == nil {
t.Fatal("parseKnownChannelsJSON: expected error on malformed JSON")
}
// cache-level: a 200 with malformed body must bump failCount and
// leave any prior snapshot in place.
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("{not json"))
}))
defer bad.Close()
c := newKnownChannelsCache(bad.URL, time.Hour)
before := c.failCount.Load()
if err := c.fetchOnce(context.Background()); err == nil {
t.Fatal("fetchOnce: expected parse error to surface")
}
if c.failCount.Load() <= before {
t.Errorf("failCount did not increment: before=%d after=%d", before, c.failCount.Load())
}
if c.fetchCount.Load() != 0 {
t.Errorf("fetchCount = %d, want 0 (parse failed)", c.fetchCount.Load())
}
}
// (e) The handler tolerates a nil cache (the startup-window fail-soft
// guarantee): server still serves 200 + an empty entries snapshot
// rather than 500. Mirrors the production code path where the route
// is registered before — or independently of — knownChannels being
// instantiated (the OPT-IN gating leaves it nil entirely when disabled).
func TestKnownChannelsHandlerNilCache(t *testing.T) {
srv := &Server{} // knownChannels intentionally nil
r := mux.NewRouter()
r.HandleFunc("/api/known-channels", srv.handleKnownChannels).Methods("GET")
req := httptest.NewRequest(http.MethodGet, "/api/known-channels", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (nil cache must fail-soft); body=%s", w.Code, w.Body.String())
}
var resp KnownChannelsSnapshot
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v; body=%s", err, w.Body.String())
}
if resp.Entries == nil {
t.Fatal("Entries is nil, want non-nil empty slice (JSON [] not null)")
}
if len(resp.Entries) != 0 {
t.Errorf("Entries len = %d, want 0", len(resp.Entries))
}
if cc := w.Header().Get("Cache-Control"); cc == "" {
t.Errorf("Cache-Control header missing on nil-cache response")
}
}
// (f) An empty region query param ("?region=") must pass through as if
// no filter was supplied — i.e. the full snapshot is returned, NOT an
// empty list. Guards against an off-by-one in the trim+filter path.
func TestKnownChannelsRegionEmptyPassthrough(t *testing.T) {
snap, err := parseKnownChannelsJSON([]byte(knownChannelsFixture), "fixture://test", time.Now())
if err != nil {
t.Fatalf("parse: %v", err)
}
srv := &Server{knownChannels: &knownChannelsCache{}}
srv.knownChannels.ptr.Store(snap)
r := mux.NewRouter()
r.HandleFunc("/api/known-channels", srv.handleKnownChannels).Methods("GET")
req := httptest.NewRequest(http.MethodGet, "/api/known-channels?region=", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp KnownChannelsSnapshot
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v; body=%s", err, w.Body.String())
}
if got, want := len(resp.Entries), len(snap.Entries); got != want {
t.Fatalf("empty region must return unfiltered snapshot: got %d entries, want %d", got, want)
}
if cc := w.Header().Get("Cache-Control"); cc == "" {
t.Errorf("Cache-Control header missing on populated response")
}
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"net/http"
"time"
)
// handleKnownChannels — GET /api/known-channels?region=XX
//
// Returns the cached community catalogue of hashtag channels (issue #1323),
// optionally filtered to one region (ISO 3166-1 alpha-2, case-insensitive).
// Empty/missing cache returns 200 with an empty Entries list so the UI
// degrades gracefully (fail-soft). Never blocks on the upstream fetch:
// the response is served straight off an atomic snapshot pointer.
func (s *Server) handleKnownChannels(w http.ResponseWriter, r *http.Request) {
region := r.URL.Query().Get("region")
var snap *KnownChannelsSnapshot
if s.knownChannels != nil {
snap = s.knownChannels.load()
}
if snap == nil {
// Empty cache — return a well-formed empty snapshot. Short
// max-age so a slow first fetch (or disabled feature) doesn't
// freeze the UI for the whole page lifetime.
w.Header().Set("Cache-Control", "public, max-age=30")
writeJSON(w, &KnownChannelsSnapshot{
FetchedAt: time.Time{},
Source: "",
Entries: []KnownChannelEntry{},
})
return
}
// Catalogue refreshes every 24h upstream; 5 min browser cache is
// well under that and avoids hammering the endpoint when the UI
// re-renders the sidebar.
w.Header().Set("Cache-Control", "public, max-age=300")
writeJSON(w, filterSnapshotByRegion(snap, region))
}
+20
View File
@@ -367,6 +367,26 @@ func main() {
defer close(stopNeighborGraphCache)
log.Printf("[neighbor-graph-cache] background recompute enabled (interval=%s)", ngInterval)
// Known-channels catalogue cache (issue #1323). OPT-IN: an empty
// cfg.KnownChannelsURL leaves srv.knownChannels nil and starts no
// background fetch. The /api/known-channels endpoint then serves an
// empty snapshot. Operators who want the community catalogue must
// set knownChannelsUrl explicitly in config.json (see
// config.example.json for the pinned-SHA recommendation).
if cfg.KnownChannelsURL != "" {
kcRefresh := DefaultKnownChannelsRefresh
if cfg.KnownChannelsRefreshMs > 0 {
kcRefresh = time.Duration(cfg.KnownChannelsRefreshMs) * time.Millisecond
}
srv.knownChannels = newKnownChannelsCache(cfg.KnownChannelsURL, kcRefresh)
kcCtx, stopKnownChannels := context.WithCancel(context.Background())
srv.knownChannels.run(kcCtx)
defer stopKnownChannels()
log.Printf("[known-channels] background fetch enabled (url=%s, refresh=%s)", cfg.KnownChannelsURL, kcRefresh)
} else {
log.Printf("[known-channels] disabled (knownChannelsUrl unset in config)")
}
// Steady-state repeater-enrichment recomputer (issue #1262).
// Prewarms the bulk caches feeding handleNodes so the very first
// /api/nodes?limit=2000 from live.js's SPA bootstrap hits a
+5
View File
@@ -89,6 +89,10 @@ type Server struct {
// package globals) so multiple instances don't share observable
// state. Initialised lazily on first use; see node_reach.go.
reach reachState
// Known-channels catalogue cache (issue #1323). Nil until configured;
// when nil the /api/known-channels endpoint returns an empty snapshot.
knownChannels *knownChannelsCache
}
// PerfStats tracks request performance.
@@ -269,6 +273,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET")
r.HandleFunc("/api/channels/{hash}/messages", s.handleChannelMessages).Methods("GET")
r.HandleFunc("/api/channels", s.handleChannels).Methods("GET")
r.HandleFunc("/api/known-channels", s.handleKnownChannels).Methods("GET")
r.HandleFunc("/api/observers/metrics/summary", s.handleMetricsSummary).Methods("GET")
r.HandleFunc("/api/observers/{id}/metrics", s.handleObserverMetrics).Methods("GET")
r.HandleFunc("/api/observers/{id}/analytics", s.handleObserverAnalytics).Methods("GET")
+3
View File
@@ -330,6 +330,9 @@
"ttlSeconds": 30,
"_comment": "TTL for the default-shape /api/observers response cache (#1481 P0-3). Default 30s. Lower = fresher data, more SQL pressure on the 1.9M-row observations table. TTL-boundary refills are collapsed via singleflight so concurrent requests cause exactly one SQL fill. Issue #1483."
},
"knownChannelsUrl": "",
"knownChannelsRefreshMs": 86400000,
"_comment_knownChannels": "Issue #1323. OPT-IN community-maintained hashtag-channels catalogue. Empty string (default) = DISABLED: no background HTTP fetch is started and GET /api/known-channels returns an empty snapshot. To enable, set knownChannelsUrl to a pinned-SHA URL such as \"https://raw.githubusercontent.com/marcelverdult/meshcore-channels/072bc25b6fc983aa2aa7e9d399a97a5f4899ea71/channels-by-country.json\". The URL should point at the channels-by-country.json shape: {generated_at, license, countries:{cc:[{channel,description,key?}]}}. Always pin to a specific commit SHA (not the moving 'main' branch) so a hostile or compromised future upstream commit cannot be silently fetched by your deployment; periodically bump the pin after reviewing upstream diffs. Fetched in the background every knownChannelsRefreshMs (default 86400000 = 24h). Stored in-memory only — no DB, no disk cache. Surfaced over GET /api/known-channels?region=XX. Cache is fail-soft: a failed refresh keeps the last-known snapshot in place. No credentials are sent.",
"batteryThresholds": {
"lowMv": 3300,
"criticalMv": 3000,
+155
View File
@@ -1808,6 +1808,155 @@
}
// #1034 PR1: sectioned sidebar — My Channels / Network / Encrypted (N).
// #1323: Known-channels (catalogue) section — community-maintained
// hashtag-channels list fetched from /api/known-channels. Lazy: the
// first render shows a "Loading…" placeholder and kicks off the fetch;
// when it completes the section is re-rendered with the entries. The
// section is collapsed by default (persisted via localStorage). Click
// "+ Add" on a row to invoke the existing addUserChannel() path, which
// saves the key + decrypts.
var __knownChannels = null; // null = not fetched; [] = fetched empty
var __knownChannelsLoading = false; // single-flight guard
var __knownChannelsError = null;
var __knownChannelsErrorAt = 0; // ms timestamp of last error (for backoff)
var KNOWN_CHANNELS_ERROR_BACKOFF_MS = 60000;
function loadKnownChannels() {
if (__knownChannelsLoading) return;
// If we already have a snapshot (success or empty), don't refetch.
if (__knownChannels !== null && !__knownChannelsError) return;
// Sticky-error backoff: once an error has been recorded, wait
// KNOWN_CHANNELS_ERROR_BACKOFF_MS before allowing another attempt.
// This lets transient upstream failures recover without forcing a
// full page reload.
if (__knownChannelsError) {
var now = Date.now();
if (now - __knownChannelsErrorAt < KNOWN_CHANNELS_ERROR_BACKOFF_MS) return;
// Clear and retry.
__knownChannelsError = null;
__knownChannels = null;
}
__knownChannelsLoading = true;
// Note: region filter intentionally NOT applied — catalogue is small
// and the user may want to browse other regions even if they're
// viewing one. Future work: per-section region selector.
var url = '/api/known-channels';
fetch(url).then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)); })
.then(function (snap) {
__knownChannels = (snap && Array.isArray(snap.entries)) ? snap.entries : [];
__knownChannelsError = null;
__knownChannelsErrorAt = 0;
__knownChannelsLoading = false;
renderChannelList();
})
.catch(function (err) {
__knownChannelsError = String(err && err.message || err);
__knownChannelsErrorAt = Date.now();
__knownChannels = [];
__knownChannelsLoading = false;
renderChannelList();
});
}
function renderKnownChannelsSection() {
var collapsed = localStorage.getItem('ch-known-collapsed') !== 'false';
var count = (__knownChannels && Array.isArray(__knownChannels)) ? __knownChannels.length : 0;
// Lazy-render: if collapsed, emit an empty body — the rows are only
// built when the user expands (toggle handler populates in place).
// Avoids burning DOM for a 1000+ entry catalogue that's never seen.
var bodyInner = '';
if (!collapsed) {
bodyInner = renderKnownChannelsBodyInner();
}
return '' +
'<div class="ch-section ch-section-catalogue" data-section="catalogue">' +
'<button type="button" class="ch-section-header ch-section-toggle" id="chCatalogueToggle" aria-expanded="' + (collapsed ? 'false' : 'true') + '" aria-controls="chCatalogueBody">' +
'<span class="ch-section-caret" aria-hidden="true">' + (collapsed ? '▸' : '▾') + '</span>' +
' Known channels (catalogue)' + (count ? ' (' + count + ')' : '') +
'</button>' +
'<div class="ch-section-body" id="chCatalogueBody"' + (collapsed ? ' hidden' : '') + '>' +
bodyInner +
'</div>' +
'</div>';
}
// Renders the inner HTML for the catalogue body (rows or placeholder).
// Kicks off the fetch on first access. Pure HTML string — no DOM writes.
function renderKnownChannelsBodyInner() {
if (__knownChannels === null) {
setTimeout(loadKnownChannels, 0);
return '<div class="ch-section-empty">Loading catalogue…</div>';
}
if (__knownChannelsError) {
return '<div class="ch-section-empty">Catalogue unavailable</div>';
}
if (__knownChannels.length === 0) {
return '<div class="ch-section-empty">No catalogue entries</div>';
}
return __knownChannels.map(renderKnownChannelRow).join('');
}
function renderKnownChannelRow(entry) {
// entry: {channel, description, region, regionName, key?}
var chName = String(entry.channel || '').toLowerCase();
var safeName = chName.replace(/[<>&"]/g, function (c) { return ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'})[c]; });
var desc = String(entry.description || '').replace(/[<>&"]/g, function (c) { return ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'})[c]; });
var region = String(entry.region || '').toUpperCase();
return '' +
'<div class="ch-channel ch-channel-catalogue" data-known-channel="' + safeName + '">' +
'<div class="ch-channel-info">' +
'<div class="ch-channel-name">' + safeName + '</div>' +
'<div class="ch-channel-sub">' + region + (desc ? ' · ' + desc : '') + '</div>' +
'</div>' +
'<button type="button" class="ch-known-add-btn" data-action="ch-known-add" data-channel="' + safeName + '" title="Add to my channels">+ Add</button>' +
'</div>';
}
// Delegated click handler for catalogue "+ Add" buttons + toggle.
// Bound per-#chList-element (marker stored on the DOM node itself) so a
// future #chList recreation re-binds automatically instead of leaving a
// dead listener pinned to a destroyed node.
function bindKnownChannelsHandlers() {
var list = document.getElementById('chList');
if (!list) return;
if (list.dataset.knownChannelsBound === '1') return;
list.addEventListener('click', function (e) {
var t = e.target;
if (!t) return;
if (t.id === 'chCatalogueToggle' || (t.closest && t.closest('#chCatalogueToggle'))) {
// Toggle in place: flip the body's hidden attr and caret, no
// full renderChannelList() rebuild. On first expand, populate
// the body lazily so collapsed catalogues never pay DOM cost.
var wasCollapsed = localStorage.getItem('ch-known-collapsed') !== 'false';
var nowCollapsed = !wasCollapsed;
try { localStorage.setItem('ch-known-collapsed', nowCollapsed ? 'true' : 'false'); } catch (er) {}
var body = document.getElementById('chCatalogueBody');
var btn = document.getElementById('chCatalogueToggle');
if (body) {
if (nowCollapsed) {
body.setAttribute('hidden', '');
} else {
body.removeAttribute('hidden');
// Populate if empty (lazy first-expand).
if (!body.firstChild || body.innerHTML === '') {
body.innerHTML = renderKnownChannelsBodyInner();
}
}
}
if (btn) {
btn.setAttribute('aria-expanded', nowCollapsed ? 'false' : 'true');
var caret = btn.querySelector('.ch-section-caret');
if (caret) caret.textContent = nowCollapsed ? '▸' : '▾';
}
return;
}
var addBtn = (t.dataset && t.dataset.action === 'ch-known-add') ? t : (t.closest && t.closest('[data-action="ch-known-add"]'));
if (addBtn) {
e.preventDefault();
var name = addBtn.getAttribute('data-channel');
if (name && typeof addUserChannel === 'function') {
addUserChannel(name, '');
}
}
});
list.dataset.knownChannelsBound = '1';
}
function renderChannelList() {
const el = document.getElementById('chList');
if (!el) return;
@@ -1857,7 +2006,13 @@
</div>
</div>`
);
// #1323: Known channels (catalogue) section — community-maintained
// hashtag list, fetched once per page-load from /api/known-channels
// and rendered with a one-click "Add to my channels" button.
sections.push(renderKnownChannelsSection());
el.innerHTML = sections.join('');
bindKnownChannelsHandlers();
// Toggle expand/collapse for the Encrypted section.
const toggle = document.getElementById('chEncryptedToggle');