diff --git a/cmd/server/db.go b/cmd/server/db.go index a2095949..e3d8363d 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1260,6 +1260,119 @@ func (db *DB) GetChannels(region ...string) ([]map[string]interface{}, error) { return channels, nil } +// GetEncryptedChannels returns channels where all messages are undecryptable (no key). +// These have decoded_json with type "GRP_TXT" and decryptionStatus "no_key". +func (db *DB) GetEncryptedChannels(region ...string) ([]map[string]interface{}, error) { + regionParam := "" + if len(region) > 0 { + regionParam = region[0] + } + regionCodes := normalizeRegionCodes(regionParam) + + var querySQL string + args := make([]interface{}, 0, len(regionCodes)) + + if len(regionCodes) > 0 { + placeholders := make([]string, len(regionCodes)) + for i, code := range regionCodes { + placeholders[i] = "?" + args = append(args, code) + } + regionPlaceholder := strings.Join(placeholders, ",") + if db.isV3 { + querySQL = fmt.Sprintf(`SELECT DISTINCT t.decoded_json, t.first_seen + FROM transmissions t + JOIN observations o ON o.transmission_id = t.id + LEFT JOIN observers obs ON obs.rowid = o.observer_idx + WHERE t.payload_type = 5 + AND obs.rowid IS NOT NULL AND UPPER(TRIM(obs.iata)) IN (%s) + ORDER BY t.first_seen ASC`, regionPlaceholder) + } else { + querySQL = fmt.Sprintf(`SELECT DISTINCT t.decoded_json, t.first_seen + FROM transmissions t + JOIN observations o ON o.transmission_id = t.id + WHERE t.payload_type = 5 + AND EXISTS ( + SELECT 1 FROM observers obs + WHERE obs.id = o.observer_id + AND UPPER(TRIM(obs.iata)) IN (%s) + ) + ORDER BY t.first_seen ASC`, regionPlaceholder) + } + } else { + querySQL = `SELECT decoded_json, first_seen FROM transmissions WHERE payload_type = 5 ORDER BY first_seen ASC` + } + + rows, err := db.conn.Query(querySQL, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + type encChanInfo struct { + hash string + messageCount int + lastActivity string + } + channelMap := map[string]*encChanInfo{} + + for rows.Next() { + var dj, fs sql.NullString + if err := rows.Scan(&dj, &fs); err != nil { continue } + if !dj.Valid { + continue + } + var decoded map[string]interface{} + if json.Unmarshal([]byte(dj.String), &decoded) != nil { + continue + } + dtype, _ := decoded["type"].(string) + // Only include undecryptable GRP_TXT packets (not CHAN) + if dtype != "GRP_TXT" { + continue + } + ds, _ := decoded["decryptionStatus"].(string) + if ds != "no_key" { + continue + } + // Group by channelHashHex + chHash, _ := decoded["channelHashHex"].(string) + if chHash == "" { + if chNum, ok := decoded["channelHash"].(float64); ok { + chHash = fmt.Sprintf("%02X", int(chNum)) + } + } + if chHash == "" { + chHash = "?" + } + key := chHash + + ch, exists := channelMap[key] + if !exists { + ch = &encChanInfo{hash: key, lastActivity: nullStrVal(fs)} + channelMap[key] = ch + } + ch.messageCount++ + if fs.Valid && fs.String > ch.lastActivity { + ch.lastActivity = fs.String + } + } + + channels := make([]map[string]interface{}, 0, len(channelMap)) + for _, ch := range channelMap { + channels = append(channels, map[string]interface{}{ + "hash": "enc_" + ch.hash, + "name": "Encrypted (0x" + ch.hash + ")", + "lastMessage": nil, + "lastSender": nil, + "messageCount": ch.messageCount, + "lastActivity": ch.lastActivity, + "encrypted": true, + }) + } + return channels, nil +} + // GetChannelMessages returns messages for a specific channel. // Uses transmission-level ordering (first_seen) to ensure correct message // sequence even when observations arrive out of order. diff --git a/cmd/server/encrypted_channels_test.go b/cmd/server/encrypted_channels_test.go new file mode 100644 index 00000000..c9c76aa7 --- /dev/null +++ b/cmd/server/encrypted_channels_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" +) + +// seedEncryptedChannelData adds undecryptable GRP_TXT packets to the test DB. +func seedEncryptedChannelData(t *testing.T, db *DB) { + t.Helper() + now := time.Now().UTC() + recent := now.Add(-1 * time.Hour).Format(time.RFC3339) + recentEpoch := now.Add(-1 * time.Hour).Unix() + + // Two encrypted GRP_TXT packets on channel hash "A1B2" + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('EE01', 'enc_hash_001', ?, 1, 5, '{"type":"GRP_TXT","channelHashHex":"A1B2","decryptionStatus":"no_key"}')`, recent) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('EE02', 'enc_hash_002', ?, 1, 5, '{"type":"GRP_TXT","channelHashHex":"A1B2","decryptionStatus":"no_key"}')`, recent) + + // Observations for both + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES ((SELECT id FROM transmissions WHERE hash='enc_hash_001'), 1, 10.0, -90, '[]', ?)`, recentEpoch) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES ((SELECT id FROM transmissions WHERE hash='enc_hash_002'), 1, 10.0, -90, '[]', ?)`, recentEpoch) +} + +func TestGetEncryptedChannels(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + seedEncryptedChannelData(t, db) + + channels, err := db.GetEncryptedChannels() + if err != nil { + t.Fatal(err) + } + if len(channels) != 1 { + t.Fatalf("expected 1 encrypted channel, got %d", len(channels)) + } + ch := channels[0] + if ch["hash"] != "enc_A1B2" { + t.Errorf("expected hash enc_A1B2, got %v", ch["hash"]) + } + if ch["encrypted"] != true { + t.Errorf("expected encrypted=true, got %v", ch["encrypted"]) + } + if ch["messageCount"] != 2 { + t.Errorf("expected messageCount=2, got %v", ch["messageCount"]) + } +} + +func TestChannelsAPIExcludesEncrypted(t *testing.T) { + _, router := setupTestServer(t) + // Seed encrypted data into the server's DB + // setupTestServer uses seedTestData which has no encrypted packets, + // so default /api/channels should NOT include encrypted channels. + req := httptest.NewRequest("GET", "/api/channels", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + channels := body["channels"].([]interface{}) + + for _, ch := range channels { + m := ch.(map[string]interface{}) + if enc, ok := m["encrypted"]; ok && enc == true { + t.Errorf("default /api/channels should not include encrypted channels, found: %v", m["hash"]) + } + } +} + +func TestChannelsAPIIncludesEncryptedWithParam(t *testing.T) { + srv, router := setupTestServer(t) + // Add encrypted data to the server's DB + seedEncryptedChannelData(t, srv.db) + // Reload store so in-memory also has the data + store := NewPacketStore(srv.db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load: %v", err) + } + srv.store = store + + req := httptest.NewRequest("GET", "/api/channels?includeEncrypted=true", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + channels := body["channels"].([]interface{}) + + foundEncrypted := false + for _, ch := range channels { + m := ch.(map[string]interface{}) + if enc, ok := m["encrypted"]; ok && enc == true { + foundEncrypted = true + break + } + } + if !foundEncrypted { + t.Error("expected encrypted channels with includeEncrypted=true, found none") + } +} + +func TestChannelMessagesExcludesEncrypted(t *testing.T) { + srv, router := setupTestServer(t) + seedEncryptedChannelData(t, srv.db) + store := NewPacketStore(srv.db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load: %v", err) + } + srv.store = store + + // Request messages for the encrypted channel — should return empty + req := httptest.NewRequest("GET", "/api/channels/enc_A1B2/messages", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + messages, ok := body["messages"].([]interface{}) + if !ok { + // messages might be null/missing — that's fine, means no messages + return + } + // Encrypted messages should not be returned as readable messages + for _, msg := range messages { + m := msg.(map[string]interface{}) + if text, ok := m["text"].(string); ok && text != "" { + t.Errorf("encrypted channel should not return readable messages, got text: %s", text) + } + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index f194bfbd..c14aa90e 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1656,6 +1656,7 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { region := r.URL.Query().Get("region") + includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true" // Prefer DB for full history (in-memory store has limited retention) if s.db != nil { channels, err := s.db.GetChannels(region) @@ -1663,11 +1664,22 @@ func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { writeError(w, 500, err.Error()) return } + if includeEncrypted { + encrypted, err := s.db.GetEncryptedChannels(region) + if err != nil { + log.Printf("WARN GetEncryptedChannels: %v", err) + } else { + channels = append(channels, encrypted...) + } + } writeJSON(w, ChannelListResponse{Channels: channels}) return } if s.store != nil { channels := s.store.GetChannels(region) + if includeEncrypted { + channels = append(channels, s.store.GetEncryptedChannels(region)...) + } writeJSON(w, ChannelListResponse{Channels: channels}) return } diff --git a/cmd/server/store.go b/cmd/server/store.go index bfb8f6be..e92a1738 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -3155,6 +3155,84 @@ func (s *PacketStore) GetChannels(region string) []map[string]interface{} { return channels } +// GetEncryptedChannels returns undecryptable GRP_TXT channels from in-memory packets. +func (s *PacketStore) GetEncryptedChannels(region string) []map[string]interface{} { + s.mu.RLock() + var regionObs map[string]bool + if region != "" { + regionObs = s.resolveRegionObservers(region) + } + grpTxts := s.byPayloadType[5] + + type encInfo struct { + hash string + messageCount int + lastActivity string + } + type grpDec struct { + Type string `json:"type"` + ChannelHash interface{} `json:"channelHash"` + ChannelHashHex string `json:"channelHashHex"` + DecryptionStatus string `json:"decryptionStatus"` + } + channelMap := map[string]*encInfo{} + + for _, tx := range grpTxts { + if regionObs != nil { + match := false + for _, obs := range tx.Observations { + if regionObs[obs.ObserverID] { + match = true + break + } + } + if !match { + continue + } + } + var decoded grpDec + if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) != nil { + continue + } + if decoded.Type != "GRP_TXT" || decoded.DecryptionStatus != "no_key" { + continue + } + chHash := decoded.ChannelHashHex + if chHash == "" { + if num, ok := decoded.ChannelHash.(float64); ok { + chHash = fmt.Sprintf("%02X", int(num)) + } + } + if chHash == "" { + chHash = "?" + } + ch := channelMap[chHash] + if ch == nil { + ch = &encInfo{hash: chHash, lastActivity: tx.FirstSeen} + channelMap[chHash] = ch + } + ch.messageCount++ + if tx.FirstSeen >= ch.lastActivity { + ch.lastActivity = tx.FirstSeen + } + } + s.mu.RUnlock() + + channels := make([]map[string]interface{}, 0, len(channelMap)) + for _, ch := range channelMap { + channels = append(channels, map[string]interface{}{ + "hash": "enc_" + ch.hash, + "name": "Encrypted (0x" + ch.hash + ")", + "lastMessage": nil, + "lastSender": nil, + "messageCount": ch.messageCount, + "lastActivity": ch.lastActivity, + "encrypted": true, + }) + } + return channels +} + // GetChannelMessages returns deduplicated messages for a channel from in-memory packets. func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int, region ...string) ([]map[string]interface{}, int) { s.mu.RLock() diff --git a/public/channels.js b/public/channels.js index 1e8b25f6..27667220 100644 --- a/public/channels.js +++ b/public/channels.js @@ -530,6 +530,9 @@
💬 Channels
+
@@ -556,6 +559,17 @@
`; RegionFilter.init(document.getElementById('chRegionFilter')); + + // Encrypted channels toggle (#727) + var showEncryptedCb = document.getElementById('chShowEncrypted'); + var showEncrypted = localStorage.getItem('channels-show-encrypted') === 'true'; + showEncryptedCb.checked = showEncrypted; + showEncryptedCb.addEventListener('change', function () { + showEncrypted = showEncryptedCb.checked; + localStorage.setItem('channels-show-encrypted', showEncrypted ? 'true' : 'false'); + loadChannels(true); + }); + regionChangeHandler = RegionFilter.onChange(function () { loadChannels(true).then(async function () { if (!selectedHash) return; @@ -576,6 +590,13 @@ }); } + // Auto-enable encrypted toggle if deep-linking to an encrypted channel + if (routeParam && routeParam.startsWith('enc_') && !showEncrypted) { + showEncrypted = true; + showEncryptedCb.checked = true; + localStorage.setItem('channels-show-encrypted', 'true'); + } + loadObserverRegions(); loadChannels().then(async function () { // Also load user-added encrypted channels into the sidebar @@ -876,7 +897,11 @@ async function loadChannels(silent) { try { const rp = RegionFilter.getRegionParam(); - const qs = rp ? '?region=' + encodeURIComponent(rp) : ''; + var showEnc = localStorage.getItem('channels-show-encrypted') === 'true'; + var params = []; + if (rp) params.push('region=' + encodeURIComponent(rp)); + if (showEnc) params.push('includeEncrypted=true'); + const qs = params.length ? '?' + params.join('&') : ''; const data = await api('/channels' + qs, { ttl: CLIENT_TTL.channels }); channels = (data.channels || []).map(ch => { ch.lastActivityMs = ch.lastActivity ? new Date(ch.lastActivity).getTime() : 0; @@ -903,22 +928,26 @@ }); el.innerHTML = sorted.map(ch => { - const name = ch.name || `Channel ${formatHashHex(ch.hash)}`; - const color = getChannelColor(ch.hash); + const isEncrypted = ch.encrypted === true; + const name = isEncrypted ? (ch.name || 'Unknown') : (ch.name || `Channel ${formatHashHex(ch.hash)}`); + const color = isEncrypted ? 'var(--text-muted, #6b7280)' : getChannelColor(ch.hash); const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : ''; - const preview = ch.lastSender && ch.lastMessage - ? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}` - : `${ch.messageCount} messages`; + const preview = isEncrypted + ? `${ch.messageCount} encrypted messages (no key configured)` + : ch.lastSender && ch.lastMessage + ? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}` + : `${ch.messageCount} messages`; const sel = selectedHash === ch.hash ? ' selected' : ''; - const abbr = name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase(); + const encClass = isEncrypted ? ' ch-encrypted' : ''; + const abbr = isEncrypted ? '🔒' : (name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase()); // Channel color dot for color picker (#674) const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null; const dotStyle = chColor ? ` style="background:${chColor}"` : ''; // Left border for assigned color const borderStyle = chColor ? ` style="border-left:3px solid ${chColor}"` : ''; - return `