diff --git a/public/analytics.js b/public/analytics.js index aa9dbae2..499464c8 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -737,6 +737,7 @@ // ===================== CHANNELS ===================== var _channelSortState = null; var _channelData = null; + var _channelRenderGen = 0; var CHANNEL_SORT_KEY = 'meshcore-channel-sort'; function loadChannelSort() { @@ -747,6 +748,18 @@ return { col: 'lastActivity', dir: 'desc' }; } + // True when the user has explicitly chosen a sort (saved in localStorage). + // Used by the grouped analytics view to decide whether to apply its own + // default ("messages desc") instead of the global flat-list default. + function hasSavedChannelSort() { + try { + var s = localStorage.getItem(CHANNEL_SORT_KEY); + if (!s) return false; + var p = JSON.parse(s); + return !!(p && p.col && p.dir); + } catch (e) { return false; } + } + function saveChannelSort(state) { try { localStorage.setItem(CHANNEL_SORT_KEY, JSON.stringify(state)); } catch (e) {} } @@ -781,20 +794,107 @@ } function channelRowHtml(c) { + var name = c.displayName || c.name || 'Unknown'; return '' + - '' + esc(c.name || 'Unknown') + '' + + '' + esc(name) + '' + '' + (typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash) + '' + '' + c.messages + '' + '' + c.senders + '' + '' + timeAgo(c.lastActivity) + '' + - '' + (c.encrypted ? '🔒' : '✅') + '' + + '' + (c.encrypted ? (c.group === 'mine' ? '🔑' : '🔒') : '✅') + '' + ''; } - function channelTbodyHtml(channels, col, dir) { + // ── PSK-aware decoration ────────────────────────────────────────────────── + // Server returns raw "chNNN" placeholder names for encrypted channels it + // doesn't know. Decorate so the UI shows a useful display name and a + // group bucket: mine / network / encrypted. Pure function for testability. + function decorateAnalyticsChannels(channels, hashByteToKeyName, labels) { + var keyMap = hashByteToKeyName || {}; + var lab = labels || {}; + var out = []; + for (var i = 0; i < (channels || []).length; i++) { + var c = channels[i]; + var copy = Object.assign({}, c); + var hashNum = typeof c.hash === 'number' ? c.hash : parseInt(c.hash, 10); + var rawName = String(c.name || ''); + var isPlaceholder = /^ch(\d+|\?)$/.test(rawName); + if (c.encrypted) { + var keyName = !isNaN(hashNum) ? keyMap[hashNum] : null; + if (keyName) { + copy.displayName = lab[keyName] || keyName; + copy.group = 'mine'; + } else if (isPlaceholder || !rawName) { + // Placeholder ("chNNN") or empty name → render as opaque encrypted. + // Empty-name encrypted rows would otherwise leak through with an + // empty in the row; force the placeholder rendering. + copy.displayName = !isNaN(hashNum) + ? '🔒 Encrypted (0x' + hashNum.toString(16).toUpperCase().padStart(2, '0') + ')' + : '🔒 Encrypted'; + copy.group = 'encrypted'; + } else { + // Server gave us a real name (rainbow table hit) for an encrypted ch. + copy.displayName = rawName; + copy.group = 'network'; + } + } else { + copy.displayName = rawName || 'Unknown'; + copy.group = 'network'; + } + out.push(copy); + } + return out; + } + + // Build the (hash byte → key name) map from ChannelDecrypt's stored keys. + // Async because computeChannelHash uses subtle.digest. Returns {} if the + // module or its keys are unavailable (graceful fallback). + async function buildHashKeyMap() { + if (typeof ChannelDecrypt === 'undefined' || !ChannelDecrypt.getStoredKeys) return {}; + var keys = ChannelDecrypt.getStoredKeys(); + var map = {}; + var names = Object.keys(keys || {}); + for (var ni = 0; ni < names.length; ni++) { + var name = names[ni]; + try { + var bytes = ChannelDecrypt.hexToBytes(keys[name]); + var hb = await ChannelDecrypt.computeChannelHash(bytes); + if (typeof hb === 'number') map[hb] = name; + } catch (e) { /* skip bad key */ } + } + return map; + } + + function channelTbodyHtml(channels, col, dir, opts) { var sorted = sortChannels(channels, col, dir); var parts = []; - for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i])); + if (opts && opts.grouped) { + // Group by .group: mine → network → encrypted. Inside each group keep + // the active sort (caller passes col/dir; for the integration we sort + // by messages desc by default). + var groups = { mine: [], network: [], encrypted: [] }; + for (var gi = 0; gi < sorted.length; gi++) { + var g = sorted[gi].group || (sorted[gi].encrypted ? 'encrypted' : 'network'); + (groups[g] || (groups[g] = [])).push(sorted[gi]); + } + var sections = [ + { key: 'mine', label: '🔑 My Channels' }, + { key: 'network', label: '📻 Network' }, + { key: 'encrypted', label: '🔒 Encrypted' }, + ]; + for (var si = 0; si < sections.length; si++) { + var rows = groups[sections[si].key] || []; + if (!rows.length) continue; + parts.push( + '' + + esc(sections[si].label) + ' (' + rows.length + ')' + + '' + ); + for (var ri = 0; ri < rows.length; ri++) parts.push(channelRowHtml(rows[ri])); + } + } else { + for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i])); + } return parts.join(''); } @@ -825,13 +925,39 @@ var tbody = document.getElementById('channelsTbody'); var thead = document.querySelector('#channelsTable thead'); if (!tbody || !_channelData) return; - tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir); + tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true }); if (thead) thead.outerHTML = channelTheadHtml(_channelSortState.col, _channelSortState.dir); } function renderChannels(el, ch) { - _channelData = ch.channels; - if (!_channelSortState) _channelSortState = loadChannelSort(); + // Decorate first so grouping/display name reflect locally-stored PSK keys. + // buildHashKeyMap is async; render once with a sync best-effort empty map, + // then upgrade once keys resolve. That keeps first paint fast and avoids + // blocking on subtle.digest in environments where it's slow. + var rawChannels = ch.channels || []; + // Resolve the persisted sort first so the default-fallback below doesn't + // shadow what the user previously chose. Default for the grouped view is + // messages desc (matches the PR description); only used when nothing saved. + if (!_channelSortState) { + _channelSortState = hasSavedChannelSort() + ? loadChannelSort() + : { col: 'messages', dir: 'desc' }; + } + var ranOnce = false; + // Generation token: if renderChannels is called again before + // buildHashKeyMap() resolves, the older promise must not clobber the + // newer rawChannels / decoration with stale-key data. + var myGen = ++_channelRenderGen; + function applyDecorate(map) { + if (myGen !== _channelRenderGen) return; // superseded + var labels = (typeof ChannelDecrypt !== 'undefined' && ChannelDecrypt.getLabels) + ? ChannelDecrypt.getLabels() : {}; + _channelData = decorateAnalyticsChannels(rawChannels, map, labels); + if (ranOnce) updateChannelTable(); + } + applyDecorate({}); + ranOnce = true; + buildHashKeyMap().then(applyDecorate).catch(function () { /* graceful */ }); var timelineHtml = renderChannelTimeline(ch.channelTimeline); var topSendersHtml = renderTopSenders(ch.topSenders); @@ -844,7 +970,7 @@ '' + channelTheadHtml(_channelSortState.col, _channelSortState.dir) + '' + - channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir) + + channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true }) + '' + '
' + '' + @@ -2055,6 +2181,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ // Expose for testing if (typeof window !== 'undefined') { + window._analyticsDecorateChannels = decorateAnalyticsChannels; window._analyticsSortChannels = sortChannels; window._analyticsLoadChannelSort = loadChannelSort; window._analyticsSaveChannelSort = saveChannelSort; diff --git a/public/channels.js b/public/channels.js index 73769a68..f4f365cf 100644 --- a/public/channels.js +++ b/public/channels.js @@ -634,6 +634,8 @@ + 📊 Channel Analytics →
diff --git a/public/style.css b/public/style.css index a3e3673c..ff6b11a2 100644 --- a/public/style.css +++ b/public/style.css @@ -1204,6 +1204,15 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } .ch-add-btn:hover { opacity: 0.85; } .ch-add-hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; line-height: 1.3; } .ch-add-status { font-size: 12px; margin-top: 4px; padding: 4px 6px; border-radius: 4px; } +.ch-analytics-link { + display: block; + padding: 6px 8px; + font-size: 12px; + text-decoration: none; + color: var(--text-muted); + border-bottom: 1px solid var(--border); +} +.ch-analytics-link:hover { color: var(--accent); } .ch-add-status--loading { color: var(--text-muted); } .ch-add-status--success { color: var(--success, #22c55e); } .ch-add-status--warn { color: var(--warning, #eab308); } @@ -1273,6 +1282,18 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } .analytics-table th.sort-active { color: var(--accent); } .analytics-table th .sort-arrow { font-size: 10px; margin-left: 4px; opacity: 0.7; } .analytics-table td { padding: 8px; border-bottom: 1px solid var(--border); } +.analytics-table .ch-section-row { background: var(--card-bg); } +.analytics-table .ch-section-row td.ch-section-header { + font-weight: 600; + font-size: 12px; + letter-spacing: 0.04em; + color: var(--text-muted); + text-transform: uppercase; + padding: 10px 8px 6px; + border-bottom: 1px solid var(--border); + background: var(--card-bg); +} +.analytics-table .ch-section-row:hover { background: var(--card-bg); cursor: default; } .hash-bars { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; } .hash-bar-row { display: flex; align-items: center; gap: 12px; } .hash-bar-label { min-width: 160px; font-size: 13px; } diff --git a/test-all.sh b/test-all.sh index 4c8a70ca..4c079541 100755 --- a/test-all.sh +++ b/test-all.sh @@ -18,6 +18,7 @@ node test-channel-sidebar-layout.js node test-channel-modal-ux.js node test-channel-decrypt-insecure-context.js node test-channel-qr.js +node test-analytics-channels-integration.js echo "" echo "═══════════════════════════════════════" diff --git a/test-analytics-channels-integration.js b/test-analytics-channels-integration.js new file mode 100644 index 00000000..abaac324 --- /dev/null +++ b/test-analytics-channels-integration.js @@ -0,0 +1,188 @@ +/** + * Analytics → Channels section integration with PSK decrypt UX. + * + * Bug: the analytics channels list shows nonsense names like "ch185" for + * every encrypted channel and ignores the user's locally-decrypted PSK + * channels (from ChannelDecrypt.getStoredKeys() + label store). + * + * Fix: + * 1. Replace "chNNN" raw names with "🔒 Encrypted (0xNN)" when the channel + * is encrypted and the server only knows its hash byte. + * 2. For channels matching a locally-stored PSK key, show the user's + * label / key-name instead of the hash-byte placeholder. + * 3. Group rendering: My Channels → Network → Encrypted, each sorted by + * message count descending. + * 4. Add a link from the Channels page to the Analytics page so users can + * jump to channel activity stats. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' ✓ ' + msg); } + else { failed++; console.error(' ✗ ' + msg); } +} + +// ── Set up a tiny browser-ish global so analytics.js loads cleanly ────────── +global.window = global; +global.document = { + documentElement: {}, + createElement: () => ({ style: {}, addEventListener() {} }), + addEventListener() {}, + removeEventListener() {}, + querySelector: () => null, + querySelectorAll: () => [], + getElementById: () => null, +}; +global.localStorage = { + _s: {}, + getItem(k) { return this._s[k] || null; }, + setItem(k, v) { this._s[k] = String(v); }, + removeItem(k) { delete this._s[k]; }, +}; +global.getComputedStyle = () => ({ getPropertyValue: () => '' }); +global.registerPage = () => {}; +global.api = async () => ({}); +global.fetch = async () => ({ ok: true, json: async () => ({}) }); +global.CLIENT_TTL = {}; +global.RegionFilter = { getRegionParam: () => '' }; +global.Storage = function () {}; +global.timeAgo = () => ''; +global.histogram = () => ({ svg: '' }); + +// Load analytics.js — it self-registers global helpers we test. +const analyticsSrc = fs.readFileSync( + path.join(__dirname, 'public/analytics.js'), + 'utf8' +); +// Strip top-level `await` / module syntax — analytics.js is plain IIFE so it's +// fine to eval as-is. +// eslint-disable-next-line no-eval +eval(analyticsSrc); // sets window._analyticsDecorateChannels etc. + +console.log('\n=== Analytics channels: decorate with PSK keys ==='); + +const decorate = global._analyticsDecorateChannels; +assert(typeof decorate === 'function', + '_analyticsDecorateChannels exposed for testing'); + +// Server response sample — mix of cleartext, rainbow-known encrypted, raw "chNNN". +const sampleChannels = [ + { hash: 17, name: 'public', messages: 100, senders: 5, encrypted: false }, + { hash: 217, name: '#test', messages: 200, senders: 8, encrypted: false }, + { hash: 185, name: 'ch185', messages: 50, senders: 0, encrypted: true }, + { hash: 64, name: 'ch64', messages: 300, senders: 0, encrypted: true }, + { hash: 30, name: 'ch30', messages: 75, senders: 0, encrypted: true }, + { hash: 99, name: '#earthquake', messages: 10, senders: 1, encrypted: false }, + // Rainbow-table hit on an ENCRYPTED channel: server resolved a real name. + { hash: 12, name: 'public-meshcore', messages: 40, senders: 2, encrypted: true }, + // Encrypted channel with empty name — must not render an empty . + { hash: 200, name: '', messages: 5, senders: 0, encrypted: true }, +]; + +// User has two PSK keys locally: one matches hash=185 (named "Levski"), +// one matches hash=30 (named "secret-room", with label "Garage"). +const myKeyHashToName = { 185: 'Levski', 30: 'secret-room' }; +const labels = { 'secret-room': 'Garage' }; + +const out = decorate(sampleChannels, myKeyHashToName, labels); +assert(Array.isArray(out), 'decorate returns an array'); +assert(out.length === sampleChannels.length, 'decorate keeps every channel'); + +// Find by original hash (and optionally original name) for assertions. +// Decoration preserves c.name as-is and writes the user-facing string to +// c.displayName, so matching on c.name is unambiguous. +function find(hash, name) { + return out.find(c => c.hash === hash && (name == null || c.name === name)); +} + +const mine185 = find(185, 'ch185'); +assert(mine185 && mine185.displayName === 'Levski', + 'hash 185 + stored key → displayName = "Levski" (not "ch185")'); +assert(mine185 && mine185.group === 'mine', + 'hash 185 grouped as "mine"'); + +const mine30 = find(30, 'ch30'); +assert(mine30 && mine30.displayName === 'Garage', + 'hash 30 with stored key + label → displayName = "Garage" (label wins)'); +assert(mine30 && mine30.group === 'mine', 'hash 30 grouped as "mine"'); + +const ch64 = find(64, 'ch64'); +assert(ch64 && ch64.displayName === '🔒 Encrypted (0x40)', + 'unknown encrypted ch64 → "🔒 Encrypted (0x40)" (no nonsense "ch64")'); +assert(ch64 && ch64.group === 'encrypted', 'unknown encrypted grouped as "encrypted"'); + +const pub = find(17, 'public'); +assert(pub && pub.displayName === 'public', 'cleartext public name preserved'); +assert(pub && pub.group === 'network', 'cleartext public grouped as "network"'); + +const test = find(217, '#test'); +assert(test && test.group === 'network', 'rainbow-known #test grouped as "network"'); + +// Rainbow-table hit on an ENCRYPTED channel — actually exercises the +// "encrypted but server has the real name" branch (was previously dead-untested). +const rainbow = find(12, 'public-meshcore'); +assert(rainbow && rainbow.encrypted === true, + 'rainbow row preserves encrypted=true'); +assert(rainbow && rainbow.displayName === 'public-meshcore', + 'rainbow-decoded encrypted row → displayName = real name'); +assert(rainbow && rainbow.group === 'network', + 'rainbow-decoded encrypted row → group = "network"'); + +// Empty-name encrypted: must NOT leak through with displayName = ''. +const empty = find(200, ''); +assert(empty && empty.displayName === '🔒 Encrypted (0xC8)', + 'encrypted with empty name → render as opaque encrypted placeholder'); +assert(empty && empty.group === 'encrypted', + 'encrypted with empty name → group = "encrypted"'); + +// No "chNNN" leaks into displayName for any row. +const leak = out.find(c => /^ch(\d+|\?)$/.test(c.displayName)); +assert(!leak, 'no displayName matches the raw chNNN placeholder'); + +console.log('\n=== Grouped table render: order + sort ==='); + +const tbody = global._analyticsChannelTbodyHtml(out, 'messages', 'desc', { + grouped: true, +}); +assert(typeof tbody === 'string' && tbody.length > 0, + 'channelTbodyHtml accepts grouped option and returns html'); + +// Group headers must appear in order: My Channels, Network, Encrypted. +const iMine = tbody.indexOf('My Channels'); +const iNet = tbody.indexOf('Network'); +const iEnc = tbody.indexOf('Encrypted'); +assert(iMine >= 0 && iNet > iMine && iEnc > iNet, + 'group headers render in order: My Channels → Network → Encrypted'); + +// Within "mine" section, hash=30 (75 msgs) > hash=185 (50 msgs). +const i30 = tbody.indexOf('Garage'); +const i185 = tbody.indexOf('Levski'); +assert(i30 > 0 && i185 > i30, + 'within "My Channels" sort by messages desc (Garage 75 before Levski 50)'); + +// Within "network" section, #test (200) > public (100) > #earthquake (10). +const iT = tbody.indexOf('#test'); +const iP = tbody.indexOf('public'); +const iE = tbody.indexOf('#earthquake'); +assert(iT > 0 && iP > iT && iE > iP, + 'within "Network" sort by messages desc (#test → public → #earthquake)'); + +// Within "encrypted" section, ch64 (300 msgs) appears (only one entry). +assert(tbody.indexOf('0x40') > iEnc, 'encrypted section contains 0x40'); + +console.log('\n=== Channels page links to Analytics ==='); + +const channelsSrc = fs.readFileSync( + path.join(__dirname, 'public/channels.js'), + 'utf8' +); +assert(/#\/analytics/.test(channelsSrc) && + /Channel Analytics|channel analytics/i.test(channelsSrc), + 'channels.js sidebar links to #/analytics with "Channel Analytics" text'); + +console.log('\n' + (failed ? '✗ ' + failed + ' failed, ' : '') + passed + ' passed'); +process.exit(failed ? 1 : 0);