diff --git a/server/routes/dxpeditions.js b/server/routes/dxpeditions.js index d99beb57..e9df4430 100644 --- a/server/routes/dxpeditions.js +++ b/server/routes/dxpeditions.js @@ -36,14 +36,17 @@ module.exports = function (app, ctx) { let prev; do { prev = text; - text = text.replace(/]*>[\s\S]*?<\/script>/gi, ''); + text = text.replace(/]*>[\s\S]*?<\/script(?:\s[^>]*)?>/gi, ''); } while (text !== prev); do { prev = text; - text = text.replace(/]*>[\s\S]*?<\/style>/gi, ''); + text = text.replace(/]*>[\s\S]*?<\/style(?:\s[^>]*)?>/gi, ''); } while (text !== prev); // Strip any remaining opening script/style tags (malformed HTML) - text = text.replace(/]*>/gi, '').replace(/]*>/gi, ''); + do { + prev = text; + text = text.replace(/]*>/gi, '').replace(/]*>/gi, ''); + } while (text !== prev); text = text .replace(//gi, '\n') // Convert br to newlines .replace(/<[^>]+>/g, ' ') // Remove all HTML tags diff --git a/server/routes/meshtastic.js b/server/routes/meshtastic.js index 72c71786..f01457fb 100644 --- a/server/routes/meshtastic.js +++ b/server/routes/meshtastic.js @@ -2,10 +2,13 @@ * Meshtastic Routes * Three connection modes: * 1. "direct" — Browser connects to device on LAN (no server involvement) - * 2. "mqtt" — Server subscribes to Meshtastic MQTT broker for remote access - * 3. "proxy" — Server proxies to device HTTP API on the local network + * 2. "mqtt" — Server subscribes to Meshtastic MQTT broker (per-user sessions) + * 3. "proxy" — Server proxies to device HTTP API on the local network (shared) * - * Config persists to data/meshtastic-config.json or via .env. + * MQTT mode is per-user: each browser gets its own MQTT connection via a session ID. + * Proxy and direct modes are shared/global since they use the server's local device. + * + * Config persists to data/meshtastic-config.json or via .env (proxy/direct only). */ const fs = require('fs'); const path = require('path'); @@ -20,6 +23,9 @@ module.exports = function meshtasticRoutes(app, ctx) { const CONFIG_FILE = path.join(ROOT_DIR, 'data', 'meshtastic-config.json'); const MIN_POLL_MS = 5000; const MAX_POLL_MS = 5 * 60 * 1000; + const MAX_MQTT_SESSIONS = 20; + const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes idle before cleanup + const SESSION_ID_REGEX = /^[a-zA-Z0-9_-]{8,64}$/; // ── SSRF protection for proxy mode ── function validateDeviceHost(raw) { @@ -60,7 +66,7 @@ module.exports = function meshtasticRoutes(app, ctx) { return Math.min(Math.max(MIN_POLL_MS, p), MAX_POLL_MS); } - // ── Config persistence ── + // ── Config persistence (for proxy/direct — shared modes) ── function loadConfig() { if (process.env.MESHTASTIC_ENABLED === 'true') { const envHost = process.env.MESHTASTIC_HOST || 'http://meshtastic.local'; @@ -69,10 +75,6 @@ module.exports = function meshtasticRoutes(app, ctx) { mode: process.env.MESHTASTIC_MODE || 'proxy', enabled: true, host: validated.ok ? validated.host : envHost.replace(/\/+$/, ''), - mqttBroker: process.env.MESHTASTIC_MQTT_BROKER || '', - mqttTopic: process.env.MESHTASTIC_MQTT_TOPIC || 'msh/US/#', - mqttUsername: process.env.MESHTASTIC_MQTT_USERNAME || '', - mqttPassword: process.env.MESHTASTIC_MQTT_PASSWORD || '', pollMs: clampPollMs(process.env.MESHTASTIC_POLL_MS || '10000'), source: 'env', }; @@ -89,6 +91,10 @@ module.exports = function meshtasticRoutes(app, ctx) { } data.host = validated.host; } + // Don't restore MQTT from saved config — MQTT is per-user now + if (data.mode === 'mqtt') { + return { mode: '', enabled: false, host: '', pollMs: 10000, source: 'none' }; + } return { ...data, pollMs: clampPollMs(data.pollMs), source: 'saved' }; } return { ...data, source: 'saved' }; @@ -96,14 +102,16 @@ module.exports = function meshtasticRoutes(app, ctx) { } catch (e) { logWarn(`[Meshtastic] Failed to load config: ${e.message}`); } - return { mode: '', enabled: false, host: '', mqttBroker: '', mqttTopic: 'msh/US/#', pollMs: 10000, source: 'none' }; + return { mode: '', enabled: false, host: '', pollMs: 10000, source: 'none' }; } function saveConfig(cfg) { try { const dir = path.dirname(CONFIG_FILE); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2)); + // Strip MQTT fields from persisted config — MQTT is per-user + const { mqttBroker, mqttTopic, mqttUsername, mqttPassword, ...saveable } = cfg; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(saveable, null, 2)); return true; } catch (e) { logWarn(`[Meshtastic] Failed to save config: ${e.message}`); @@ -113,7 +121,7 @@ module.exports = function meshtasticRoutes(app, ctx) { let config = loadConfig(); - // ── Shared state ── + // ── Shared state (for proxy/direct modes) ── const state = { connected: false, lastSeen: 0, @@ -127,9 +135,201 @@ module.exports = function meshtasticRoutes(app, ctx) { let pollTimer = null; let infoTimer = null; - let mqttClient = null; - // ── Node/message parsing (shared by proxy and MQTT) ── + // ── Per-user MQTT sessions ── + // Each session: { config, state, mqttClient, lastActivity } + const mqttSessions = new Map(); + + function createMqttState() { + return { + connected: false, + lastSeen: 0, + lastError: null, + myNodeNum: null, + nodes: new Map(), + messages: [], + maxMessages: 200, + deviceInfo: null, + }; + } + + function getSessionId(req) { + const id = req.headers['x-mesh-session'] || req.query.meshSession || ''; + if (!id || !SESSION_ID_REGEX.test(id)) return null; + return id; + } + + function getMqttSession(sessionId) { + const session = mqttSessions.get(sessionId); + if (session) session.lastActivity = Date.now(); + return session || null; + } + + function mqttSessionConnect(sessionId, sessionConfig) { + // Disconnect existing session if any + mqttSessionDisconnect(sessionId); + + if (mqttSessions.size >= MAX_MQTT_SESSIONS) { + return { ok: false, error: `Too many active MQTT sessions (max ${MAX_MQTT_SESSIONS}). Try again later.` }; + } + + const broker = sessionConfig.mqttBroker; + const topic = sessionConfig.mqttTopic || 'msh/#'; + if (!broker) return { ok: false, error: 'MQTT broker URL is required' }; + + const sessionState = createMqttState(); + const clientId = `ohc_mesh_${Math.random().toString(16).substr(2, 8)}`; + + logInfo(`[Meshtastic MQTT] Session ${sessionId.slice(0, 8)}… connecting to ${broker} topic ${topic}`); + + const opts = { + clientId, + clean: true, + connectTimeout: 15000, + reconnectPeriod: 30000, + keepalive: 60, + }; + if (sessionConfig.mqttUsername) { + opts.username = sessionConfig.mqttUsername; + opts.password = sessionConfig.mqttPassword || ''; + } + + const client = mqttLib.connect(broker, opts); + + const session = { + config: sessionConfig, + state: sessionState, + mqttClient: client, + lastActivity: Date.now(), + }; + mqttSessions.set(sessionId, session); + + // ── Per-session node/message parsing ── + function parseNodeInfo(packet) { + if (!packet?.user && !packet?.position) return null; + const num = packet.num || packet.nodeNum; + if (!num) return null; + const existing = sessionState.nodes.get(num) || {}; + const node = { + ...existing, + num, + id: packet.user?.id || existing.id || `!${num.toString(16)}`, + longName: packet.user?.longName || existing.longName || '', + shortName: packet.user?.shortName || existing.shortName || '', + hwModel: packet.user?.hwModel || existing.hwModel || '', + lat: + packet.position?.latitudeI != null + ? packet.position.latitudeI / 1e7 + : (packet.position?.latitude ?? existing.lat ?? null), + lon: + packet.position?.longitudeI != null + ? packet.position.longitudeI / 1e7 + : (packet.position?.longitude ?? existing.lon ?? null), + alt: packet.position?.altitude ?? existing.alt ?? null, + batteryLevel: packet.deviceMetrics?.batteryLevel ?? existing.batteryLevel ?? null, + voltage: packet.deviceMetrics?.voltage ?? existing.voltage ?? null, + snr: packet.snr ?? existing.snr ?? null, + lastHeard: packet.lastHeard + ? packet.lastHeard * 1000 + : packet.timestamp + ? packet.timestamp * 1000 + : existing.lastHeard || Date.now(), + hopsAway: packet.hopsAway ?? existing.hopsAway ?? null, + }; + sessionState.nodes.set(num, node); + return node; + } + + function addMessage(msg) { + if (msg.id && sessionState.messages.some((m) => m.id === msg.id)) return; + sessionState.messages.push(msg); + if (sessionState.messages.length > sessionState.maxMessages) { + sessionState.messages = sessionState.messages.slice(-sessionState.maxMessages); + } + } + + client.on('connect', () => { + sessionState.connected = true; + sessionState.lastError = null; + logInfo(`[Meshtastic MQTT] Session ${sessionId.slice(0, 8)}… connected to ${broker}`); + client.subscribe(topic, { qos: 0 }, (err) => { + if (err) logWarn(`[Meshtastic MQTT] Session ${sessionId.slice(0, 8)}… subscribe error: ${err.message}`); + else logInfo(`[Meshtastic MQTT] Session ${sessionId.slice(0, 8)}… subscribed to ${topic}`); + }); + }); + + client.on('message', (_topic, payload) => { + try { + const data = JSON.parse(payload.toString()); + sessionState.lastSeen = Date.now(); + + if (data.type === 'nodeinfo' || data.payload?.user || data.payload?.position) { + parseNodeInfo(data.payload || data); + } else if (data.type === 'text' || data.payload?.text) { + const p = data.payload || data; + const fromNode = sessionState.nodes.get(p.from); + addMessage({ + id: data.id || `mqtt-${p.from}-${Date.now()}`, + from: p.from, + to: p.to, + text: p.text || '', + timestamp: p.timestamp ? p.timestamp * 1000 : Date.now(), + channel: p.channel ?? 0, + fromName: fromNode?.longName || fromNode?.shortName || `!${(p.from || 0).toString(16)}`, + }); + } else if (data.type === 'position' && data.payload) { + const num = data.from || data.payload.from; + if (num) { + parseNodeInfo({ num, position: data.payload }); + } + } + } catch { + // Binary protobuf — can't parse without meshtastic protobuf definitions + } + }); + + client.on('error', (err) => { + logErrorOnce(`Meshtastic MQTT ${sessionId.slice(0, 8)}`, err.message); + sessionState.lastError = err.message; + }); + + client.on('close', () => { + sessionState.connected = false; + }); + + return { ok: true }; + } + + function mqttSessionDisconnect(sessionId) { + const session = mqttSessions.get(sessionId); + if (!session) return; + if (session.mqttClient) { + try { + session.mqttClient.removeAllListeners(); + session.mqttClient.on('error', () => {}); + session.mqttClient.end(true); + } catch {} + } + mqttSessions.delete(sessionId); + logInfo(`[Meshtastic MQTT] Session ${sessionId.slice(0, 8)}… disconnected`); + } + + // Cleanup stale MQTT sessions every 5 minutes + const sessionCleanupTimer = setInterval( + () => { + const now = Date.now(); + for (const [id, session] of mqttSessions) { + if (now - session.lastActivity > SESSION_TTL_MS) { + logInfo(`[Meshtastic MQTT] Cleaning up idle session ${id.slice(0, 8)}…`); + mqttSessionDisconnect(id); + } + } + }, + 5 * 60 * 1000, + ); + sessionCleanupTimer.unref(); + + // ── Node/message parsing (for proxy mode — shared state) ── function parseNodeInfo(packet) { if (!packet?.user && !packet?.position) return null; const num = packet.num || packet.nodeNum; @@ -238,108 +438,12 @@ module.exports = function meshtasticRoutes(app, ctx) { } catch {} } - // ── MQTT mode: subscribe to Meshtastic MQTT broker ── - function mqttConnect() { - mqttDisconnect(); - if (!config.mqttBroker) return; - - const broker = config.mqttBroker; - const topic = config.mqttTopic || 'msh/#'; - const clientId = `ohc_mesh_${Math.random().toString(16).substr(2, 8)}`; - - logInfo(`[Meshtastic MQTT] Connecting to ${broker} topic ${topic}`); - - const opts = { - clientId, - clean: true, - connectTimeout: 15000, - reconnectPeriod: 30000, - keepalive: 60, - }; - if (config.mqttUsername) { - opts.username = config.mqttUsername; - opts.password = config.mqttPassword || ''; - } - - const client = mqttLib.connect(broker, opts); - mqttClient = client; - - client.on('connect', () => { - state.connected = true; - state.lastError = null; - logInfo(`[Meshtastic MQTT] Connected to ${broker}`); - client.subscribe(topic, { qos: 0 }, (err) => { - if (err) logWarn(`[Meshtastic MQTT] Subscribe error: ${err.message}`); - else logInfo(`[Meshtastic MQTT] Subscribed to ${topic}`); - }); - }); - - client.on('message', (_topic, payload) => { - try { - const data = JSON.parse(payload.toString()); - state.lastSeen = Date.now(); - - // Meshtastic MQTT JSON format varies — handle common shapes - if (data.type === 'nodeinfo' || data.payload?.user || data.payload?.position) { - parseNodeInfo(data.payload || data); - } else if (data.type === 'text' || data.payload?.text) { - const p = data.payload || data; - const fromNode = state.nodes.get(p.from); - addMessage({ - id: data.id || `mqtt-${p.from}-${Date.now()}`, - from: p.from, - to: p.to, - text: p.text || '', - timestamp: p.timestamp ? p.timestamp * 1000 : Date.now(), - channel: p.channel ?? 0, - fromName: fromNode?.longName || fromNode?.shortName || `!${(p.from || 0).toString(16)}`, - }); - } else if (data.type === 'position' && data.payload) { - // Position-only update - const num = data.from || data.payload.from; - if (num) { - parseNodeInfo({ num, position: data.payload }); - } - } - } catch { - // Binary protobuf — can't parse without meshtastic protobuf definitions - // JSON mode is required for MQTT integration - } - }); - - client.on('error', (err) => { - logErrorOnce('Meshtastic MQTT', err.message); - state.lastError = err.message; - }); - - client.on('close', () => { - state.connected = false; - }); - } - - function mqttDisconnect() { - if (mqttClient) { - try { - mqttClient.removeAllListeners(); - mqttClient.on('error', () => {}); - mqttClient.end(true); - } catch {} - mqttClient = null; - } - } - - // ── Polling lifecycle ── + // ── Polling lifecycle (proxy/direct only — no global MQTT) ── function startPolling() { stopPolling(); if (!config.enabled) return; - if (config.mode === 'mqtt') { - mqttConnect(); - return; - } - if (config.mode === 'direct') { - // Direct mode is client-side only — server just stores config logInfo('[Meshtastic] Direct (browser) mode — no server polling'); return; } @@ -372,7 +476,6 @@ module.exports = function meshtasticRoutes(app, ctx) { clearInterval(infoTimer); infoTimer = null; } - mqttDisconnect(); } function resetState() { @@ -385,28 +488,49 @@ module.exports = function meshtasticRoutes(app, ctx) { if (config.enabled) startPolling(); + // ── Helper: resolve the active state/config for a request ── + // MQTT requests use per-session state; proxy/direct use shared state. + function resolveSession(req) { + const sessionId = getSessionId(req); + if (sessionId) { + const session = getMqttSession(sessionId); + if (session) { + return { + mode: 'mqtt', + sessionId, + config: session.config, + state: session.state, + mqttClient: session.mqttClient, + }; + } + } + return { mode: config.mode, sessionId: null, config, state, mqttClient: null }; + } + // ── API Endpoints ── app.get('/api/meshtastic/status', (req, res) => { + const s = resolveSession(req); res.json({ - mode: config.mode || 'proxy', - enabled: config.enabled, - connected: state.connected, - lastSeen: state.lastSeen, - lastError: state.lastError, - host: config.mode === 'proxy' && config.enabled ? config.host : null, - mqttBroker: config.mode === 'mqtt' && config.enabled ? config.mqttBroker : null, - mqttTopic: config.mode === 'mqtt' ? config.mqttTopic : null, - pollMs: config.pollMs, - configSource: config.source, - nodeCount: state.nodes.size, - messageCount: state.messages.length, - deviceInfo: state.deviceInfo, + mode: s.config.mode || '', + enabled: s.config.enabled || false, + connected: s.state.connected, + lastSeen: s.state.lastSeen, + lastError: s.state.lastError, + host: s.config.mode === 'proxy' && s.config.enabled ? s.config.host : null, + mqttBroker: s.config.mode === 'mqtt' && s.config.enabled ? s.config.mqttBroker : null, + mqttTopic: s.config.mode === 'mqtt' ? s.config.mqttTopic : null, + pollMs: s.config.pollMs, + configSource: s.config.source, + nodeCount: s.state.nodes.size, + messageCount: s.state.messages.length, + deviceInfo: s.state.deviceInfo, }); }); app.get('/api/meshtastic/nodes', (req, res) => { - const nodes = [...state.nodes.values()].map((n) => ({ + const s = resolveSession(req); + const nodes = [...s.state.nodes.values()].map((n) => ({ num: n.num, id: n.id, longName: n.longName, @@ -422,29 +546,30 @@ module.exports = function meshtasticRoutes(app, ctx) { hwModel: n.hwModel, hasPosition: n.lat != null && n.lon != null, })); - res.json({ connected: state.connected, nodes, timestamp: Date.now() }); + res.json({ connected: s.state.connected, nodes, timestamp: Date.now() }); }); app.get('/api/meshtastic/messages', (req, res) => { + const s = resolveSession(req); const since = parseInt(req.query.since) || 0; - const messages = since ? state.messages.filter((m) => m.timestamp > since) : state.messages; - res.json({ connected: state.connected, messages: messages.slice(-100), timestamp: Date.now() }); + const messages = since ? s.state.messages.filter((m) => m.timestamp > since) : s.state.messages; + res.json({ connected: s.state.connected, messages: messages.slice(-100), timestamp: Date.now() }); }); app.post('/api/meshtastic/send', writeLimiter, async (req, res) => { - if (!config.enabled || !state.connected) { + const s = resolveSession(req); + if (!s.config.enabled || !s.state.connected) { return res.status(503).json({ error: 'Meshtastic not connected' }); } - if (config.mode === 'direct') { + if (s.config.mode === 'direct') { return res.status(400).json({ error: 'Direct mode sends from the browser — use the device URL directly' }); } const { text, to, channel } = req.body || {}; if (!text || typeof text !== 'string' || text.length > 228) { return res.status(400).json({ error: 'Text required (max 228 chars)' }); } - if (config.mode === 'mqtt') { - // Publish to MQTT - if (!mqttClient || !mqttClient.connected) { + if (s.config.mode === 'mqtt') { + if (!s.mqttClient || !s.mqttClient.connected) { return res.status(503).json({ error: 'MQTT not connected' }); } try { @@ -452,16 +577,23 @@ module.exports = function meshtasticRoutes(app, ctx) { type: 'sendtext', payload: { text: text.trim(), to: to || 0xffffffff, channel: channel || 0 }, }); - mqttClient.publish(config.mqttTopic?.replace(/#$/, '') + 'sendtext', payload, { qos: 0 }); - addMessage({ + s.mqttClient.publish(s.config.mqttTopic?.replace(/#$/, '') + 'sendtext', payload, { qos: 0 }); + // Add to session messages + const msg = { id: `local-${Date.now()}`, - from: state.myNodeNum || 0, + from: s.state.myNodeNum || 0, to: to || 0xffffffff, text: text.trim(), timestamp: Date.now(), channel: channel || 0, - fromName: state.deviceInfo?.longName || 'Me', - }); + fromName: s.state.deviceInfo?.longName || 'Me', + }; + if (msg.id && !s.state.messages.some((m) => m.id === msg.id)) { + s.state.messages.push(msg); + if (s.state.messages.length > s.state.maxMessages) { + s.state.messages = s.state.messages.slice(-s.state.maxMessages); + } + } return res.json({ ok: true }); } catch (e) { return res.status(500).json({ error: `MQTT publish failed: ${e.message}` }); @@ -496,9 +628,16 @@ module.exports = function meshtasticRoutes(app, ctx) { // POST /api/meshtastic/configure app.post('/api/meshtastic/configure', writeLimiter, async (req, res) => { const { enabled, mode, host, mqttBroker, mqttTopic, mqttUsername, mqttPassword, pollMs } = req.body || {}; + const sessionId = getSessionId(req); // Disable if (enabled === false) { + // If this is an MQTT session, disconnect just that session + if (sessionId && mqttSessions.has(sessionId)) { + mqttSessionDisconnect(sessionId); + return res.json({ ok: true, enabled: false, message: 'MQTT session disconnected.' }); + } + // Otherwise disable the shared proxy/direct config stopPolling(); config = { ...config, enabled: false, source: 'saved' }; resetState(); @@ -512,6 +651,8 @@ module.exports = function meshtasticRoutes(app, ctx) { // ── Direct mode — just save the config, browser handles everything ── if (mode === 'direct') { + // Disconnect any MQTT session for this client + if (sessionId && mqttSessions.has(sessionId)) mqttSessionDisconnect(sessionId); stopPolling(); config = { mode: 'direct', enabled: true, host: host || '', pollMs: clampPollMs(pollMs), source: 'saved' }; saveConfig(config); @@ -524,13 +665,16 @@ module.exports = function meshtasticRoutes(app, ctx) { }); } - // ── MQTT mode ── + // ── MQTT mode — per-user session ── if (mode === 'mqtt') { if (!mqttBroker) { return res.status(400).json({ error: 'MQTT broker URL is required (e.g. mqtt://mqtt.meshtastic.org)' }); } - stopPolling(); - config = { + if (!sessionId) { + return res.status(400).json({ error: 'MQTT mode requires a session ID (x-mesh-session header)' }); + } + + const sessionConfig = { mode: 'mqtt', enabled: true, mqttBroker: mqttBroker.trim(), @@ -538,12 +682,14 @@ module.exports = function meshtasticRoutes(app, ctx) { mqttUsername: (mqttUsername || '').trim(), mqttPassword: (mqttPassword || '').trim(), pollMs: clampPollMs(pollMs), - source: 'saved', + source: 'session', }; - saveConfig(config); - resetState(); - startPolling(); - return res.json({ ok: true, mode: 'mqtt', message: `Connected to MQTT broker ${config.mqttBroker}` }); + + const result = mqttSessionConnect(sessionId, sessionConfig); + if (!result.ok) { + return res.status(503).json({ error: result.error }); + } + return res.json({ ok: true, mode: 'mqtt', message: `Connected to MQTT broker ${sessionConfig.mqttBroker}` }); } // ── Proxy mode ── @@ -551,6 +697,8 @@ module.exports = function meshtasticRoutes(app, ctx) { if (!host) { return res.status(400).json({ error: 'Device host URL is required' }); } + // Disconnect any MQTT session for this client + if (sessionId && mqttSessions.has(sessionId)) mqttSessionDisconnect(sessionId); const validated = validateDeviceHost(host); if (!validated.ok) { return res.status(400).json({ error: validated.error }); diff --git a/server/routes/pskreporter.js b/server/routes/pskreporter.js index d8c1ca29..b87043fd 100644 --- a/server/routes/pskreporter.js +++ b/server/routes/pskreporter.js @@ -492,7 +492,7 @@ module.exports = function (app, ctx) { // "Connection closed" errors are expected during reconnects — // the on('connect') handler will re-subscribe all active callsigns if (err.message && err.message.includes('onnection closed')) return; - console.error(`[PSK-MQTT] Subscribe error for ${call}:`, err.message); + console.error('[PSK-MQTT] Subscribe error for %s:', call, err.message); } }); } @@ -504,7 +504,7 @@ module.exports = function (app, ctx) { pskMqtt.client.unsubscribe([txTopic, rxTopic], (err) => { if (err) { if (err.message && err.message.includes('onnection closed')) return; - console.error(`[PSK-MQTT] Unsubscribe error for ${call}:`, err.message); + console.error('[PSK-MQTT] Unsubscribe error for %s:', call, err.message); } }); } @@ -525,9 +525,9 @@ module.exports = function (app, ctx) { pskMqtt.client.subscribe([txTopic, rxTopic], { qos: 0 }, (err) => { if (err) { if (err.message && err.message.includes('onnection closed')) return; - console.error(`[PSK-MQTT] Grid subscribe error for ${grid}:`, err.message); + console.error('[PSK-MQTT] Grid subscribe error for %s:', grid, err.message); } else { - console.log(`[PSK-MQTT] Subscribed grid ${grid}`); + console.log('[PSK-MQTT] Subscribed grid %s', grid); } }); } @@ -539,7 +539,7 @@ module.exports = function (app, ctx) { pskMqtt.client.unsubscribe([txTopic, rxTopic], (err) => { if (err) { if (err.message && err.message.includes('onnection closed')) return; - console.error(`[PSK-MQTT] Grid unsubscribe error for ${grid}:`, err.message); + console.error('[PSK-MQTT] Grid unsubscribe error for %s:', grid, err.message); } }); } diff --git a/server/routes/wsjtx.js b/server/routes/wsjtx.js index 36f9d4a0..647edad6 100644 --- a/server/routes/wsjtx.js +++ b/server/routes/wsjtx.js @@ -481,6 +481,11 @@ module.exports = function (app, ctx) { // Reject dangerous msg.id values to prevent prototype pollution on state.clients if (msg.id && !isValidSessionId(msg.id)) return; + // Ensure clients is a prototype-less object to prevent prototype pollution + if (!state.clients || Object.getPrototypeOf(state.clients) !== null) { + state.clients = Object.assign(Object.create(null), state.clients || {}); + } + switch (msg.type) { case WSJTX_MSG.HEARTBEAT: { state.clients[msg.id] = { diff --git a/src/App.jsx b/src/App.jsx index e9221cf4..37aa238e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -617,6 +617,7 @@ const App = () => { layoutLocked={layoutLocked} onToggleLayoutLock={toggleLayoutLock} onResetLayout={handleResetLayout} + version={config.version} /> diff --git a/src/components/DonateButton.jsx b/src/components/DonateButton.jsx index 0a29bd92..0290094c 100644 --- a/src/components/DonateButton.jsx +++ b/src/components/DonateButton.jsx @@ -8,7 +8,7 @@ const PAYPAL_URL = 'https://www.paypal.com/donate/?hosted_button_id=MMYPQBLA6SW6 const COFFEE_URL = 'https://buymeacoffee.com/k0cjh'; const MERCH_URL = 'https://openhamclock.printify.me'; -export default function DonateButton({ compact = false, fontSize = '12px', padding = '6px 10px' }) { +export default function DonateButton({ compact = false, fontSize = '12px', padding = '6px 10px', tabIndex }) { const [open, setOpen] = useState(false); const close = useCallback(() => setOpen(false), []); @@ -28,6 +28,7 @@ export default function DonateButton({ compact = false, fontSize = '12px', paddi {/* Donate */} - {!isFullscreen && } + {!isFullscreen && ( + + )} {/* Settings (quick access) */} - + + {/* Version */} + {version && isExpanded && ( +
window.dispatchEvent(new Event('openhamclock-show-whatsnew'))} + style={{ + fontSize: '10px', + color: 'var(--text-muted)', + textAlign: 'center', + cursor: 'pointer', + padding: '4px 0 2px', + }} + title="What's new in this version" + > + v{version} +
+ )} diff --git a/src/hooks/useWeatherAlerts.js b/src/hooks/useWeatherAlerts.js index eb394344..3113daf2 100644 --- a/src/hooks/useWeatherAlerts.js +++ b/src/hooks/useWeatherAlerts.js @@ -111,7 +111,7 @@ export const useWeatherAlerts = (location) => { severity: p.severity, urgency: p.urgency, description: p.description, - expires, + expires: p.expires || null, expiresMs, areaDesc: p.areaDesc, senderName: p.senderName, diff --git a/src/lang/ca.json b/src/lang/ca.json index 82ab47d2..eabc8fbc 100644 --- a/src/lang/ca.json +++ b/src/lang/ca.json @@ -439,4 +439,4 @@ "weather.wind.W": "O", "weather.wind.WNW": "ONO", "weather.wind.WSW": "OSO" -} +} \ No newline at end of file diff --git a/src/lang/de.json b/src/lang/de.json index 48e60574..9b15f4f8 100644 --- a/src/lang/de.json +++ b/src/lang/de.json @@ -439,4 +439,4 @@ "weather.wind.W": "W", "weather.wind.WNW": "WNW", "weather.wind.WSW": "WSW" -} +} \ No newline at end of file diff --git a/src/lang/fr.json b/src/lang/fr.json index 05ff3702..80ece507 100644 --- a/src/lang/fr.json +++ b/src/lang/fr.json @@ -439,4 +439,4 @@ "weather.wind.W": "O", "weather.wind.WNW": "ONO", "weather.wind.WSW": "OSO" -} +} \ No newline at end of file diff --git a/src/lang/it.json b/src/lang/it.json index 121eef3a..590a2e7f 100644 --- a/src/lang/it.json +++ b/src/lang/it.json @@ -439,4 +439,4 @@ "weather.wind.W": "O", "weather.wind.WNW": "ONO", "weather.wind.WSW": "OSO" -} +} \ No newline at end of file diff --git a/src/lang/ja.json b/src/lang/ja.json index 87f40b76..b2b6f943 100644 --- a/src/lang/ja.json +++ b/src/lang/ja.json @@ -439,4 +439,4 @@ "weather.wind.W": "西", "weather.wind.WNW": "西北西", "weather.wind.WSW": "西南西" -} +} \ No newline at end of file diff --git a/src/lang/ko.json b/src/lang/ko.json index 63bec8e7..ed9197a8 100644 --- a/src/lang/ko.json +++ b/src/lang/ko.json @@ -439,4 +439,4 @@ "weather.wind.W": "서", "weather.wind.WNW": "서북서", "weather.wind.WSW": "서남서" -} +} \ No newline at end of file diff --git a/src/lang/ms.json b/src/lang/ms.json index 6e5eaf01..bfa7f06e 100644 --- a/src/lang/ms.json +++ b/src/lang/ms.json @@ -439,4 +439,4 @@ "weather.wind.W": "B", "weather.wind.WNW": "BUB", "weather.wind.WSW": "BSB" -} +} \ No newline at end of file diff --git a/src/lang/nl.json b/src/lang/nl.json index 868fc247..b04f01a5 100644 --- a/src/lang/nl.json +++ b/src/lang/nl.json @@ -439,4 +439,4 @@ "weather.wind.W": "W", "weather.wind.WNW": "WNW", "weather.wind.WSW": "WZW" -} +} \ No newline at end of file diff --git a/src/lang/pt.json b/src/lang/pt.json index 99dd8b8f..5d1488d0 100644 --- a/src/lang/pt.json +++ b/src/lang/pt.json @@ -439,4 +439,4 @@ "weather.wind.W": "O", "weather.wind.WNW": "ONO", "weather.wind.WSW": "OSO" -} +} \ No newline at end of file diff --git a/src/lang/sl.json b/src/lang/sl.json index 02e2cd5d..bebd8752 100644 --- a/src/lang/sl.json +++ b/src/lang/sl.json @@ -439,4 +439,4 @@ "weather.wind.W": "Z", "weather.wind.WNW": "ZSZ", "weather.wind.WSW": "ZJZ" -} +} \ No newline at end of file diff --git a/src/plugins/layers/useTornadoWarnings.js b/src/plugins/layers/useTornadoWarnings.js index 5cff8226..d2b94a53 100644 --- a/src/plugins/layers/useTornadoWarnings.js +++ b/src/plugins/layers/useTornadoWarnings.js @@ -1,5 +1,5 @@ import i18n from '../../lang/i18n'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; // 🌪️ Tornado Warnings layer — NWS Weather Alerts API // Displays active tornado watches, warnings, and emergencies from the @@ -17,7 +17,7 @@ export const metadata = { category: 'hazards', defaultEnabled: false, defaultOpacity: 0.7, - version: '1.0.0', + version: '1.1.0', }; // NWS alert event types we care about, in priority order @@ -29,12 +29,13 @@ const TORNADO_EVENTS = [ ]; // Color and style config per alert type +// Warnings = red outlined polygons, Watches = amber outlined polygons const ALERT_STYLES = { 'Tornado Emergency': { color: '#8B0000', fill: '#8B0000', weight: 3, - fillOpacity: 0.35, + fillOpacity: 0.5, icon: '‼️', size: 28, zOffset: 10200, @@ -43,7 +44,7 @@ const ALERT_STYLES = { color: '#FF0000', fill: '#FF0000', weight: 3, - fillOpacity: 0.25, + fillOpacity: 0.5, icon: '🌪️', size: 24, zOffset: 10100, @@ -52,7 +53,8 @@ const ALERT_STYLES = { color: '#FFAA00', fill: '#FFAA00', weight: 2, - fillOpacity: 0.15, + fillOpacity: 0.08, + fillOpacity: 0.5, icon: '👁️', size: 20, zOffset: 10000, @@ -61,7 +63,7 @@ const ALERT_STYLES = { color: '#FF8C00', fill: '#FF8C00', weight: 2, - fillOpacity: 0.2, + fillOpacity: 0.5, icon: '⛈️', size: 20, zOffset: 9900, @@ -72,7 +74,7 @@ const DEFAULT_STYLE = { color: '#FF6600', fill: '#FF6600', weight: 2, - fillOpacity: 0.15, + fillOpacity: 0.5, icon: '⚠️', size: 18, zOffset: 9800, @@ -95,26 +97,36 @@ function polygonCentroid(ring) { } export function useLayer({ enabled = false, opacity = 0.7, map = null, lowMemoryMode = false }) { - const [layerItems, setLayerItems] = useState([]); const [alertData, setAlertData] = useState([]); + const layerItemsRef = useRef([]); const previousAlertIds = useRef(new Set()); const isFirstLoad = useRef(true); const MAX_ALERTS = lowMemoryMode ? 30 : 150; - const REFRESH_INTERVAL = lowMemoryMode ? 180000 : 120000; // 3 min vs 2 min (alerts are time-critical) + const REFRESH_INTERVAL = lowMemoryMode ? 180000 : 120000; + + // Remove all layers from map + const clearLayers = useCallback(() => { + layerItemsRef.current.forEach((item) => { + try { + map?.removeLayer(item); + } catch (e) {} + }); + layerItemsRef.current = []; + }, [map]); // Fetch tornado alerts from NWS useEffect(() => { - if (!enabled) return; + if (!enabled) { + setAlertData([]); + return; + } const fetchAlerts = async () => { try { - // Fetch active tornado-related alerts - // NWS API returns GeoJSON FeatureCollection - // NWS expects separate event= params for each event type const params = new URLSearchParams(); TORNADO_EVENTS.forEach((e) => params.append('event', e)); - params.append('limit', MAX_ALERTS); + params.append('status', 'actual'); const response = await fetch(`https://api.weather.gov/alerts/active?${params.toString()}`, { headers: { 'User-Agent': 'OpenHamClock (https://github.com/accius/openhamclock)', @@ -139,13 +151,8 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null, lowMemory useEffect(() => { if (!map || typeof L === 'undefined') return; - // Clear previous layers - layerItems.forEach((item) => { - try { - map.removeLayer(item); - } catch (e) {} - }); - setLayerItems([]); + // Always clear previous layers first + clearLayers(); if (!enabled || alertData.length === 0) return; @@ -161,10 +168,7 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null, lowMemory const style = getAlertStyle(event); const isNew = !isFirstLoad.current && !previousAlertIds.current.has(alertId); - // NWS alerts have geometry as Polygon or null - // If geometry is null, the alert uses UGC zone codes only (no polygon available) const geometry = feature.geometry; - let centroid = null; // Draw polygon if geometry exists @@ -203,7 +207,7 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null, lowMemory }); } - // If no geometry, try to skip (or we could geocode UGC zones, but that's complex) + // Skip alerts without renderable geometry if (!centroid) return; // Create centroid marker @@ -235,7 +239,6 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null, lowMemory }).addTo(map); // Format timing info - const effective = props.effective ? new Date(props.effective) : null; const expires = props.expires ? new Date(props.expires) : null; const now = Date.now(); const expiresIn = expires ? Math.max(0, Math.floor((expires.getTime() - now) / 60000)) : null; @@ -248,15 +251,12 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null, lowMemory : `${Math.floor(expiresIn / 60)}h ${expiresIn % 60}m` : 'Unknown'; - // Areas affected const areas = props.areaDesc || 'Unknown area'; const sender = props.senderName || ''; const headline = props.headline || ''; const description = props.description || ''; - // Truncate description for popup const shortDesc = description.length > 300 ? description.substring(0, 300) + '...' : description; - // Severity badge const severity = props.severity || ''; const urgency = props.urgency || ''; const certainty = props.certainty || ''; @@ -286,19 +286,13 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null, lowMemory previousAlertIds.current = currentAlertIds; if (isFirstLoad.current) isFirstLoad.current = false; - setLayerItems(newItems); + layerItemsRef.current = newItems; - return () => { - newItems.forEach((item) => { - try { - map.removeLayer(item); - } catch (e) {} - }); - }; - }, [enabled, alertData, map, opacity]); + return () => clearLayers(); + }, [enabled, alertData, map, opacity, clearLayers]); return { - markers: layerItems, + markers: layerItemsRef.current, alertCount: alertData.length, }; }