mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-28 07:01:55 +00:00
e04c7113cb
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>
225 lines
7.2 KiB
Go
225 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}()
|
|
}
|