diff --git a/.env.example b/.env.example index 18fa832a..cd2d8b8e 100644 --- a/.env.example +++ b/.env.example @@ -53,9 +53,14 @@ HOST=localhost # API_WRITE_KEY=your-secret-key-here # CORS allowed origins (comma-separated) -# If not set, defaults to reflecting the request origin (backward-compatible). -# Set this for cloud deployments to restrict which websites can call your API. -# CORS_ORIGINS=https://yourdomain.com,http://localhost:3000 +# By default, only localhost and openhamclock.com/app origins are allowed. +# Add your custom domain if you host OHC behind your own URL. +# CORS_ORIGINS=https://yourdomain.com + +# Trust proxy headers (X-Forwarded-For) for rate limiting IP detection. +# Auto-detected: enabled on Railway, disabled on Pi/local installs. +# Set to 'true' if behind nginx/Caddy/etc., 'false' to force off. +# TRUST_PROXY=true # =========================================== # AUTO UPDATE (GIT) @@ -229,18 +234,19 @@ VITE_AMBIENT_API_KEY=your_api_key_here # =========================================== # ROTATOR CONTROL # =========================================== - -ROTATOR_PROVIDER=pstrotator_udp -PSTROTATOR_HOST=192.168.1.43 -PSTROTATOR_UDP_PORT=12000 -ROTATOR_STALE_MS=5000 +# Uncomment and configure ONLY if you have a PSTRotator-compatible rotator. +# Leaving these active on a fresh install sends UDP traffic to 192.168.1.43. +# ROTATOR_PROVIDER=pstrotator_udp +# PSTROTATOR_HOST=192.168.1.43 +# PSTROTATOR_UDP_PORT=12000 +# ROTATOR_STALE_MS=5000 # Your app will use the proxy path: -VITE_PSTROTATOR_BASE_URL=/pstrotator +# VITE_PSTROTATOR_BASE_URL=/pstrotator # Optional: HTTP endpoint for PstRotatorAz web interface (for proxy) # Set this to the machine running PstRotatorAz (default shown below) -VITE_PSTROTATOR_TARGET=http://192.168.1.43:50004 +# VITE_PSTROTATOR_TARGET=http://192.168.1.43:50004 # =========================================== # N3FJP QSO RETENTION diff --git a/AddOns/APRS-Newsfeed/aprs_newsfeed.user.js b/AddOns/APRS-Newsfeed/aprs_newsfeed.user.js index 3c9a84a5..63144c82 100644 --- a/AddOns/APRS-Newsfeed/aprs_newsfeed.user.js +++ b/AddOns/APRS-Newsfeed/aprs_newsfeed.user.js @@ -159,6 +159,9 @@ let apiKey = localStorage.getItem(STORAGE_API_KEY) || ''; let lastUpdateTs = parseInt(localStorage.getItem('ohc_aprs_last_update')) || 0; + // Escape HTML to prevent XSS when interpolating into innerHTML + const esc = (s) => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); + function getCallsign() { try { const config = JSON.parse(localStorage.getItem('openhamclock_config')); @@ -386,7 +389,7 @@
${t('setup_required')}
- +
@@ -552,7 +555,7 @@ } } else { document.getElementById('aprs-news-content').innerHTML = - `
${t('error_api')}: ${data.description || ''}
`; + `
${t('error_api')}: ${esc(data.description || '')}
`; status.innerText = 'Error'; } } @@ -580,12 +583,12 @@ return `
- ${entry.srccall}${tag} + ${esc(entry.srccall)}${tag} ${timeStr}
-
${entry.message}
+
${esc(entry.message)}
- ${t('to')}: ${entry.dst} + ${t('to')}: ${esc(entry.dst)}
`; diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ffab5a..0cb67e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ All notable changes to OpenHamClock will be documented in this file. > **📅 Schedule Change:** Starting with v15.5.10, OpenHamClock moves to a weekly release cycle. Updates will ship on **Tuesday nights (EST)** — one release per week for better testing and stability. +## [15.6.5] - 2026-03-09 + +### Security + +- **CORS lockdown**: Replaced wildcard `origin: true` with explicit origin allowlist (localhost, openhamclock.com/app). Prevents malicious websites from accessing the API via the user's browser. Custom origins configurable via `CORS_ORIGINS` env var. +- **SSRF elimination**: Custom DX cluster hosts are now DNS-resolved to IPv4, validated against private/reserved ranges, and the connection uses the validated IP (not hostname) to prevent DNS rebinding. IPv6 fallback removed to eliminate representation bypass attacks. +- **Rotator & QRZ auth**: `/api/rotator/turn`, `/api/rotator/stop`, `/api/qrz/configure`, `/api/qrz/remove` now require `API_WRITE_KEY` authentication. +- **Trust proxy auto-detect**: `trust proxy` enabled only on Railway (auto-detected), disabled on Pi/local installs to prevent rate-limit bypass via spoofed `X-Forwarded-For` headers. Override with `TRUST_PROXY` env var. +- **SSE connection limiter**: Per-IP cap on concurrent SSE streams (default 10, configurable via `MAX_SSE_PER_IP`) to prevent resource exhaustion. +- **Telnet command injection**: Control characters stripped from DX cluster login callsigns. +- **DOM XSS fixes**: `sanitizeColor()` for N3FJP logged QSO line colors; `esc()` helper for APRS Newsfeed userscript. +- **ReDoS fix**: Replaced `/\d+$/` regex with `substring()` for IP anonymization. +- **URL encoding**: `encodeURIComponent()` applied to callsign parameters in localhost fetch calls. +- **RBN callsign validation**: Input sanitized and length-checked on `/api/rbn/location/:callsign`. +- **Health endpoint**: Session details (partial IPs, user agents) gated behind `API_WRITE_KEY` auth. +- **Dockerfile**: Application now runs as non-root user (`nodejs`, UID 1001). +- **Startup warning**: Server prints visible warning when `API_WRITE_KEY` is not set. +- **Rig-bridge CORS**: Restricted to explicit origin allowlist (was wildcard `*`). +- **Rig-bridge localhost binding**: HTTP server binds to `127.0.0.1` by default (was `0.0.0.0`). +- **Rig-bridge serial port validation**: Paths validated against OS-specific patterns (COM*, /dev/tty*, /dev/cu.*). +- **Rig-bridge relay SSRF**: Relay URL validated to reject private/reserved addresses. + +### Added + +- **LMSAL solar image fallback**: Three-source failover for solar imagery: SDO direct → LMSAL Sun Today (Lockheed Martin) → Helioviewer API. Independent of NASA Goddard infrastructure. +- **Lightning unit preferences**: Proximity panel distances respect km/miles setting from allUnits. +- **DXCC entity selector**: Browse/search DXCC entities to set DX target in Modern and Dockable layouts. +- **DX News text scale**: Adjustable font size (0.7x–2.0x) with A-/A+ buttons. Persists in localStorage. +- **Layout lock border panel**: Lock/unlock toggle in dedicated FlexLayout border tab (Dockable layout). +- **Rig-bridge multicast**: WSJT-X relay supports UDP multicast for multi-app packet sharing. +- **Rig-bridge simulated radio**: Mock plugin for testing without hardware (`radio.type = "mock"`). +- **DX cluster TCP keepalive**: Persistent telnet sessions use OS-level keepalive and auto-reconnect after 5 min silence. +- **DX cluster SSID**: Callsign SSID (-56) appended automatically when not provided. + +### Fixed + +- **Rotator enabled by default**: `.env.example` had `ROTATOR_PROVIDER=pstrotator_udp` uncommented, causing fresh installs to send UDP to a hardcoded IP. All rotator lines now commented out. +- **Pi setup (armhf)**: NodeSource dropped 32-bit ARM support for Node 20+. Setup script now downloads armv7l binaries directly from nodejs.org with retry support. +- **Pi setup (electron)**: `npm install --ignore-scripts` prevents electron-winstaller postinstall failures on ARM. `ELECTRON_SKIP_BINARY_DOWNLOAD=1` skips useless Electron download. `npm prune --omit=dev` frees ~500MB after build. + ## [15.5.10] - 2026-02-20 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a12d03e4..060bf3e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,16 +10,14 @@ Thank you for helping build OpenHamClock! Whether you're fixing a bug, adding a # 1. Fork and clone git clone https://github.com/YOUR_USERNAME/openhamclock.git cd openhamclock +npm ci git checkout Staging -# 2. Install dependencies -npm install - -# 3. Start the backend (Terminal 1) +# 2. Start the backend (Terminal 1) node server.js # → Server running on http://localhost:3001 -# 4. Start the frontend dev server (Terminal 2) +# 3. Start the frontend dev server (Terminal 2) npm run dev # → App running on http://localhost:3000 (proxies API to :3001) ``` @@ -113,7 +111,7 @@ docs/update-readme We use **Prettier** to enforce consistent formatting across the codebase. This eliminates quote style, indentation, and whitespace noise from PRs so code review can focus on logic. -**It happens automatically:** If you run `npm install`, a git pre-commit hook (via Husky + lint-staged) will auto-format any staged files before each commit. You don't need to think about it. +**It happens automatically:** After you run `npm ci`, a git pre-commit hook (via Husky + lint-staged) will auto-format any staged files before each commit. You don't need to think about it. **Manual commands:** @@ -227,6 +225,7 @@ This repository uses shared formatting and dependency lock conventions so contri ```bash npm ci +git checkout Staging npm run dev ``` diff --git a/Dockerfile b/Dockerfile index 0843a34d..322126c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,10 @@ ENV NODE_ENV=production ENV PORT=3000 ENV NODE_OPTIONS="--max-old-space-size=2048 --expose-gc" +# Create non-root user for running the application +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 -G nodejs + WORKDIR /app # Create /data directory for persistent stats (Railway volume mount point) @@ -66,6 +70,12 @@ COPY --from=builder /app/public ./public # Create local data directory as fallback RUN mkdir -p /app/data +# Set ownership so non-root user can write to data directories and .git (auto-update) +RUN chown -R nodejs:nodejs /app /data + +# Run as non-root user +USER nodejs + # Expose ports (3000 = web, 2237 = WSJT-X UDP, 12060 = N1MM/DXLog) EXPOSE 3000 EXPOSE 2237/udp diff --git a/README.md b/README.md index a44b4e13..0ce978b9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ OpenHamClock brings DX cluster spots, space weather, propagation predictions, PO ```bash git clone https://github.com/accius/openhamclock.git cd openhamclock -npm install +npm ci npm start ``` @@ -498,6 +498,14 @@ Live decoded FT8, FT4, JT65, JT9, and WSPR messages from WSJT-X, JTDX, or any co 2. In WSJT-X: set the UDP Server address to your OpenHamClock machine's IP (e.g., `192.168.1.100`) and port `2237`. 3. Make sure UDP port 2237 is not blocked by a firewall. +**Network setup (WSJT-X using Multicast):** + +While the above configuration works just fine in a majority of cases, if you are running more than one multicast listener on a host (e.g. OpenHamClock and something like GridTracker2), then OpenHamClock needs to configure itself properly as a multicast listener. + +Uncomment the `WSJTX_MULTICAST_ADDRESS` line in `.env`, and make sure that the multicast address there matches what you have set in WSJT-X. e.g. `224.0.0.1` + +You will need to restart OpenHamCLock after this change. + **Cloud setup (OpenHamClock on a remote server):** WSJT-X sends data over UDP, which only works on a local network. For cloud deployments (like Railway or openhamclock.com), you need the WSJT-X Relay Agent to bridge the gap. See the [WSJT-X Relay Agent](#wsjt-x-relay-agent) section below. @@ -773,11 +781,12 @@ All configuration is done through the `.env` file. On first run, this file is au ### WSJT-X Integration -| Variable | Default | Description | -| ----------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `WSJTX_ENABLED` | `true` | Enable the WSJT-X UDP listener on the server. | -| `WSJTX_UDP_PORT` | `2237` | UDP port for receiving WSJT-X decoded messages. Must match the port configured in WSJT-X Settings → Reporting → UDP Server. | -| `WSJTX_RELAY_KEY` | _(none)_ | Shared secret key for the WSJT-X relay agent. Required only for cloud deployments where WSJT-X can't reach the server directly over UDP. Pick any strong random string. | +| Variable | Default | Description | +| ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `WSJTX_ENABLED` | `true` | Enable the WSJT-X UDP listener on the server. | +| `WSJTX_MULTICAST_ADDRESS` | _(none)_ | Multicast address to listen for messages | +| `WSJTX_UDP_PORT` | `2237` | UDP port for receiving WSJT-X decoded messages. Must match the port configured in WSJT-X Settings → Reporting → UDP Server. | +| `WSJTX_RELAY_KEY` | _(none)_ | Shared secret key for the WSJT-X relay agent. Required only for cloud deployments where WSJT-X can't reach the server directly over UDP. Pick any strong random string. | ### DX Cluster @@ -1266,11 +1275,15 @@ OpenHamClock is built by the ham radio community. We have 28+ contributors and g ```bash git clone https://github.com/accius/openhamclock.git -cd openhamclock && npm install +cd openhamclock +git checkout Staging +npm ci node server.js # Terminal 1 — Backend on :3001 npm run dev # Terminal 2 — Frontend on :3000 ``` +Open pull requests against `Staging`, not `main`. + **Read first:** - **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** — Full codebase map and key patterns diff --git a/package.json b/package.json index 0f865cc9..856dabe3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openhamclock", - "version": "15.6.4", + "version": "15.6.5", "description": "Amateur Radio Dashboard - A modern web-based HamClock alternative", "main": "electron/main.js", "scripts": { diff --git a/rig-bridge/README.md b/rig-bridge/README.md index db7967c5..f64f3a9c 100644 --- a/rig-bridge/README.md +++ b/rig-bridge/README.md @@ -18,6 +18,16 @@ Built on a **plugin architecture** — each radio integration is a standalone mo Also works with **Elecraft** radios (K3, K4, KX3, KX2) using the Kenwood plugin. +### SDR Radios via TCI (WebSocket) + +TCI (Transceiver Control Interface) is a WebSocket-based protocol used by modern SDR applications. Unlike serial CAT, TCI **pushes** frequency, mode, and PTT changes in real-time — no polling, no serial port conflicts. + +| Application | Radios | Default TCI Port | +| ------------- | --------------------- | ---------------- | +| **Thetis** | Hermes Lite 2, ANAN | 40001 | +| **ExpertSDR** | SunSDR2 | 40001 | +| **SmartSDR** | Flex (via TCI bridge) | varies | + ### Via Control Software (Legacy) | Software | Protocol | Default Port | @@ -25,6 +35,14 @@ Also works with **Elecraft** radios (K3, K4, KX3, KX2) using the Kenwood plugin. | **flrig** | XML-RPC | 12345 | | **rigctld** | TCP | 4532 | +### For Testing (No Hardware Required) + +| Type | Description | +| ------------------- | -------------------------------------------------------------------- | +| **Simulated Radio** | Fake radio that drifts through several bands — no serial port needed | + +Enable by setting `radio.type = "mock"` in `rig-bridge-config.json` or selecting **Simulated Radio** in the setup UI. + --- ## Quick Start @@ -75,6 +93,124 @@ node rig-bridge.js --debug # Enable raw hex/ASCII CAT traffic logging 1. Connect USB cable from radio to computer 2. In Rig Bridge: Select **Kenwood**, pick COM port, baud **9600**, stop bits **1** +### SDR Radios via TCI + +#### 1. Enable TCI in your SDR application + +**Thetis (HL2 / ANAN):** Setup → CAT Control → check **Enable TCI Server** (default port 40001) + +**ExpertSDR:** Settings → TCI → Enable (default port 40001) + +#### 2. Configure rig-bridge + +Edit `rig-bridge-config.json`: + +```json +{ + "radio": { "type": "tci" }, + "tci": { + "host": "localhost", + "port": 40001, + "trx": 0, + "vfo": 0 + } +} +``` + +| Field | Description | Default | +| ------ | -------------------------------- | ----------- | +| `host` | Host running the SDR application | `localhost` | +| `port` | TCI WebSocket port | `40001` | +| `trx` | Transceiver index (0 = primary) | `0` | +| `vfo` | VFO index (0 = VFO-A, 1 = VFO-B) | `0` | + +#### 3. Run rig-bridge + +```bash +node rig-bridge.js +``` + +You should see: + +``` +[TCI] Connecting to ws://localhost:40001... +[TCI] ✅ Connected to ws://localhost:40001 +[TCI] Device: Thetis +[TCI] Server ready +``` + +The bridge auto-reconnects every 5 s if the connection drops — just restart your SDR app and it will reconnect automatically. + +--- + +## WSJT-X Relay + +The WSJT-X Relay is an **integration plugin** (not a radio plugin) that listens for WSJT-X UDP packets on the local machine and forwards decoded messages to an OpenHamClock server in real-time. This lets OpenHamClock display your FT8/FT4 decodes as DX spots without any manual intervention. + +### Setup + +Edit `rig-bridge-config.json`: + +```json +{ + "wsjtxRelay": { + "enabled": true, + "url": "https://openhamclock.com", + "key": "your-relay-key", + "session": "your-session-id", + "udpPort": 2237, + "batchInterval": 2000, + "verbose": false, + "multicast": false, + "multicastGroup": "224.0.0.1", + "multicastInterface": "" + } +} +``` + +| Field | Description | Default | +| -------------------- | ------------------------------------------------------- | -------------------------- | +| `enabled` | Activate the relay on startup | `false` | +| `url` | OpenHamClock server URL | `https://openhamclock.com` | +| `key` | Relay authentication key (from your OHC account) | — | +| `session` | Browser session ID for per-user isolation | — | +| `udpPort` | UDP port WSJT-X is sending to | `2237` | +| `batchInterval` | How often decoded messages are sent (ms) | `2000` | +| `verbose` | Log every decoded message to the console | `false` | +| `multicast` | Join a UDP multicast group to receive WSJT-X packets | `false` | +| `multicastGroup` | Multicast group IP address to join | `224.0.0.1` | +| `multicastInterface` | Local NIC IP for multi-homed systems; `""` = OS default | `""` | + +### In WSJT-X + +Make sure WSJT-X is configured to send UDP packets to `localhost` on the same port as `udpPort` (default `2237`): +**File → Settings → Reporting → UDP Server → `127.0.0.1:2237`** + +The relay runs alongside your radio plugin — you can use direct USB or TCI at the same time. + +### Multicast Mode + +By default the relay uses **unicast** — WSJT-X sends packets directly to `127.0.0.1` and only this process receives them. + +If you want multiple applications on the same machine or LAN to receive WSJT-X packets simultaneously, enable multicast: + +1. In WSJT-X: **File → Settings → Reporting → UDP Server** — set the address to `224.0.0.1` +2. In `rig-bridge-config.json` (or via the setup UI at `http://localhost:5555`): + +```json +{ + "wsjtxRelay": { + "multicast": true, + "multicastGroup": "224.0.0.1", + "multicastInterface": "" + } +} +``` + +Leave `multicastInterface` blank unless you have multiple network adapters and need to specify which one to use (enter its local IP, e.g. `"192.168.1.100"`). + +> `224.0.0.1` is the WSJT-X conventional multicast group. It is link-local — packets are not routed across subnet boundaries. + --- ## OpenHamClock Setup @@ -109,15 +245,19 @@ Executables are output to the `dist/` folder. ## Troubleshooting -| Problem | Solution | -| ---------------------- | ------------------------------------------------------------------------- | -| No COM ports found | Install USB driver (Silicon Labs CP210x for Yaesu, FTDI for some Kenwood) | -| Port opens but no data | Check baud rate matches radio's CAT Rate setting | -| Icom not responding | Verify CI-V address matches your radio model | -| CORS errors in browser | The bridge allows all origins by default | -| Port already in use | Close flrig/rigctld if running — you don't need them anymore | -| PTT not responsive | Enable **Hardware Flow (RTS/CTS)** (especially for FT-991A/FT-710) | -| macOS Comms Failure | The bridge automatically applies a `stty` fix for CP210x drivers. | +| Problem | Solution | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| No COM ports found | Install USB driver (Silicon Labs CP210x for Yaesu, FTDI for some Kenwood) | +| Port opens but no data | Check baud rate matches radio's CAT Rate setting | +| Icom not responding | Verify CI-V address matches your radio model | +| CORS errors in browser | The bridge allows all origins by default | +| Port already in use | Close flrig/rigctld if running — you don't need them anymore | +| PTT not responsive | Enable **Hardware Flow (RTS/CTS)** (especially for FT-991A/FT-710) | +| macOS Comms Failure | The bridge automatically applies a `stty` fix for CP210x drivers. | +| TCI: Connection refused | Enable TCI in your SDR app (Thetis → Setup → CAT Control → Enable TCI Server) | +| TCI: No frequency updates | Check `trx` / `vfo` index in config match the active transceiver in your SDR app | +| TCI: Remote SDR | Set `tci.host` to the IP of the machine running the SDR application | +| Multicast: no packets | Verify `multicastGroup` matches what WSJT-X sends to; check OS firewall allows multicast UDP; set `multicastInterface` to the correct NIC IP if multi-homed | --- @@ -154,12 +294,15 @@ rig-bridge/ │ └── plugins/ ├── usb/ - │ ├── index.js # USB serial lifecycle (open, reconnect, poll) - │ ├── protocol-yaesu.js # Yaesu CAT ASCII protocol - │ ├── protocol-kenwood.js# Kenwood ASCII protocol - │ └── protocol-icom.js # Icom CI-V binary protocol + │ ├── index.js # USB serial lifecycle (open, reconnect, poll) + │ ├── protocol-yaesu.js # Yaesu CAT ASCII protocol + │ ├── protocol-kenwood.js # Kenwood ASCII protocol + │ └── protocol-icom.js # Icom CI-V binary protocol + ├── tci.js # TCI/SDR WebSocket plugin (Thetis, ExpertSDR, etc.) ├── rigctld.js # rigctld TCP plugin - └── flrig.js # flrig XML-RPC plugin + ├── flrig.js # flrig XML-RPC plugin + ├── mock.js # Simulated radio for testing (no hardware needed) + └── wsjtx-relay.js # WSJT-X UDP listener → OpenHamClock relay ``` --- @@ -172,7 +315,7 @@ Each plugin exports an object with the following shape: module.exports = { id: 'my-plugin', // Unique identifier (matches config.radio.type) name: 'My Plugin', // Human-readable name - category: 'rig', // 'rig' | 'rotator' | 'logger' | 'other' + category: 'rig', // 'rig' | 'integration' | 'rotator' | 'logger' | 'other' configKey: 'radio', // Which config section this plugin reads create(config, { updateState, state }) { @@ -205,6 +348,7 @@ module.exports = { **Categories:** - `rig` — radio control; the bridge dispatches `/freq`, `/mode`, `/ptt` to the active rig plugin +- `integration` — background service plugins (e.g. WSJT-X relay); started via `registry.connectIntegrations()` - `rotator`, `logger`, `other` — use `registerRoutes(app)` to expose their own endpoints To register a plugin at startup, call `registry.register(descriptor)` in `rig-bridge.js` before `registry.connectActive()`. diff --git a/rig-bridge/core/config.js b/rig-bridge/core/config.js index 9861b9e4..306f1d97 100644 --- a/rig-bridge/core/config.js +++ b/rig-bridge/core/config.js @@ -12,10 +12,12 @@ const CONFIG_PATH = path.join(CONFIG_DIR, 'rig-bridge-config.json'); const DEFAULT_CONFIG = { port: 5555, + bindAddress: '127.0.0.1', // Bind to localhost only; set to '0.0.0.0' for LAN access + corsOrigins: '', // Extra allowed CORS origins (comma-separated); OHC origins always allowed debug: false, // Centralized verbose CAT logging flag logging: true, // Enable/disable console log capture & broadcast to UI radio: { - type: 'none', // none | yaesu | kenwood | icom | flrig | rigctld + type: 'none', // none | yaesu | kenwood | icom | flrig | rigctld | tci serialPort: '', // COM3, /dev/ttyUSB0, etc. baudRate: 38400, dataBits: 8, @@ -33,6 +35,12 @@ const DEFAULT_CONFIG = { flrigHost: '127.0.0.1', flrigPort: 12345, }, + tci: { + host: 'localhost', + port: 40001, + trx: 0, // transceiver index (0 = primary) + vfo: 0, // VFO index (0 = A, 1 = B) + }, wsjtxRelay: { enabled: false, url: '', // OpenHamClock server URL (e.g. https://openhamclock.com) @@ -41,6 +49,9 @@ const DEFAULT_CONFIG = { udpPort: 2237, // UDP port to listen on for WSJT-X packets batchInterval: 2000, // Batch send interval in ms verbose: false, // Log all decoded messages + multicast: false, // Join a multicast group instead of unicast + multicastGroup: '224.0.0.1', // WSJT-X conventional multicast group + multicastInterface: '', // Local NIC IP for multi-homed systems; '' = let OS choose }, }; @@ -63,6 +74,7 @@ function loadConfig() { ...DEFAULT_CONFIG, ...raw, radio: { ...DEFAULT_CONFIG.radio, ...(raw.radio || {}) }, + tci: { ...DEFAULT_CONFIG.tci, ...(raw.tci || {}) }, wsjtxRelay: { ...DEFAULT_CONFIG.wsjtxRelay, ...(raw.wsjtxRelay || {}) }, // Coerce logging to boolean in case the stored value is a string logging: raw.logging !== undefined ? !!raw.logging : DEFAULT_CONFIG.logging, @@ -87,6 +99,7 @@ function applyCliArgs() { const args = process.argv.slice(2); for (let i = 0; i < args.length; i++) { if (args[i] === '--port') config.port = parseInt(args[++i]); + if (args[i] === '--bind') config.bindAddress = args[++i]; if (args[i] === '--debug') config.debug = true; } } diff --git a/rig-bridge/core/plugin-registry.js b/rig-bridge/core/plugin-registry.js index a8d052ab..ba77aead 100644 --- a/rig-bridge/core/plugin-registry.js +++ b/rig-bridge/core/plugin-registry.js @@ -47,7 +47,7 @@ class PluginRegistry { } // Single-export rig plugins - for (const file of ['rigctld', 'flrig', 'mock']) { + for (const file of ['rigctld', 'flrig', 'mock', 'tci']) { try { const p = require(`../plugins/${file}`); this._descriptors.set(p.id, p); diff --git a/rig-bridge/core/server.js b/rig-bridge/core/server.js index 2eb85bc7..ddb8eb1b 100644 --- a/rig-bridge/core/server.js +++ b/rig-bridge/core/server.js @@ -244,6 +244,8 @@ function buildSetupHtml(version) { } .icom-addr { display: none; } .icom-addr.show { display: block; } + .tci-opts { display: none; } + .tci-opts.show { display: block; } .ohc-instructions { background: #0f1923; border: 1px dashed #2a3040; @@ -389,6 +391,9 @@ function buildSetupHtml(version) { + + + @@ -457,6 +462,31 @@ function buildSetupHtml(version) {
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
Enable TCI in your SDR app: Thetis → Setup → CAT Control → Enable TCI Server (port 40001)
+
+
@@ -524,6 +554,28 @@ function buildSetupHtml(version) {
+
+ + Enable Multicast +
+
+ Join a UDP multicast group so multiple apps can receive WSJT-X packets simultaneously. + In WSJT-X set UDP Server to 224.0.0.1 instead of 127.0.0.1. +
+ + +
Status:
@@ -592,7 +644,11 @@ function buildSetupHtml(version) { document.getElementById('wsjtxSession').value = w.session || ''; document.getElementById('wsjtxPort').value = w.udpPort || 2237; document.getElementById('wsjtxInterval').value = w.batchInterval || 2000; + document.getElementById('wsjtxMulticast').checked = !!w.multicast; + document.getElementById('wsjtxMulticastGroup').value = w.multicastGroup || '224.0.0.1'; + document.getElementById('wsjtxMulticastInterface').value = w.multicastInterface || ''; toggleWsjtxOpts(); + toggleWsjtxMulticastOpts(); } function toggleWsjtxOpts() { @@ -600,6 +656,11 @@ function buildSetupHtml(version) { document.getElementById('wsjtxOpts').style.display = enabled ? 'block' : 'none'; } + function toggleWsjtxMulticastOpts() { + const on = document.getElementById('wsjtxMulticast').checked; + document.getElementById('wsjtxMulticastOpts').style.display = on ? 'block' : 'none'; + } + async function saveIntegrations() { const wsjtxRelay = { enabled: document.getElementById('wsjtxEnabled').checked, @@ -608,6 +669,9 @@ function buildSetupHtml(version) { session: document.getElementById('wsjtxSession').value.trim(), udpPort: parseInt(document.getElementById('wsjtxPort').value) || 2237, batchInterval: parseInt(document.getElementById('wsjtxInterval').value) || 2000, + multicast: document.getElementById('wsjtxMulticast').checked, + multicastGroup: document.getElementById('wsjtxMulticastGroup').value.trim() || '224.0.0.1', + multicastInterface: document.getElementById('wsjtxMulticastInterface').value.trim(), }; try { const res = await fetch('/api/config', { @@ -684,6 +748,11 @@ function buildSetupHtml(version) { r.type === 'rigctld' ? (r.rigctldHost || '127.0.0.1') : (r.flrigHost || '127.0.0.1'); document.getElementById('legacyPort').value = r.type === 'rigctld' ? (r.rigctldPort || 4532) : (r.flrigPort || 12345); + const tci = cfg.tci || {}; + document.getElementById('tciHost').value = tci.host || 'localhost'; + document.getElementById('tciPort').value = tci.port || 40001; + document.getElementById('tciTrx').value = tci.trx ?? 0; + document.getElementById('tciVfo').value = tci.vfo ?? 0; onTypeChange(true); // Don't overwrite loaded values with model defaults } @@ -691,10 +760,12 @@ function buildSetupHtml(version) { const type = document.getElementById('radioType').value; const isDirect = ['yaesu', 'kenwood', 'icom'].includes(type); const isLegacy = ['flrig', 'rigctld'].includes(type); + const isTci = type === 'tci'; document.getElementById('serialOpts').className = 'serial-opts' + (isDirect ? ' show' : ''); document.getElementById('legacyOpts').className = 'legacy-opts' + (isLegacy ? ' show' : ''); document.getElementById('icomAddr').className = 'icom-addr' + (type === 'icom' ? ' show' : ''); + document.getElementById('tciOpts').className = 'tci-opts' + (isTci ? ' show' : ''); if (!skipDefaults) { if (type === 'yaesu') { @@ -787,11 +858,23 @@ function buildSetupHtml(version) { radio.flrigPort = parseInt(document.getElementById('legacyPort').value); } + const tci = { + host: document.getElementById('tciHost').value.trim() || 'localhost', + port: parseInt(document.getElementById('tciPort').value) || 40001, + trx: Math.max(0, parseInt(document.getElementById('tciTrx').value) || 0), + vfo: Math.max(0, parseInt(document.getElementById('tciVfo').value) || 0), + }; + + if (type === 'tci') { + if (!tci.host) return showToast('TCI host cannot be empty', 'error'); + if (tci.port < 1 || tci.port > 65535) return showToast('TCI port must be 1–65535', 'error'); + } + try { const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ radio }), + body: JSON.stringify({ radio, tci }), }); const data = await res.json(); if (data.success) { @@ -989,7 +1072,37 @@ function buildSetupHtml(version) { function createServer(registry, version) { const app = express(); - app.use(cors()); + + // SECURITY: Restrict CORS to OpenHamClock origins instead of wildcard. + // Wildcard CORS allows any website the user visits to silently call localhost:5555 + // endpoints (including PTT) via the browser's fetch API. + const allowedOrigins = (config.corsOrigins || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + // Always allow the local setup UI and common OHC origins + const defaultOrigins = [ + `http://localhost:${config.port}`, + `http://127.0.0.1:${config.port}`, + 'http://localhost:3000', + 'http://127.0.0.1:3000', + 'https://openhamclock.com', + 'https://www.openhamclock.com', + ]; + const origins = [...new Set([...defaultOrigins, ...allowedOrigins])]; + + app.use( + cors({ + origin: (requestOrigin, callback) => { + // Allow requests with no origin (curl, Postman, server-to-server) + if (!requestOrigin) return callback(null, true); + if (origins.includes(requestOrigin)) return callback(null, true); + callback(null, false); + }, + methods: ['GET', 'POST'], + }), + ); app.use(express.json()); // Allow plugins to register their own routes @@ -1044,6 +1157,19 @@ function createServer(registry, version) { }); // ─── API: Get/Set config ─── + + // Validate serial port paths to prevent arbitrary file access + const isValidSerialPort = (p) => { + if (!p || typeof p !== 'string') return false; + // Windows: COM1-COM256 + if (/^COM\d{1,3}$/i.test(p)) return true; + // Linux: /dev/ttyUSB*, /dev/ttyACM*, /dev/ttyS*, /dev/ttyAMA* + if (/^\/dev\/tty(USB|ACM|S|AMA)\d+$/.test(p)) return true; + // macOS: /dev/cu.* or /dev/tty.* + if (/^\/dev\/(cu|tty)\.[A-Za-z0-9._-]+$/.test(p)) return true; + return false; + }; + app.get('/api/config', (req, res) => { res.json(config); }); @@ -1052,6 +1178,10 @@ function createServer(registry, version) { const newConfig = req.body; if (newConfig.port) config.port = newConfig.port; if (newConfig.radio) { + // Validate serial port path if provided + if (newConfig.radio.serialPort && !isValidSerialPort(newConfig.radio.serialPort)) { + return res.status(400).json({ success: false, error: 'Invalid serial port path' }); + } config.radio = { ...config.radio, ...newConfig.radio }; } if (typeof newConfig.logging === 'boolean') { @@ -1060,6 +1190,9 @@ function createServer(registry, version) { if (newConfig.wsjtxRelay) { config.wsjtxRelay = { ...config.wsjtxRelay, ...newConfig.wsjtxRelay }; } + if (newConfig.tci) { + config.tci = { ...config.tci, ...newConfig.tci }; + } // macOS: tty.* (dial-in) blocks open() — silently upgrade to cu.* (call-out) if (process.platform === 'darwin' && config.radio.serialPort?.startsWith('/dev/tty.')) { config.radio.serialPort = config.radio.serialPort.replace('/dev/tty.', '/dev/cu.'); @@ -1082,6 +1215,9 @@ function createServer(registry, version) { // ─── API: Test serial port connection ─── app.post('/api/test', async (req, res) => { const testPort = req.body.serialPort || config.radio.serialPort; + if (!isValidSerialPort(testPort)) { + return res.json({ success: false, error: 'Invalid serial port path' }); + } const testBaud = req.body.baudRate || config.radio.baudRate; const testStopBits = req.body.stopBits || config.radio.stopBits || 1; const testRtscts = req.body.rtscts !== undefined ? !!req.body.rtscts : !!config.radio.rtscts; @@ -1183,7 +1319,13 @@ function createServer(registry, version) { function startServer(port, registry, version) { const app = createServer(registry, version); - const server = app.listen(port, '0.0.0.0', () => { + + // SECURITY: Bind to localhost by default. Set bindAddress to '0.0.0.0' in + // rig-bridge-config.json only if you need LAN access (e.g. bridge on a Pi, + // browser on a desktop). + const bindAddress = config.bindAddress || '127.0.0.1'; + + const server = app.listen(port, bindAddress, () => { const versionLabel = `v${version}`.padEnd(8); console.log(''); console.log(' ╔══════════════════════════════════════════════╗'); @@ -1191,6 +1333,9 @@ function startServer(port, registry, version) { console.log(' ╠══════════════════════════════════════════════╣'); console.log(` ║ Setup UI: http://localhost:${port} ║`); console.log(` ║ Radio: ${(config.radio.type || 'none').padEnd(30)}║`); + if (bindAddress !== '127.0.0.1') { + console.log(` ║ ⚠ Bound to ${bindAddress.padEnd(33)}║`); + } console.log(' ╚══════════════════════════════════════════════╝'); console.log(''); }); diff --git a/rig-bridge/package-lock.json b/rig-bridge/package-lock.json index 9c1d495f..d8028f8a 100644 --- a/rig-bridge/package-lock.json +++ b/rig-bridge/package-lock.json @@ -1,16 +1,17 @@ { "name": "openhamclock-rig-bridge", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhamclock-rig-bridge", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "cors": "^2.8.5", "express": "^4.18.2", "serialport": "^12.0.0", + "ws": "^8.14.2", "xmlrpc": "^1.3.2" }, "bin": { @@ -2746,6 +2747,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", diff --git a/rig-bridge/package.json b/rig-bridge/package.json index 007f6a96..dabfc28a 100644 --- a/rig-bridge/package.json +++ b/rig-bridge/package.json @@ -1,6 +1,6 @@ { "name": "openhamclock-rig-bridge", - "version": "1.1.0", + "version": "1.2.0", "description": "Universal rig control bridge for OpenHamClock — plugin architecture supporting USB, flrig, rigctld, and more", "main": "rig-bridge.js", "bin": "rig-bridge.js", @@ -24,6 +24,7 @@ "cors": "^2.8.5", "express": "^4.18.2", "serialport": "^12.0.0", + "ws": "^8.14.2", "xmlrpc": "^1.3.2" }, "devDependencies": { diff --git a/rig-bridge/plugins/tci.js b/rig-bridge/plugins/tci.js new file mode 100644 index 00000000..5bdde6c1 --- /dev/null +++ b/rig-bridge/plugins/tci.js @@ -0,0 +1,298 @@ +'use strict'; +/** + * plugins/tci.js — TCI (Transceiver Control Interface) WebSocket plugin + * + * Connects to an SDR application's TCI server via WebSocket and provides + * real-time rig control without polling. TCI pushes frequency, mode, and + * PTT changes as they happen. + * + * Supported applications: + * Thetis — Hermes Lite 2, ANAN (default port 40001) + * ExpertSDR — SunSDR2 (default port 40001) + * SmartSDR — Flex (via TCI bridge) + * + * Config key: config.tci { host, port, trx, vfo } + * + * TCI reference: https://github.com/ExpertSDR3/TCI + */ + +// TCI mode name → OpenHamClock mode name +const TCI_MODES = { + am: 'AM', + sam: 'SAM', + dsb: 'DSB', + lsb: 'LSB', + usb: 'USB', + cw: 'CW', + nfm: 'FM', + wfm: 'WFM', + digl: 'DATA-LSB', + digu: 'DATA-USB', + spec: 'SPEC', + drm: 'DRM', +}; + +// OpenHamClock mode name → TCI mode name +const TCI_MODES_REV = {}; +for (const [tci, ohc] of Object.entries(TCI_MODES)) { + TCI_MODES_REV[ohc] = tci; +} + +module.exports = { + id: 'tci', + name: 'TCI/SDR (WebSocket)', + category: 'rig', + configKey: 'tci', + + create(config, { updateState, state }) { + // Resolve WebSocket implementation: prefer 'ws' npm package (works inside + // pkg snapshots), fall back to Node 21+ built-in WebSocket. + let WS; + let wsSource; + try { + WS = require('ws'); + wsSource = 'ws npm'; + } catch { + console.warn('[TCI] ws npm package not found (run: npm install) — falling back to native WebSocket'); + if (typeof globalThis.WebSocket !== 'undefined') { + WS = globalThis.WebSocket; + wsSource = 'native (Node built-in)'; + } else { + console.error('[TCI] WebSocket library not available. Run: npm install ws'); + WS = null; + } + } + if (WS) console.log(`[TCI] WebSocket implementation: ${wsSource}`); + + const tciCfg = config.tci || {}; + const trx = tciCfg.trx ?? 0; + const vfo = tciCfg.vfo ?? 0; + const host = tciCfg.host || 'localhost'; + const port = tciCfg.port || 40001; + const url = `ws://${host}:${port}`; + + let ws = null; + let reconnectTimer = null; + let wasExplicitlyDisconnected = false; + let msgBuffer = ''; // TCI messages end with ';', may arrive chunked + + function parseMessage(msg) { + // Accumulate into buffer; split on ';' delimiter + msgBuffer += msg; + const parts = msgBuffer.split(';'); + // Last element is either empty (complete) or a partial message + msgBuffer = parts.pop(); + + for (const raw of parts) { + const trimmed = raw.trim(); + if (!trimmed) continue; + + // Format: "name:arg1,arg2,..." or just "name" + const colonIdx = trimmed.indexOf(':'); + const name = colonIdx >= 0 ? trimmed.slice(0, colonIdx).toLowerCase() : trimmed.toLowerCase(); + const argStr = colonIdx >= 0 ? trimmed.slice(colonIdx + 1) : ''; + const args = argStr ? argStr.split(',') : []; + + switch (name) { + case 'vfo': { + // vfo:rx,sub_vfo,freq_hz + const rxIdx = parseInt(args[0]); + const vfoIdx = parseInt(args[1]); + if (rxIdx === trx && vfoIdx === vfo) { + const freq = parseInt(args[2]); + if (freq > 0 && state.freq !== freq) { + console.log(`[TCI] freq → ${(freq / 1e6).toFixed(6)} MHz`); + updateState('freq', freq); + } + } + break; + } + case 'modulation': { + // modulation:rx,mode_name + const rxIdx = parseInt(args[0]); + if (rxIdx === trx) { + const modeName = (args[1] || '').toLowerCase(); + const ohcMode = TCI_MODES[modeName] || modeName.toUpperCase(); + if (state.mode !== ohcMode) { + console.log(`[TCI] mode → ${ohcMode}`); + updateState('mode', ohcMode); + } + } + break; + } + case 'trx': { + // trx:rx,true|false — transmit state + const rxIdx = parseInt(args[0]); + if (rxIdx === trx) { + const ptt = args[1] === 'true'; + if (state.ptt !== ptt) { + console.log(`[TCI] PTT → ${ptt ? 'TX' : 'RX'}`); + updateState('ptt', ptt); + } + } + break; + } + case 'rx_filter_band': { + // rx_filter_band:rx,low_hz,high_hz + const rxIdx = parseInt(args[0]); + if (rxIdx === trx) { + const lo = parseInt(args[1]); + const hi = parseInt(args[2]); + const width = hi - lo; + if (width > 0 && state.width !== width) updateState('width', width); + } + break; + } + case 'protocol': + console.log(`[TCI] Server protocol: ${argStr}`); + break; + case 'device': + console.log(`[TCI] Device: ${argStr}`); + break; + case 'receive_only': + if (args[0] === 'true') { + console.log('[TCI] ⚠️ Radio is in receive-only mode (PTT disabled server-side)'); + } + break; + case 'ready': + console.log('[TCI] Server ready'); + break; + // Silently ignore high-volume / irrelevant TCI messages + case 'iq_samplerate': + case 'audio_samplerate': + case 'iq_start': + case 'iq_stop': + case 'audio_start': + case 'audio_stop': + case 'spot': + case 'drive': + case 'sql_enable': + case 'mute': + case 'rx_enable': + case 'sensors_enable': + case 'cw_macros_speed': + case 'volume': + case 'rx_smeter': + break; + default: + // Uncomment for debugging unknown TCI messages: + // console.log(`[TCI] Unhandled: ${trimmed}`); + break; + } + } + } + + function send(data) { + if (!ws || ws.readyState !== 1 /* OPEN */) return false; + try { + ws.send(data); + return true; + } catch (e) { + console.error(`[TCI] Send error: ${e.message}`); + return false; + } + } + + function scheduleReconnect() { + if (reconnectTimer || wasExplicitlyDisconnected) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); // eslint-disable-line no-use-before-define + }, 5000); + } + + function connect() { + if (ws || wasExplicitlyDisconnected) return; + if (!WS) return; + + console.log(`[TCI] Connecting to ${url}...`); + try { + // perMessageDeflate disabled for compatibility with non-standard TCI servers + // (e.g. Thetis) that may not handle WebSocket extension negotiation correctly. + ws = new WS(url, wsSource === 'ws npm' ? { perMessageDeflate: false } : undefined); + } catch (e) { + console.error(`[TCI] Connection failed: ${e.message}`); + scheduleReconnect(); + return; + } + + // Use addEventListener — works on both 'ws' npm lib AND Node 21+ native + // WebSocket. (.on() is ws-library-only and crashes with native WebSocket.) + ws.addEventListener('open', () => { + console.log(`[TCI] ✅ Connected to ${url}`); + msgBuffer = ''; + updateState('connected', true); + // Initiate TCI session — server will send device info, then state dump + ws.send('start;'); + }); + + ws.addEventListener('message', (evt) => { + // ws lib: evt is the data directly (string or Buffer) + // native WebSocket: evt is a MessageEvent with .data property + const raw = evt.data !== undefined ? evt.data : evt; + const msg = typeof raw === 'string' ? raw : raw.toString('utf8'); + parseMessage(msg); + }); + + ws.addEventListener('error', (evt) => { + // 'error' fires before 'close' — just log; reconnect happens on 'close' + const err = evt.error || evt; + const msg = (err && err.message) || ''; + if (err && err.code === 'ECONNREFUSED') { + console.error('[TCI] Connection refused — is the SDR app running with TCI enabled?'); + } else if (msg.toLowerCase().includes('sec-websocket-accept') || msg.toLowerCase().includes('incorrect hash')) { + console.error('[TCI] WebSocket handshake rejected by server (invalid Sec-WebSocket-Accept).'); + console.error( + '[TCI] Possible causes: TCI not enabled in SDR app, incompatible SDR version, or ws npm package not installed (run: npm install).', + ); + console.error(`[TCI] Active WebSocket implementation: ${wsSource}`); + } else { + console.error(`[TCI] Error: ${msg || 'connection error'}`); + } + }); + + ws.addEventListener('close', () => { + console.log('[TCI] Disconnected'); + ws = null; + updateState('connected', false); + scheduleReconnect(); + }); + } + + function disconnect() { + wasExplicitlyDisconnected = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (ws) { + try { + ws.send('stop;'); + ws.close(); + } catch (e) {} + ws = null; + } + msgBuffer = ''; + updateState('connected', false); + console.log('[TCI] Disconnected'); + } + + function setFreq(hz) { + console.log(`[TCI] SET FREQ: ${(hz / 1e6).toFixed(6)} MHz`); + send(`VFO:${trx},${vfo},${hz};`); + } + + function setMode(mode) { + console.log(`[TCI] SET MODE: ${mode}`); + const tciMode = TCI_MODES_REV[mode] || TCI_MODES_REV[mode.toUpperCase()] || mode.toLowerCase(); + send(`MODULATION:${trx},${tciMode};`); + } + + function setPTT(on) { + console.log(`[TCI] SET PTT: ${on ? 'TX' : 'RX'}`); + send(`TRX:${trx},${on};`); + } + + return { connect, disconnect, setFreq, setMode, setPTT }; + }, +}; diff --git a/rig-bridge/plugins/wsjtx-relay.js b/rig-bridge/plugins/wsjtx-relay.js index af813604..47482895 100644 --- a/rig-bridge/plugins/wsjtx-relay.js +++ b/rig-bridge/plugins/wsjtx-relay.js @@ -10,9 +10,12 @@ * url string OpenHamClock server URL (e.g. https://openhamclock.com) * key string Relay authentication key * session string Browser session ID for per-user isolation - * udpPort number UDP port to listen on (default: 2237) - * batchInterval number Batch send interval in ms (default: 2000) - * verbose boolean Log all decoded messages (default: false) + * udpPort number UDP port to listen on (default: 2237) + * batchInterval number Batch send interval in ms (default: 2000) + * verbose boolean Log all decoded messages (default: false) + * multicast boolean Join a multicast group (default: false) + * multicastGroup string Multicast group IP (default: '224.0.0.1') + * multicastInterface string Local NIC IP for multi-homed systems; '' = OS default */ const dgram = require('dgram'); @@ -249,6 +252,10 @@ const descriptor = { const serverUrl = (cfg.url || '').replace(/\/$/, ''); const relayEndpoint = `${serverUrl}/api/wsjtx/relay`; + const mcEnabled = !!cfg.multicast; + const mcGroup = cfg.multicastGroup || '224.0.0.1'; + const mcInterface = cfg.multicastInterface || undefined; // undefined → OS picks NIC + let socket = null; let batchTimer = null; let heartbeatInterval = null; @@ -394,6 +401,26 @@ const descriptor = { return; } + // SECURITY: Validate relay URL to prevent SSRF via config API. + // The relay should only POST to legitimate OpenHamClock servers, not internal services. + try { + const parsed = new URL(cfg.url); + if (!['http:', 'https:'].includes(parsed.protocol)) { + console.error(`[WsjtxRelay] Blocked: only http/https URLs allowed (got ${parsed.protocol})`); + return; + } + const host = parsed.hostname.toLowerCase(); + const blockedHosts = + /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.|::1|fe80:|fc00:|fd00:)/; + if (blockedHosts.test(host)) { + console.error(`[WsjtxRelay] Blocked: relay URL must not point to a private/internal address (${host})`); + return; + } + } catch (e) { + console.error(`[WsjtxRelay] Invalid relay URL: ${e.message}`); + return; + } + const udpPort = cfg.udpPort || 2237; socket = dgram.createSocket('udp4'); @@ -426,6 +453,19 @@ const descriptor = { console.log(`[WsjtxRelay] Listening for WSJT-X on UDP ${addr.address}:${addr.port}`); console.log(`[WsjtxRelay] Relaying to ${serverUrl}`); + if (mcEnabled) { + try { + socket.addMembership(mcGroup, mcInterface); + const ifaceLabel = mcInterface || '0.0.0.0 (OS default)'; + console.log(`[WsjtxRelay] Joined multicast group ${mcGroup} on interface ${ifaceLabel}`); + } catch (err) { + console.error(`[WsjtxRelay] Failed to join multicast group ${mcGroup}: ${err.message}`); + console.error( + `[WsjtxRelay] Falling back to unicast — check that ${mcGroup} is a valid multicast address and your OS supports multicast on this interface`, + ); + } + } + scheduleBatch(); // Initial health check then heartbeat @@ -469,6 +509,15 @@ const descriptor = { healthInterval = null; } if (socket) { + if (mcEnabled) { + try { + socket.dropMembership(mcGroup, mcInterface); + console.log(`[WsjtxRelay] Left multicast group ${mcGroup}`); + } catch (err) { + // Socket may already be closing or membership was never joined — safe to ignore + console.error(`[WsjtxRelay] dropMembership failed (non-fatal): ${err.message}`); + } + } try { socket.close(); } catch (e) {} @@ -488,6 +537,8 @@ const descriptor = { consecutiveErrors, udpPort: cfg.udpPort || 2237, serverUrl, + multicast: mcEnabled, + multicastGroup: mcEnabled ? mcGroup : null, }; } diff --git a/rig-bridge/rig-bridge-config.example.json b/rig-bridge/rig-bridge-config.example.json index 14882186..8e52467b 100644 --- a/rig-bridge/rig-bridge-config.example.json +++ b/rig-bridge/rig-bridge-config.example.json @@ -1,5 +1,7 @@ { "port": 5555, + "bindAddress": "127.0.0.1", + "corsOrigins": "", "logging": true, "radio": { "type": "none", @@ -26,6 +28,9 @@ "session": "", "udpPort": 2237, "batchInterval": 2000, - "verbose": false + "verbose": false, + "multicast": false, + "multicastGroup": "224.0.0.1", + "multicastInterface": "" } } diff --git a/rig-bridge/rig-bridge.js b/rig-bridge/rig-bridge.js index 585d13d4..1aaa3828 100644 --- a/rig-bridge/rig-bridge.js +++ b/rig-bridge/rig-bridge.js @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * OpenHamClock Rig Bridge v1.1.0 + * OpenHamClock Rig Bridge v1.2.0 * * Universal bridge connecting radios and other ham radio services to OpenHamClock. * Uses a plugin architecture — each integration is a standalone module. @@ -19,7 +19,7 @@ 'use strict'; -const VERSION = '1.1.0'; +const VERSION = '1.2.0'; const { config, loadConfig, applyCliArgs } = require('./core/config'); const { updateState, state } = require('./core/state'); @@ -46,6 +46,7 @@ Usage: Options: --port HTTP port for setup UI (default: 5555) + --bind
Bind address (default: 127.0.0.1, use 0.0.0.0 for LAN) --debug Enable verbose CAT protocol logging --version, -v Print version and exit --help, -h Show this help message @@ -53,6 +54,7 @@ Options: Examples: node rig-bridge.js node rig-bridge.js --port 8080 --debug + node rig-bridge.js --bind 0.0.0.0 # Allow LAN access `); process.exit(0); } diff --git a/scripts/setup-pi.sh b/scripts/setup-pi.sh index 2b298e4f..6f81b25d 100755 --- a/scripts/setup-pi.sh +++ b/scripts/setup-pi.sh @@ -189,7 +189,7 @@ update_system() { # Install Node.js install_nodejs() { echo -e "${BLUE}>>> Installing Node.js ${NODE_VERSION}...${NC}" - + # Check if Node.js is already installed if command -v node &> /dev/null; then CURRENT_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) @@ -198,14 +198,55 @@ install_nodejs() { return fi fi - - # Install Node.js via NodeSource - curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | sudo -E bash - || { - echo -e "${RED}✗ NodeSource setup failed. Check your Debian version and internet connection.${NC}" - exit 1 - } - sudo apt-get install -y nodejs - + + ARCH=$(dpkg --print-architecture 2>/dev/null || uname -m) + + if [ "$ARCH" = "armhf" ]; then + # NodeSource dropped 32-bit ARM (armhf) support from Node.js 20 onwards. + # The official nodejs.org project still publishes armv7l tarballs, so we + # download and install those directly instead. + echo -e "${YELLOW}⚠ 32-bit ARM (armhf) detected — NodeSource does not support this architecture.${NC}" + echo -e "${BLUE} Downloading official Node.js ${NODE_VERSION} armv7l binary from nodejs.org...${NC}" + + NODE_DIST_BASE="https://nodejs.org/dist/latest-v${NODE_VERSION}.x" + NODE_TARBALL=$(curl -fsSL "$NODE_DIST_BASE/" \ + | grep -o "node-v[0-9.]*-linux-armv7l\.tar\.gz" \ + | head -1) + + if [ -z "$NODE_TARBALL" ]; then + echo -e "${RED}✗ Could not locate a Node.js ${NODE_VERSION} armv7l release on nodejs.org.${NC}" + exit 1 + fi + + # Download to a temp file with retry support. + # Piping curl directly into tar gives no retry opportunity on a + # dropped connection; saving to disk first lets curl resume/retry + # and keeps extraction separate so errors are easier to diagnose. + echo -e "${BLUE} Installing $NODE_TARBALL ...${NC}" + NODE_TMPFILE=$(mktemp /tmp/nodejs-armv7l-XXXXXX.tar.gz) + curl -fsSL \ + --retry 3 --retry-delay 5 --retry-connrefused \ + "$NODE_DIST_BASE/$NODE_TARBALL" \ + -o "$NODE_TMPFILE" || { + rm -f "$NODE_TMPFILE" + echo -e "${RED}✗ Failed to download Node.js armv7l binary (tried 3 times).${NC}" + exit 1 + } + sudo tar -xz -C /usr/local --strip-components=1 -f "$NODE_TMPFILE" || { + rm -f "$NODE_TMPFILE" + echo -e "${RED}✗ Failed to extract Node.js armv7l binary.${NC}" + exit 1 + } + rm -f "$NODE_TMPFILE" + else + # amd64 and arm64 are supported by NodeSource. + curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | sudo -E bash - || { + echo -e "${RED}✗ NodeSource setup failed. Check your Debian version and internet connection.${NC}" + exit 1 + } + sudo apt-get install -y nodejs + fi + echo -e "${GREEN}✓ Node.js $(node -v) installed${NC}" } @@ -249,15 +290,24 @@ setup_repository() { # Prevent file permission changes from blocking future updates git config core.fileMode false 2>/dev/null - # Install npm dependencies - npm install --include=dev - + # Install npm dependencies. + # --ignore-scripts skips lifecycle hooks (postinstall, prepare, etc.) that are + # irrelevant or harmful on ARM Linux — most notably electron-winstaller's + # postinstall, which tries to copy vendor/7z-arm.exe and fails on a Pi because + # that Windows-only file is not shipped for Linux targets. + # Husky git-hooks (prepare) are also skipped, which is fine on a production Pi. + ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm install --include=dev --ignore-scripts + # Download vendor assets (fonts, Leaflet) for self-hosting — no external CDN requests echo -e "${BLUE}>>> Downloading vendor assets for privacy...${NC}" bash scripts/vendor-download.sh || echo -e "${YELLOW}⚠ Vendor download failed — will fall back to CDN${NC}" - + # Build frontend for production npm run build + + # Remove dev dependencies (electron, electron-builder, etc.) after the build. + # This frees ~500 MB of node_modules that are not needed at runtime on the Pi. + npm prune --omit=dev # Make update script executable chmod +x scripts/update.sh 2>/dev/null || true diff --git a/server.js b/server.js index cde999a3..065da79d 100644 --- a/server.js +++ b/server.js @@ -27,6 +27,7 @@ const net = require('net'); const dgram = require('dgram'); const fs = require('fs'); const { execFile, spawn } = require('child_process'); +const dns = require('dns'); const mqttLib = require('mqtt'); const { initCtyData, getCtyData, lookupCall } = require('./src/server/ctydat.js'); @@ -93,10 +94,15 @@ const app = express(); const PORT = Number(process.env.PORT) || 3000; const HOST = process.env.HOST || '0.0.0.0'; -// Trust first proxy (Railway, Docker, nginx, etc.) so rate limiting -// uses X-Forwarded-For (real client IP) instead of the proxy's IP. -// Without this, ALL users behind a reverse proxy share one rate limit bucket. -app.set('trust proxy', 1); +// Trust proxy setting — controls whether X-Forwarded-For headers are trusted. +// Railway/Docker/nginx deployments need this for correct client IP detection. +// Pi/local installs should NOT trust proxy headers since clients can forge them, +// bypassing rate limiting by sending a different X-Forwarded-For with each request. +// Default: trust proxy if running on Railway (PORT env is always set), otherwise don't. +const TRUST_PROXY = process.env.TRUST_PROXY !== undefined + ? (process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1' ? 1 : false) + : (process.env.RAILWAY_ENVIRONMENT || process.env.RAILWAY_PROJECT_ID) ? 1 : false; +app.set('trust proxy', TRUST_PROXY); // Security: API key for write operations (set in .env to protect POST endpoints) // If not set, write endpoints are open (backward-compatible for local installs) @@ -407,11 +413,40 @@ app.use((req, res, next) => { next(); }); -// CORS — restrict to same origin by default; allow override via env -const CORS_ORIGINS = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').map((s) => s.trim()) : true; // true = reflect request origin (same as before for local installs) +// CORS — explicit origin allowlist to prevent malicious websites from accessing the API. +// origin: true (the old default) reflects any requesting origin, which allows any website +// the user visits to silently read their callsign, coordinates, QSO data, and (without +// API_WRITE_KEY) write settings, move their rotator, or restart the server. +const CORS_ORIGINS = process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(',').map((s) => s.trim()) + : null; // null = no extra origins, only defaults below + +const defaultOrigins = [ + `http://localhost:${PORT}`, + `http://127.0.0.1:${PORT}`, + 'http://localhost:3000', + 'http://127.0.0.1:3000', + 'http://localhost:5173', // Vite dev server + 'http://127.0.0.1:5173', + 'https://openhamclock.com', + 'https://www.openhamclock.com', + 'https://openhamclock.app', + 'https://www.openhamclock.app', +]; +const allowedOrigins = new Set([...defaultOrigins, ...(CORS_ORIGINS || [])]); + app.use( cors({ - origin: CORS_ORIGINS, + origin: (requestOrigin, callback) => { + // Allow requests with no Origin header (curl, Postman, server-to-server, same-origin) + if (!requestOrigin) return callback(null, true); + if (allowedOrigins.has(requestOrigin)) return callback(null, true); + // Don't set CORS headers for unknown origins — the browser's same-origin policy + // will block cross-origin reads. Using callback(null, false) instead of throwing + // an Error avoids breaking same-origin static asset requests on Railway/staging + // where the deployment URL isn't in the allowlist. + callback(null, false); + }, methods: ['GET', 'POST'], maxAge: 86400, }), @@ -865,7 +900,7 @@ app.get('/api/rotator/status', (req, res) => { }); }); -app.post('/api/rotator/turn', async (req, res) => { +app.post('/api/rotator/turn', writeLimiter, requireWriteAuth, async (req, res) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); try { const { azimuth } = req.body || {}; @@ -886,7 +921,7 @@ app.post('/api/rotator/turn', async (req, res) => { } }); -app.post('/api/rotator/stop', async (req, res) => { +app.post('/api/rotator/stop', writeLimiter, requireWriteAuth, async (req, res) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); try { const result = await stopRotator(); @@ -1407,7 +1442,7 @@ const sessionTracker = { duration: now - session.firstSeen, durationFormatted: formatDuration(now - session.firstSeen), requests: session.requests, - ip: ip.replace(/\d+$/, 'x'), // Anonymize last octet + ip: ip.includes('.') ? ip.substring(0, ip.lastIndexOf('.') + 1) + 'x' : ip, // Anonymize last octet }); } activeList.sort((a, b) => b.duration - a.duration); @@ -1446,8 +1481,9 @@ app.use((req, res, next) => { rolloverVisitorStats(); // Track concurrent sessions for ALL requests (not just countable routes) - const sessionIp = - req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.connection?.remoteAddress || 'unknown'; + // Use req.ip which respects the trust proxy setting, not manual x-forwarded-for parsing + // which is trivially spoofable on installs without a reverse proxy. + const sessionIp = req.ip || req.connection?.remoteAddress || 'unknown'; if (req.path !== '/api/health' && !req.path.startsWith('/assets/')) { sessionTracker.touch(sessionIp, req.headers['user-agent']); } @@ -1455,8 +1491,7 @@ app.use((req, res, next) => { // Only count meaningful "visits" — initial page load or config fetch const countableRoutes = ['/', '/index.html', '/api/config']; if (countableRoutes.includes(req.path)) { - const ip = - req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.connection?.remoteAddress || 'unknown'; + const ip = req.ip || req.connection?.remoteAddress || 'unknown'; // Track today's visitors const isNewToday = !todayIPSet.has(ip); @@ -1477,7 +1512,7 @@ app.use((req, res, next) => { visitorStats.allTimeVisitors++; queueGeoIPLookup(ip); logInfo( - `[Stats] New visitor (#${visitorStats.uniqueIPsToday.length} today, #${visitorStats.allTimeVisitors} all-time) from ${ip.replace(/\d+$/, 'x')}`, + `[Stats] New visitor (#${visitorStats.uniqueIPsToday.length} today, #${visitorStats.allTimeVisitors} all-time) from ${ip.includes('.') ? ip.substring(0, ip.lastIndexOf('.') + 1) + 'x' : ip}`, ); } else if (isNewToday) { // Existing all-time visitor but new today — queue GeoIP in case cache was lost @@ -2034,7 +2069,7 @@ app.get('/api/solar-indices', async (req, res) => { }); // NASA SDO Solar Image Proxy — caches SDO/AIA images so clients don't hit NASA directly. -// Multi-source: tries SDO direct first (works for self-hosters), then Helioviewer API (works from cloud). +// Multi-source failover: SDO direct → LMSAL Sun Today (Lockheed) → Helioviewer API. const sdoImageCache = new Map(); // key: imageType → { buffer, contentType, timestamp } const SDO_CACHE_TTL = 15 * 60 * 1000; // 15 minutes const SDO_STALE_SERVE = 6 * 60 * 60 * 1000; // Serve stale up to 6 hours @@ -2099,6 +2134,31 @@ const fetchFromHelioviewer = async (type, timeoutMs = 20000) => { } }; +// Helper: fetch from LMSAL Sun Today (Lockheed Martin Solar & Astrophysics Lab) +// Independent of Goddard infrastructure — useful when sdo.gsfc.nasa.gov is down. +// URL pattern: t{type}.jpg = 256x256 thumbnail (AIA channels only, no HMI) +const LMSAL_TYPES = new Set(['0193', '0304', '0171', '0094']); +const fetchFromLMSAL = async (type, timeoutMs = 15000) => { + if (!LMSAL_TYPES.has(type)) throw new Error(`LMSAL does not serve ${type}`); + const url = `https://sdowww.lmsal.com/sdomedia/SunInTime/mostrecent/t${type}.jpg`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + headers: { 'User-Agent': `OpenHamClock/${APP_VERSION}` }, + signal: controller.signal, + }); + clearTimeout(timer); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const buffer = Buffer.from(await res.arrayBuffer()); + if (buffer.length < 500) throw new Error(`Response too small (${buffer.length} bytes)`); + return { buffer, contentType: res.headers.get('content-type') || 'image/jpeg', source: 'LMSAL' }; + } catch (e) { + clearTimeout(timer); + throw e; + } +}; + app.get('/api/solar/image/:type', async (req, res) => { const type = req.params.type; if (!SDO_VALID_TYPES.has(type)) { @@ -2130,9 +2190,10 @@ app.get('/api/solar/image/:type', async (req, res) => { return res.status(503).json({ error: 'SDO temporarily unavailable' }); } - // Try sources in order: SDO direct → Helioviewer + // Try sources in order: SDO direct → LMSAL Sun Today → Helioviewer const sources = [ { name: 'SDO', fn: () => fetchFromSDO(type) }, + { name: 'LMSAL', fn: () => fetchFromLMSAL(type) }, { name: 'Helioviewer', fn: () => fetchFromHelioviewer(type) }, ]; @@ -2891,8 +2952,13 @@ const DXSPIDER_NODES = [ const DXSPIDER_SSID = '-56'; // OpenHamClock SSID function getDxClusterLoginCallsign(preferredCallsign = null) { - const candidate = (preferredCallsign || CONFIG.dxClusterCallsign || '').trim(); + // Strip control characters to prevent telnet command injection via query params + const candidate = (preferredCallsign || CONFIG.dxClusterCallsign || '').replace(/[\x00-\x1F\x7F]/g, '').trim(); if (candidate && candidate.toUpperCase() !== 'N0CALL') { + // Append default SSID if caller didn't include one + if (!candidate.includes('-')) { + return `${candidate.toUpperCase()}${DXSPIDER_SSID}`; + } return candidate.toUpperCase(); } @@ -2994,6 +3060,7 @@ const CUSTOM_DX_RETENTION_MS = 30 * 60 * 1000; const CUSTOM_DX_MAX_SPOTS = 500; const CUSTOM_DX_RECONNECT_DELAY_MS = 10000; const CUSTOM_DX_KEEPALIVE_MS = 30000; +const CUSTOM_DX_STALE_MS = 5 * 60 * 1000; // Force reconnect after 5 min with no data const CUSTOM_DX_IDLE_TIMEOUT = 15 * 60 * 1000; // Reap sessions idle for 15 minutes const customDxSessions = new Map(); @@ -3092,11 +3159,13 @@ function connectCustomSession(session) { session.loginSent = false; session.commandSent = false; client.setTimeout(0); + client.setKeepAlive(true, 60000); // OS-level TCP keepalive probes every 60s client.connect(session.node.port, session.node.host, () => { session.connected = true; session.connecting = false; session.lastConnectedAt = Date.now(); + session.lastDataAt = Date.now(); logDebug( `[DX Cluster] DX Spider: connected to ${session.node.host}:${session.node.port} as ${session.loginCallsign}`, ); @@ -3111,6 +3180,15 @@ function connectCustomSession(session) { session.keepAliveTimer = setInterval(() => { if (session.client && session.connected) { + // Force reconnect if no data received for CUSTOM_DX_STALE_MS + const silentMs = Date.now() - (session.lastDataAt || 0); + if (silentMs > CUSTOM_DX_STALE_MS) { + logWarn( + `[DX Cluster] No data from ${session.node.host} in ${Math.round(silentMs / 60000)} min — forcing reconnect`, + ); + handleCustomSessionDisconnect(session); + return; + } try { session.client.write('\r\n'); } catch (e) {} @@ -3119,6 +3197,7 @@ function connectCustomSession(session) { }); client.on('data', (data) => { + session.lastDataAt = Date.now(); session.buffer += data.toString(); // Login prompt detection @@ -3171,7 +3250,10 @@ function connectCustomSession(session) { } }); - client.on('timeout', () => {}); + client.on('timeout', () => { + logWarn(`[DX Cluster] Socket timeout for ${session.node.host} — reconnecting`); + handleCustomSessionDisconnect(session); + }); client.on('error', (err) => { if ( @@ -3211,6 +3293,7 @@ function getOrCreateCustomSession(node, userCallsign = null) { reconnectTimer: null, keepAliveTimer: null, lastConnectedAt: 0, + lastDataAt: 0, lastUsedAt: Date.now(), cleanupTimer: null, }; @@ -3592,6 +3675,64 @@ function parseSpotHHMMzToTimestamp(timeStr, fallbackTs = Date.now()) { return ts; } +/** + * SSRF protection: resolve hostname to IP and reject private/reserved addresses. + * Returns the resolved IP so callers can connect to the IP directly, preventing + * DNS rebinding (TOCTOU) attacks where the record changes between validation and connect. + */ +function isPrivateIP(ip) { + // Normalize IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 → 127.0.0.1) + const normalized = ip.replace(/^::ffff:/i, ''); + + // IPv4 private/reserved ranges + const parts = normalized.split('.').map(Number); + if (parts.length === 4 && parts.every((n) => n >= 0 && n <= 255)) { + if (parts[0] === 127) return true; // loopback + if (parts[0] === 10) return true; // 10.0.0.0/8 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 172.16.0.0/12 + if (parts[0] === 192 && parts[1] === 168) return true; // 192.168.0.0/16 + if (parts[0] === 169 && parts[1] === 254) return true; // link-local + if (parts[0] === 0) return true; // 0.0.0.0/8 + if (parts[0] >= 224) return true; // multicast + reserved + } + // IPv6 private/reserved + const lower = normalized.toLowerCase(); + if (lower === '::1' || lower === '::' || + lower.startsWith('fe80:') || lower.startsWith('fc00:') || + lower.startsWith('fd00:') || lower.startsWith('ff00:') || + lower.startsWith('::ffff:')) { + // Catch any remaining IPv4-mapped forms that weren't normalized above + return true; + } + return false; +} + +async function validateCustomHost(host) { + // Reject obvious localhost strings before DNS + if (/^localhost$/i.test(host)) return { ok: false, reason: 'localhost not allowed' }; + + // Resolve hostname to IPv4 addresses ONLY. + // We intentionally do not fall back to resolve6 because IPv6 has many equivalent + // representations for private addresses (e.g. ::ffff:7f00:1 = 127.0.0.1 in hex form) + // that bypass string-based checks. DX cluster telnet servers are IPv4. + let addresses; + try { + addresses = await dns.promises.resolve4(host); + } catch { + return { ok: false, reason: 'Host could not be resolved (IPv4 required for custom DX clusters)' }; + } + + // Check every resolved address — block if any resolve to private/reserved + for (const addr of addresses) { + if (isPrivateIP(addr)) { + return { ok: false, reason: 'Host resolves to a private/reserved address' }; + } + } + // Return the first resolved IP so callers connect to the validated IP, not the hostname. + // This prevents DNS rebinding (TOCTOU) where the record changes between validation and connect. + return { ok: true, resolvedIP: addresses[0] }; +} + app.get('/api/dxcluster/paths', async (req, res) => { // Parse query parameters for custom cluster settings const source = req.query.source || 'auto'; @@ -3601,28 +3742,15 @@ app.get('/api/dxcluster/paths', async (req, res) => { const userCallsign = (req.query.callsign || CONFIG.dxClusterCallsign || '').trim(); // SECURITY: Validate custom host to prevent SSRF (internal network scanning) + // Resolves DNS and returns the validated IP. We connect to the IP, not the hostname, + // to prevent DNS rebinding (TOCTOU) where the record changes between validation and connect. + let resolvedHost = customHost; if (source === 'custom' && customHost) { - // Block private/reserved IP ranges and localhost - const blockedPatterns = - /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.|0:|\[::1\]|::1|fe80:|fc00:|fd00:|ff00:)/i; - if (blockedPatterns.test(customHost)) { - return res.status(400).json({ error: 'Custom host cannot be a private/reserved address' }); - } - // Block numeric-only hosts (raw IPs) that could be encoded to bypass above - // Only allow hostnames that look like legitimate DX Spider nodes - if (/^\d+\.\d+\.\d+\.\d+$/.test(customHost)) { - const octets = customHost.split('.').map(Number); - if ( - octets[0] === 10 || - octets[0] === 127 || - octets[0] === 0 || - (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || - (octets[0] === 192 && octets[1] === 168) || - (octets[0] === 169 && octets[1] === 254) - ) { - return res.status(400).json({ error: 'Custom host cannot be a private/reserved address' }); - } + const hostCheck = await validateCustomHost(customHost); + if (!hostCheck.ok) { + return res.status(400).json({ error: `Custom host rejected: ${hostCheck.reason}` }); } + resolvedHost = hostCheck.resolvedIP; // Connect to the validated IP, not the hostname // Restrict port range to common DX Spider/telnet ports if (customPort < 1024 || customPort > 49151) { return res.status(400).json({ error: 'Port must be between 1024 and 49151' }); @@ -3632,7 +3760,7 @@ app.get('/api/dxcluster/paths', async (req, res) => { // Generate cache key based on source profile so custom/proxy/auto don't mix. const cacheKey = source === 'custom' - ? `custom-${customHost}-${customPort}-${getDxClusterLoginCallsign(userCallsign)}` + ? `custom-${resolvedHost}-${customPort}-${getDxClusterLoginCallsign(userCallsign)}` : `source-${source}`; const pathsCache = getDxPathsCache(cacheKey); @@ -3652,11 +3780,11 @@ app.get('/api/dxcluster/paths', async (req, res) => { let usedSource = 'none'; // Handle custom telnet source (persistent connection, no reconnect-per-poll) - if (source === 'custom' && customHost) { + if (source === 'custom' && resolvedHost) { logDebug( - `[DX Paths] Using custom telnet session: ${customHost}:${customPort} as ${getDxClusterLoginCallsign(userCallsign)}`, + `[DX Paths] Using custom telnet session: ${resolvedHost}:${customPort} as ${getDxClusterLoginCallsign(userCallsign)}`, ); - const customNode = { host: customHost, port: customPort }; + const customNode = { host: resolvedHost, port: customPort }; const session = getOrCreateCustomSession(customNode, userCallsign); // Take the most recent spots from persistent session buffer. const customSpots = (session.spots || []).slice(0, 100).map((s) => ({ @@ -4446,7 +4574,7 @@ app.get('/api/qrz/status', (req, res) => { }); // POST /api/qrz/configure — save QRZ credentials (from Settings UI) -app.post('/api/qrz/configure', writeLimiter, async (req, res) => { +app.post('/api/qrz/configure', writeLimiter, requireWriteAuth, async (req, res) => { const { username, password } = req.body || {}; if (!username || !password) { @@ -4510,7 +4638,7 @@ app.post('/api/qrz/configure', writeLimiter, async (req, res) => { }); // POST /api/qrz/remove — remove saved QRZ credentials -app.post('/api/qrz/remove', writeLimiter, (req, res) => { +app.post('/api/qrz/remove', writeLimiter, requireWriteAuth, (req, res) => { qrzSession.username = CONFIG._qrzUsername || ''; qrzSession.password = CONFIG._qrzPassword || ''; qrzSession.key = null; @@ -6561,7 +6689,28 @@ pskMqtt.cleanupInterval = setInterval( // SSE endpoint — clients connect here for real-time spots // ?type=grid subscribes by grid square instead of callsign + +// Per-IP connection limiter for SSE streams to prevent resource exhaustion. +// Once an SSE connection is established it persists indefinitely, so the normal +// request-rate limiter doesn't help. This caps concurrent open streams per IP. +const MAX_SSE_PER_IP = parseInt(process.env.MAX_SSE_PER_IP || '10', 10); +const sseConnectionsByIP = new Map(); + app.get('/api/pskreporter/stream/:identifier', (req, res) => { + // Use req.ip which respects the trust proxy setting, consistent with express-rate-limit. + // Manual x-forwarded-for parsing is trivially spoofable on installs without a reverse proxy. + const ip = req.ip || req.connection?.remoteAddress || 'unknown'; + const current = sseConnectionsByIP.get(ip) || 0; + if (current >= MAX_SSE_PER_IP) { + return res.status(429).json({ error: 'Too many open SSE connections from this IP' }); + } + sseConnectionsByIP.set(ip, current + 1); + req.on('close', () => { + const count = sseConnectionsByIP.get(ip) || 1; + if (count <= 1) sseConnectionsByIP.delete(ip); + else sseConnectionsByIP.set(ip, count - 1); + }); + const identifier = req.params.identifier.toUpperCase(); const type = (req.query.type || 'call').toLowerCase(); @@ -6928,7 +7077,7 @@ async function enrichSpotWithLocation(spot) { // Lookup location (don't block on failures) try { - const response = await fetch(`http://localhost:${PORT}/api/callsign/${skimmerCall}`); + const response = await fetch(`http://localhost:${PORT}/api/callsign/${encodeURIComponent(skimmerCall)}`); if (response.ok) { const locationData = await response.json(); @@ -7072,7 +7221,10 @@ app.get('/api/rbn/spots', async (req, res) => { // Endpoint to lookup skimmer location (cached permanently) app.get('/api/rbn/location/:callsign', async (req, res) => { - const callsign = req.params.callsign.toUpperCase(); + const callsign = req.params.callsign.toUpperCase().replace(/[^\w\-\/]/g, ''); + if (!callsign || callsign.length > 15) { + return res.status(400).json({ error: 'Invalid callsign' }); + } // Check cache first if (callsignLocationCache.has(callsign)) { @@ -7081,7 +7233,7 @@ app.get('/api/rbn/location/:callsign', async (req, res) => { try { // Look up via HamQTH - const response = await fetch(`http://localhost:${PORT}/api/callsign/${callsign}`); + const response = await fetch(`http://localhost:${PORT}/api/callsign/${encodeURIComponent(callsign)}`); if (response.ok) { const locationData = await response.json(); const grid = latLonToGrid(locationData.lat, locationData.lon); @@ -10451,7 +10603,11 @@ app.get('/api/health', (req, res) => { lastSaved: visitorStats.lastSaved, } : { enabled: !!STATS_FILE }, - sessions: sessionTracker.getStats(), + // SECURITY: Session details include partially anonymized IPs — only expose to authenticated requests. + // Unauthenticated requests get aggregate counts only. + sessions: isAuthed + ? sessionTracker.getStats() + : { concurrent: sessionTracker.activeSessions.size, peakConcurrent: sessionTracker.peakConcurrent }, visitors: { today: { date: visitorStats.today, @@ -12868,6 +13024,11 @@ app.listen(PORT, '0.0.0.0', () => { if (AUTO_UPDATE_ENABLED) { console.log(` 🔄 Auto-update enabled every ${AUTO_UPDATE_INTERVAL_MINUTES || 60} minutes`); } + if (!API_WRITE_KEY) { + console.log(''); + console.log(' ⚠️ API_WRITE_KEY is not set — write endpoints (settings, update, rotator, QRZ) are unprotected.'); + console.log(' Set API_WRITE_KEY in .env to secure POST endpoints.'); + } console.log(' 🖥️ Open your browser to start using OpenHamClock'); console.log(''); if (CONFIG.callsign !== 'N0CALL') { diff --git a/src/App.jsx b/src/App.jsx index 694a77d2..e44b7ec7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -169,6 +169,12 @@ const App = () => { return; } + if (e.key === '/') { + toggleDeDxMarkers(); + e.preventDefault(); + return; + } + const layerId = layerShortcuts[e.key.toLowerCase()]; if (layerId && window.hamclockLayerControls) { const isEnabled = window.hamclockLayerControls.layers?.find((l) => l.id === layerId)?.enabled ?? false; diff --git a/src/DockableApp.jsx b/src/DockableApp.jsx index 0bcffc82..b131bd46 100644 --- a/src/DockableApp.jsx +++ b/src/DockableApp.jsx @@ -37,6 +37,7 @@ import { DockableLayoutProvider } from './contexts'; import { useRig } from './contexts/RigContext.jsx'; import { calculateBearing, calculateDistance, formatDistance } from './utils/geo.js'; import { DXGridInput } from './components/DXGridInput.jsx'; +import DXCCSelect from './components/DXCCSelect.jsx'; import './styles/flexlayout-openhamclock.css'; import useMapLayers from './hooks/app/useMapLayers'; import useRotator from './hooks/useRotator'; @@ -175,6 +176,7 @@ export const DockableApp = ({ }); }, []); const [showDXLocalTime, setShowDXLocalTime] = useState(false); + const [showDxccSelect, setShowDxccSelect] = useState(false); // ── Tabset auto-rotation (persistent per tabset) ── const [tabsetRotation, setTabsetRotation] = useState(() => { @@ -415,6 +417,7 @@ export const DockableApp = ({ 'on-air': { name: 'On Air', icon: '🔴' }, 'id-timer': { name: 'ID Timer', icon: '📢' }, keybindings: { name: 'Keyboard Shortcuts', icon: '⌨️' }, + 'lock-layout': { name: 'Lock Layout', icon: '🔒' }, }; }, [isLocalInstall]); @@ -495,12 +498,40 @@ export const DockableApp = ({
- +
+ + +
+ {showDxccSelect && ( + + )} ; break; + case 'lock-layout': + content = ( + + ); + break; + default: content = (
@@ -1184,38 +1231,6 @@ export const DockableApp = ({ />
- {/* Dockable toolbar */} -
- -
- {/* Dockable Layout */}
diff --git a/src/components/ActivatePanel.jsx b/src/components/ActivatePanel.jsx index 2e3cfbc8..a136b596 100644 --- a/src/components/ActivatePanel.jsx +++ b/src/components/ActivatePanel.jsx @@ -175,8 +175,9 @@ export const ActivatePanel = ({ textOverflow: 'ellipsis', whiteSpace: 'nowrap', }} + title={`${spot.ref} - ${spot.name}`} > - {`${spot.ref} - ${spot.name}`} + {spot.ref} {(() => { diff --git a/src/components/DXCCSelect.jsx b/src/components/DXCCSelect.jsx new file mode 100644 index 00000000..c0b916b5 --- /dev/null +++ b/src/components/DXCCSelect.jsx @@ -0,0 +1,137 @@ +import { useEffect, useId, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getCtyEntities, isCtyLoaded } from '../utils/ctyLookup.js'; + +function normalize(value) { + return (value || '').trim().toLowerCase(); +} + +export default function DXCCSelect({ dxLocked, onDXChange, style }) { + const { t } = useTranslation(); + const [entities, setEntities] = useState(() => (isCtyLoaded() ? getCtyEntities() : [])); + const [inputValue, setInputValue] = useState(''); + const listId = useId(); + + useEffect(() => { + if (isCtyLoaded()) { + setEntities(getCtyEntities()); + return undefined; + } + + const handleLoaded = () => setEntities(getCtyEntities()); + window.addEventListener('openhamclock-cty-loaded', handleLoaded); + return () => window.removeEventListener('openhamclock-cty-loaded', handleLoaded); + }, []); + + const options = useMemo(() => { + return entities + .filter((item) => item?.entity && Number.isFinite(item.lat) && Number.isFinite(item.lon)) + .sort((a, b) => a.entity.localeCompare(b.entity)) + .map((item) => ({ + key: `${item.entity}|${item.dxcc || ''}`, + label: item.dxcc ? `${item.entity} (${item.dxcc})` : item.entity, + entity: item.entity, + dxcc: item.dxcc || '', + lat: item.lat, + lon: item.lon, + })); + }, [entities]); + + const optionMap = useMemo(() => { + const map = new Map(); + options.forEach((item) => { + map.set(normalize(item.label), item); + map.set(normalize(item.entity), item); + if (item.dxcc) map.set(normalize(item.dxcc), item); + }); + return map; + }, [options]); + + const commit = () => { + const match = optionMap.get(normalize(inputValue)); + if (!match) return; + onDXChange({ lat: match.lat, lon: match.lon }); + setInputValue(match.label); + }; + + const handleChange = (value) => { + setInputValue(value); + + const match = optionMap.get(normalize(value)); + if (match) { + onDXChange({ lat: match.lat, lon: match.lon }); + setInputValue(match.label); + } + }; + + return ( +
+
+ handleChange(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commit(); + e.currentTarget.blur(); + } else if (e.key === 'Escape') { + setInputValue(''); + e.currentTarget.blur(); + } + }} + style={{ + flex: '1 1 auto', + minWidth: 0, + background: 'var(--bg-secondary)', + color: 'var(--text-primary)', + border: '1px solid var(--border-color)', + borderRadius: '4px', + padding: '6px 8px', + fontSize: '12px', + fontFamily: 'JetBrains Mono, monospace', + outline: 'none', + cursor: dxLocked ? 'not-allowed' : 'text', + }} + /> + + + {options.map((item) => ( + +
+
+ ); +} diff --git a/src/components/DXGridInput.jsx b/src/components/DXGridInput.jsx index 8fb7fea5..e69970b4 100644 --- a/src/components/DXGridInput.jsx +++ b/src/components/DXGridInput.jsx @@ -89,7 +89,6 @@ export function DXGridInput({ dxGrid, onDXChange, dxLocked, style }) { outline: 'none', cursor: dxLocked ? 'not-allowed' : 'text', width: '7ch', - textAlign: 'right', padding: 0, margin: 0, fontFamily: 'JetBrains Mono, monospace', diff --git a/src/components/DXNewsTicker.jsx b/src/components/DXNewsTicker.jsx index fa5add64..fbd4c6f9 100644 --- a/src/components/DXNewsTicker.jsx +++ b/src/components/DXNewsTicker.jsx @@ -6,6 +6,11 @@ import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +// Base font sizes (px) — all sizes are derived by multiplying with textScale +const BASE_LABEL_SIZE = 10; // "📰 DX NEWS" label, separator ◆ +const BASE_TEXT_SIZE = 11; // news titles and descriptions +const BASE_HEIGHT = 28; // container height in map overlay mode (px) + // Check if DX News is enabled (reads directly from localStorage as belt-and-suspenders) function isDXNewsEnabled() { try { @@ -28,6 +33,20 @@ export const DXNewsTicker = ({ sidebar = false }) => { const [paused, setPaused] = useState(false); const { t } = useTranslation(); + // Text scale persisted in localStorage (0.7 – 2.0, default 1.0) + const [textScale, setTextScale] = useState(() => { + try { + const stored = localStorage.getItem('openhamclock_dxNewsTextScale'); + if (stored) return parseFloat(stored); + } catch {} + return 1.0; + }); + + // Persist textScale whenever it changes + useEffect(() => { + localStorage.setItem('openhamclock_dxNewsTextScale', String(textScale)); + }, [textScale]); + // Listen for mapLayers changes (custom event for same-tab, storage for cross-tab) useEffect(() => { const checkVisibility = () => setVisible(isDXNewsEnabled()); @@ -66,7 +85,9 @@ export const DXNewsTicker = ({ sidebar = false }) => { return () => clearInterval(interval); }, [visible]); - // Calculate animation duration based on content width + // Calculate animation duration based on content width. + // textScale is included so speed recalculates after a font-size change + // (useEffect runs after paint, so scrollWidth reflects the new size). useEffect(() => { if (contentRef.current && tickerRef.current) { const contentWidth = contentRef.current.scrollWidth; @@ -75,7 +96,7 @@ export const DXNewsTicker = ({ sidebar = false }) => { const duration = Math.max(20, (contentWidth + containerWidth) / 90); setAnimDuration(duration); } - }, [news]); + }, [news, textScale]); // Inject keyframes animation style once useEffect(() => { @@ -94,6 +115,26 @@ export const DXNewsTicker = ({ sidebar = false }) => { desc: item.description, })); + const atMin = textScale <= 0.7; + const atMax = textScale >= 2.0; + + const handleDecrease = () => setTextScale((s) => parseFloat(Math.max(0.7, s - 0.1).toFixed(1))); + const handleIncrease = () => setTextScale((s) => parseFloat(Math.min(2.0, s + 0.1).toFixed(1))); + + const sizeButtonStyle = (disabled) => ({ + background: 'transparent', + border: 'none', + color: disabled ? '#444' : '#ff8800', + fontSize: `${BASE_LABEL_SIZE * textScale}px`, + fontWeight: '700', + fontFamily: 'JetBrains Mono, monospace', + padding: `0 ${6 * textScale}px`, + height: '100%', + cursor: disabled ? 'default' : 'pointer', + lineHeight: 1, + flexShrink: 0, + }); + return (
{ bottom: '8px', left: '8px', right: '8px', - height: '28px', + height: `${BASE_HEIGHT * textScale}px`, background: 'rgba(0, 0, 0, 0.85)', border: '1px solid #444', borderRadius: '6px', @@ -132,7 +173,7 @@ export const DXNewsTicker = ({ sidebar = false }) => { background: 'rgba(255, 136, 0, 0.9)', color: '#000', fontWeight: '700', - fontSize: '10px', + fontSize: `${BASE_LABEL_SIZE * textScale}px`, fontFamily: 'JetBrains Mono, monospace', padding: '0 8px', height: '100%', @@ -183,7 +224,7 @@ export const DXNewsTicker = ({ sidebar = false }) => { style={{ color: '#ff8800', fontWeight: '700', - fontSize: '11px', + fontSize: `${BASE_TEXT_SIZE * textScale}px`, fontFamily: 'JetBrains Mono, monospace', marginRight: '6px', }} @@ -193,7 +234,7 @@ export const DXNewsTicker = ({ sidebar = false }) => { { @@ -218,7 +259,7 @@ export const DXNewsTicker = ({ sidebar = false }) => { style={{ color: '#ff8800', fontWeight: '700', - fontSize: '11px', + fontSize: `${BASE_TEXT_SIZE * textScale}px`, fontFamily: 'JetBrains Mono, monospace', marginRight: '6px', }} @@ -228,7 +269,7 @@ export const DXNewsTicker = ({ sidebar = false }) => { { @@ -248,6 +289,34 @@ export const DXNewsTicker = ({ sidebar = false }) => { ))}
+ + {/* Text size controls */} +
+ + +
); }; diff --git a/src/components/KeybindingsPanel.jsx b/src/components/KeybindingsPanel.jsx index e950de2f..441a093e 100644 --- a/src/components/KeybindingsPanel.jsx +++ b/src/components/KeybindingsPanel.jsx @@ -125,6 +125,43 @@ export const KeybindingsPanel = ({ isOpen, onClose, keybindings, nodeId }) => { {t('keybindings.panel.toggle', 'Toggle this help panel')}
+
+ + {'/'} + + + {t('keybindings.panel.toggleDeDx', 'Toggle DE and DX Markers')} + +
); @@ -282,7 +319,6 @@ export const KeybindingsPanel = ({ isOpen, onClose, keybindings, nodeId }) => { display: 'flex', alignItems: 'center', gap: '12px', - gridColumn: 'span 2', }} > { > ? + { {t('keybindings.panel.toggle', 'Toggle this help panel')} +
+ + / + + + {t('keybindings.panel.toggleDeDx', 'Toggle DE and DX Markers')} + +
{/* Footer note */} diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index 5e8205ce..c2c15725 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -41,7 +41,7 @@ export const SettingsPanel = ({ const [callsign, setCallsign] = useState(config?.callsign || ''); const [headerSize, setheaderSize] = useState(config?.headerSize || 1.0); - const [gridSquare, setGridSquare] = useState(''); + const [gridSquare, setGridSquare] = useState(config?.locator || ''); const [lat, setLat] = useState(config?.location?.lat || 0); const [lon, setLon] = useState(config?.location?.lon || 0); const [layout, setLayout] = useState(config?.layout || 'modern'); @@ -172,7 +172,9 @@ export const SettingsPanel = ({ setTuneEnabled(config.rigControl?.tuneEnabled || false); setAutoMode(config.rigControl?.autoMode !== false); if (config.location?.lat && config.location?.lon) { - setGridSquare(calculateGridSquare(config.location.lat, config.location.lon)); + const grid = calculateGridSquare(config.location.lat, config.location.lon); + setGridSquare(grid); + setConfigLocator(grid); } } }, [config, isOpen]); @@ -276,6 +278,13 @@ export const SettingsPanel = ({ } }; + function setConfigLocator(grid) { + if (grid.length >= 4) { + config.locator = grid.slice(0, 4).toUpperCase() + grid.slice(4).toLowerCase(); + } else { + config.locator = grid.toUpperCase(); + } + } const handleGridChange = (grid) => { setGridSquare(grid.toUpperCase()); if (grid.length >= 4) { @@ -285,11 +294,14 @@ export const SettingsPanel = ({ setLon(parsed.lon); } } + setConfigLocator(grid); }; useEffect(() => { if (lat && lon) { - setGridSquare(calculateGridSquare(lat, lon)); + const grid = calculateGridSquare(lat, lon); + setGridSquare(grid); + setConfigLocator(grid); } }, [lat, lon]); diff --git a/src/components/WhatsNew.jsx b/src/components/WhatsNew.jsx index 41cb1646..86bee5b0 100644 --- a/src/components/WhatsNew.jsx +++ b/src/components/WhatsNew.jsx @@ -19,6 +19,79 @@ const ANNOUNCEMENT = { // Add new versions at the TOP of this array. // Each entry: { version, date, heading, features: [...] } const CHANGELOG = [ + { + version: '15.6.5', + date: '2026-03-09', + heading: + 'Major security hardening release — CORS lockdown, SSRF elimination, rate-limit bypass fixes, and XSS prevention. Plus LMSAL solar image fallback, lightning unit preferences, DXCC entity selector, rig-bridge multicast, and Raspberry Pi setup improvements.', + features: [ + { + icon: '🔒', + title: 'Security Hardening — CORS & API Protection', + desc: 'Replaced wildcard CORS policy with an explicit origin allowlist. Previously, any website you visited could silently access your API, read your callsign/coordinates, and (without API_WRITE_KEY) control your rotator or restart the server. Now only localhost and openhamclock.com/app origins are allowed by default. Add custom origins via CORS_ORIGINS in .env. Rotator and QRZ credential endpoints now require API_WRITE_KEY authentication. Server prints a startup warning when API_WRITE_KEY is not set.', + }, + { + icon: '🛡️', + title: 'Security Hardening — SSRF Elimination', + desc: 'Custom DX cluster connections are now fully protected against Server-Side Request Forgery. The server resolves DNS to an IPv4 address, validates it against private/reserved ranges, and connects to the validated IP directly — preventing DNS rebinding (TOCTOU) attacks. IPv6 resolution removed entirely to eliminate representation bypass attacks (e.g. ::ffff:7f00:1 mapping to 127.0.0.1). Telnet command injection prevented via control character stripping on callsign inputs.', + }, + { + icon: '🔐', + title: 'Security Hardening — Rate Limiting & XSS', + desc: 'Trust proxy is now auto-detected (enabled on Railway, disabled on Pi/local) to prevent rate-limit bypass via spoofed X-Forwarded-For headers. SSE connections have a per-IP limit (default 10) to prevent resource exhaustion. Health endpoint session details gated behind authentication. DOM XSS fixes applied to N3FJP logged QSO colors and APRS Newsfeed userscript. ReDoS vulnerability fixed in IP anonymization. Dockerfile now runs as non-root user.', + }, + { + icon: '📻', + title: 'Rig-Bridge Security', + desc: 'Rig-Bridge gets the same security treatment: CORS restricted to explicit origins (no more wildcard), HTTP server binds to localhost by default (set bindAddress to 0.0.0.0 for LAN access), serial port paths validated against OS-specific allowlists, and WSJT-X relay URL validated to prevent SSRF to internal services.', + }, + { + icon: '☀️', + title: 'LMSAL Solar Image Fallback', + desc: 'Solar imagery now has three-source failover: NASA SDO → LMSAL Sun Today (Lockheed Martin) → Helioviewer. When NASA Goddard infrastructure is down (increasingly common during budget disruptions), the Lockheed mirror provides independent coverage for all four AIA channels. HMI continuum skips LMSAL (not available) and falls through to Helioviewer.', + }, + { + icon: '⚡', + title: 'Lightning Distance Units', + desc: 'Lightning proximity panel now respects your km/miles unit preference. Closest strike distance, strike list, and radius labels all display in your chosen unit instead of always showing both.', + }, + { + icon: '🌍', + title: 'DXCC Entity Selector', + desc: 'New DXCC entity picker button next to the DX grid display in Modern and Dockable layouts. Browse or search the full DXCC entity list to quickly set a DX target without knowing the grid square.', + }, + { + icon: '📰', + title: 'DX News Text Scale', + desc: 'DX News ticker now has A-/A+ buttons to adjust font size (0.7x to 2.0x). Setting persists across sessions. Useful for readability on large displays or compact layouts.', + }, + { + icon: '📡', + title: 'Rig-Bridge Multicast', + desc: 'WSJT-X relay in rig-bridge now supports UDP multicast, allowing multiple applications (GridTracker, JTAlert, OpenHamClock) to receive WSJT-X packets simultaneously. Enable via the setup UI checkbox or multicast settings in rig-bridge-config.json.', + }, + { + icon: '🔧', + title: 'Rig-Bridge Simulated Radio', + desc: 'New mock radio plugin for testing rig-bridge without hardware. Simulates a radio drifting through several bands. Enable with radio.type = "mock" in config or select Simulated Radio in the setup UI.', + }, + { + icon: '🥧', + title: 'Raspberry Pi Setup Improvements', + desc: 'Pi setup script now handles 32-bit ARM (armhf) directly from nodejs.org since NodeSource dropped support for Node 20+. npm install uses --ignore-scripts to avoid electron-winstaller failures on ARM. Dev dependencies pruned after build, freeing ~500MB on SD cards.', + }, + { + icon: '🔒', + title: 'Layout Lock in Border Panel', + desc: 'Layout lock toggle moved to a dedicated border tab on the left edge of the Dockable layout. Always accessible, never accidentally closeable (enableClose: false). Keeps the header clean while maintaining one-click access.', + }, + { + icon: '🔗', + title: 'DX Cluster Connection Reliability', + desc: 'Custom DX cluster telnet sessions now use TCP keepalive and automatic stale-connection detection (reconnects after 5 minutes of silence). Callsign SSID (-56) appended automatically when missing.', + }, + ], + }, { version: '15.6.4', date: '2026-03-04', diff --git a/src/lang/ca.json b/src/lang/ca.json index b46c4e37..5a1d95cb 100644 --- a/src/lang/ca.json +++ b/src/lang/ca.json @@ -21,6 +21,8 @@ "app.dxLock.unlocked": "🔓 DX desbloquejat", "app.dxNews.pauseTooltip": "Clica per pausar", "app.dxNews.resumeTooltip": "Clica per reprendre", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "app.legend.moon": "Lluna", "app.legend.sun": "Sol", "app.liveSpots.ofGridLastMinutes": "de {{grid}} - {{minutes}} min", diff --git a/src/lang/de.json b/src/lang/de.json index cf02fa63..90b89f8d 100644 --- a/src/lang/de.json +++ b/src/lang/de.json @@ -9,6 +9,11 @@ "app.dxLocation.dxTitle": "📍 DX - ZIEL", "app.dxLocation.gridInputTitle": "Maidenhead-Locator eingeben (z.B. JN58sm), Enter drücken", "app.dxLocation.gridInputTitleLocked": "DX-Position entsperren, um einen Locator einzugeben", + "app.dxLocation.dxccPlaceholder": "DXCC-Eintrag wählen", + "app.dxLocation.dxccTitle": "DXCC-Eintrag auswählen, um das DX-Ziel zu verschieben", + "app.dxLocation.dxccTitleLocked": "DX-Position entsperren, um einen DXCC-Eintrag zu wählen", + "app.dxLocation.dxccToggleTitle": "DXCC-Auswahl ein- oder ausblenden", + "app.dxLocation.dxccClearTitle": "DXCC-Eingabe löschen", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "Karte klicken, um DX zu setzen", @@ -21,6 +26,8 @@ "app.dxLock.unlocked": "🔓 DX entsperrt", "app.dxNews.pauseTooltip": "Klicken zum Anhalten", "app.dxNews.resumeTooltip": "Klicken zum Fortsetzen", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "app.legend.moon": "Mond", "app.legend.sun": "Sonne", "app.mapUi.hide": "UI ausblenden", diff --git a/src/lang/en.json b/src/lang/en.json index 1fd09e95..bcfd6316 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -222,11 +222,18 @@ "app.dxLocation.dxTitle": "📍 DX - TARGET", "app.dxLocation.gridInputTitle": "Type a Maidenhead locator (e.g. JN58sm), press Enter", "app.dxLocation.gridInputTitleLocked": "Unlock DX position to enter a locator manually", + "app.dxLocation.dxccPlaceholder": "Select DXCC entity", + "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", + "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", + "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", + "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.beamDir": "Beam Dir:", "app.dxLocation.sp": "SP:", "app.dxLocation.lp": "LP:", "app.dxNews.pauseTooltip": "Click to pause scrolling", "app.dxNews.resumeTooltip": "Click to resume scrolling", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "dxClusterPanel.title": "DX CLUSTER", "dxClusterPanel.live": "LIVE", "dxClusterPanel.filterTooltip": "Filter DX spots by band, mode, or continent", @@ -422,5 +429,6 @@ "keybindings.panel.title": "KEYBOARD SHORTCUTS", "keybindings.panel.description": "Press the following keys to toggle map layers:", "keybindings.panel.toggle": "Toggle this help panel", + "keybindings.panel.toggleDeDx": "Toggle DE and DX Markers", "keybindings.panel.note": "Press ESC or click outside to close this panel" } diff --git a/src/lang/es.json b/src/lang/es.json index 09ee67f4..c800fbdd 100644 --- a/src/lang/es.json +++ b/src/lang/es.json @@ -21,6 +21,8 @@ "app.dxLock.unlocked": "🔓 DX desbloqueado", "app.dxNews.pauseTooltip": "Clic para pausar", "app.dxNews.resumeTooltip": "Clic para reanudar", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "app.legend.moon": "Moon", "app.legend.sun": "Sun", "app.mapUi.hide": "Ocultar interfaz", diff --git a/src/lang/fr.json b/src/lang/fr.json index 5b1852d8..ce2afadd 100644 --- a/src/lang/fr.json +++ b/src/lang/fr.json @@ -21,6 +21,8 @@ "app.dxLock.unlocked": "🔓 DX déverrouillé", "app.dxNews.pauseTooltip": "Cliquer pour mettre en pause", "app.dxNews.resumeTooltip": "Cliquer pour reprendre", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "app.legend.moon": "Moon", "app.legend.sun": "Sun", "app.mapUi.hide": "Masquer interface", diff --git a/src/lang/it.json b/src/lang/it.json index 63ed7d78..567faa3b 100644 --- a/src/lang/it.json +++ b/src/lang/it.json @@ -21,6 +21,8 @@ "app.dxLock.unlocked": "🔓 DX sbloccato", "app.dxNews.pauseTooltip": "Clicca per mettere in pausa", "app.dxNews.resumeTooltip": "Clicca per riprendere", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "app.legend.moon": "Moon", "app.legend.sun": "Sun", "app.mapUi.hide": "Nascondi interfaccia", diff --git a/src/lang/ja.json b/src/lang/ja.json index f2fbecb7..ac8151e0 100644 --- a/src/lang/ja.json +++ b/src/lang/ja.json @@ -21,6 +21,8 @@ "app.dxLock.unlocked": "🔓 DX ロック解除", "app.dxNews.pauseTooltip": "クリックして一時停止", "app.dxNews.resumeTooltip": "クリックして再開", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "app.legend.moon": "Moon", "app.legend.sun": "Sun", "app.mapUi.hide": "UIを隠す", diff --git a/src/lang/ka.json b/src/lang/ka.json index 923ac089..096abf70 100644 --- a/src/lang/ka.json +++ b/src/lang/ka.json @@ -214,6 +214,8 @@ "app.dxLocation.lp": "გრძელი:", "app.dxNews.pauseTooltip": "დააწკაპუნეთ გადახვევის შესაჩერებლად", "app.dxNews.resumeTooltip": "დააწკაპუნეთ გადახვევის გასაგრძელებლად", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "dxClusterPanel.title": "DX კლასტერი", "dxClusterPanel.live": "ეთერი", "dxClusterPanel.filterTooltip": "DX სპოტების ფილტრი დიაპაზონით, მოდით ან კონტინენტით", diff --git a/src/lang/ko.json b/src/lang/ko.json index 28b59a73..d1535e27 100644 --- a/src/lang/ko.json +++ b/src/lang/ko.json @@ -21,6 +21,8 @@ "app.dxLock.unlocked": "🔓 DX 잠금 해제", "app.dxNews.pauseTooltip": "클릭하여 일시 중지", "app.dxNews.resumeTooltip": "클릭하여 다시 시작", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "app.legend.moon": "Moon", "app.legend.sun": "Sun", "app.mapUi.hide": "UI 숨기기", diff --git a/src/lang/ms.json b/src/lang/ms.json index 3331a5d5..97dbbbe4 100644 --- a/src/lang/ms.json +++ b/src/lang/ms.json @@ -21,6 +21,8 @@ "app.dxLock.unlocked": "🔓 DX Dibuka", "app.dxNews.pauseTooltip": "Klik untuk jeda tatalan", "app.dxNews.resumeTooltip": "Klik untuk sambung tatalan", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "app.legend.moon": "Bulan", "app.legend.sun": "Matahari", "app.mapUi.hide": "Sembunyi UI", diff --git a/src/lang/nl.json b/src/lang/nl.json index bd9be168..6aa15e62 100644 --- a/src/lang/nl.json +++ b/src/lang/nl.json @@ -21,6 +21,8 @@ "app.dxLock.unlocked": "DX ontgrendeld", "app.dxNews.pauseTooltip": "Klik om te pauzeren", "app.dxNews.resumeTooltip": "Klik om te hervatten", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "app.legend.moon": "Moon", "app.legend.sun": "Sun", "app.mapUi.hide": "UI verbergen", diff --git a/src/lang/pt.json b/src/lang/pt.json index cb58e6fb..5aaff644 100644 --- a/src/lang/pt.json +++ b/src/lang/pt.json @@ -21,6 +21,8 @@ "app.dxLock.unlocked": "🔓 DX desbloqueado", "app.dxNews.pauseTooltip": "Clique para pausar", "app.dxNews.resumeTooltip": "Clique para resumir", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "app.legend.moon": "Moon", "app.legend.sun": "Sun", "app.mapUi.hide": "Ocultar interface", diff --git a/src/lang/ru.json b/src/lang/ru.json index 0bc34ce9..484b1669 100644 --- a/src/lang/ru.json +++ b/src/lang/ru.json @@ -214,6 +214,8 @@ "app.dxLocation.lp": "ДП:", "app.dxNews.pauseTooltip": "Нажмите для паузы прокрутки", "app.dxNews.resumeTooltip": "Нажмите для возобновления прокрутки", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "dxClusterPanel.title": "DX КЛАСТЕР", "dxClusterPanel.live": "ЭФИР", "dxClusterPanel.filterTooltip": "Фильтр DX-спотов по диапазону, виду излучения или континенту", diff --git a/src/lang/sl.json b/src/lang/sl.json index d02bcc45..87bde91d 100644 --- a/src/lang/sl.json +++ b/src/lang/sl.json @@ -21,6 +21,8 @@ "app.dxLock.unlocked": "🔓 DX odklenjen", "app.dxNews.pauseTooltip": "Kliknite za premor", "app.dxNews.resumeTooltip": "Kliknite za nadaljevanje", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "app.legend.moon": "Moon", "app.legend.sun": "Sun", "app.mapUi.hide": "Skrij vmesnik", diff --git a/src/lang/zh.json b/src/lang/zh.json index c5b1719d..9e9787a0 100644 --- a/src/lang/zh.json +++ b/src/lang/zh.json @@ -196,6 +196,8 @@ "app.dxLocation.lp": "长径:", "app.dxNews.pauseTooltip": "点击暂停滚动", "app.dxNews.resumeTooltip": "点击恢复滚动", + "app.dxNews.increaseTextSize": "Increase text size", + "app.dxNews.decreaseTextSize": "Decrease text size", "dxClusterPanel.title": "DX 集群", "dxClusterPanel.live": "实时", "dxClusterPanel.filterTooltip": "按频段、模式或大洲筛选 DX 监测", diff --git a/src/layouts/ModernLayout.jsx b/src/layouts/ModernLayout.jsx index 0245c47c..fdb05f41 100644 --- a/src/layouts/ModernLayout.jsx +++ b/src/layouts/ModernLayout.jsx @@ -27,6 +27,7 @@ import { import { useRig } from '../contexts/RigContext.jsx'; import { calculateDistance, formatDistance } from '../utils/geo.js'; import { DXGridInput } from '../components/DXGridInput.jsx'; +import DXCCSelect from '../components/DXCCSelect.jsx'; import useBreakpoint from '../hooks/app/useBreakpoint'; export default function ModernLayout(props) { @@ -118,6 +119,7 @@ export default function ModernLayout(props) { const { tuneTo } = useRig(); const { breakpoint } = useBreakpoint(); + const [showDxccSelect, setShowDxccSelect] = useState(false); const [showDXLocalTime, setShowDXLocalTime] = useState(false); const isMobile = breakpoint === 'mobile'; const isTablet = breakpoint === 'tablet'; @@ -241,12 +243,41 @@ export default function ModernLayout(props) {
- +
+ + +
+ {showDxccSelect && ( + + )} { if (typeof getIsMinimized === 'function') return !!getIsMinimized(); return localStorage.getItem(minimizeKey) === 'true'; @@ -49,7 +40,8 @@ export function addMinimizeToggle(element, storageKey, options = {}) { const syncState = (button, wrapper) => { const isMinimized = readState(); wrapper.style.display = isMinimized ? 'none' : 'block'; - button.innerHTML = isMinimized ? '▶' : '▼'; + button.innerHTML = '▶'; + button.style.transform = isMinimized ? 'rotate(0deg)' : 'rotate(90deg)'; element.style.cursor = isMinimized ? 'pointer' : 'default'; }; @@ -68,7 +60,8 @@ export function addMinimizeToggle(element, storageKey, options = {}) { e.stopPropagation(); const next = existingWrapper.style.display !== 'none'; existingWrapper.style.display = next ? 'none' : 'block'; - existingButton.innerHTML = next ? '▶' : '▼'; + existingButton.innerHTML = '▶'; + existingButton.style.transform = next ? 'rotate(90deg)' : 'rotate(0deg)'; element.style.cursor = next ? 'pointer' : 'default'; writeState(next); }, @@ -87,23 +80,14 @@ export function addMinimizeToggle(element, storageKey, options = {}) { const minimizeBtn = document.createElement('button'); minimizeBtn.className = buttonClassName; - minimizeBtn.innerHTML = '▼'; + minimizeBtn.innerHTML = '▶'; minimizeBtn.style.cssText = ` display: inline-flex; align-items: center; justify-content: center; - width: 16px; - min-width: 16px; - height: 16px; - background: none; - border: none; - color: #888; cursor: pointer; user-select: none; - padding: 2px 4px; - margin: 0; - font-size: 10px; - line-height: 1; + transform: rotate(0deg); `; minimizeBtn.title = 'Minimize/Maximize'; minimizeBtn.addEventListener( @@ -122,12 +106,6 @@ export function addMinimizeToggle(element, storageKey, options = {}) { title.textContent = header.textContent.replace(/[▼▶]/g, '').trim(); title.dataset.dragHandle = 'true'; title.style.flex = '1'; - title.style.cursor = 'grab'; - title.style.userSelect = 'none'; - title.style.fontFamily = "'JetBrains Mono', monospace"; - title.style.fontSize = '13px'; - title.style.fontWeight = '700'; - title.style.color = titleColor; header.textContent = ''; header.appendChild(title); @@ -141,7 +119,7 @@ export function addMinimizeToggle(element, storageKey, options = {}) { e.stopPropagation(); const hidden = contentWrapper.style.display === 'none'; contentWrapper.style.display = hidden ? 'block' : 'none'; - minimizeBtn.innerHTML = hidden ? '▼' : '▶'; + minimizeBtn.style.transform = hidden ? 'rotate(90deg)' : 'rotate(0deg)'; element.style.cursor = hidden ? 'default' : 'pointer'; writeState(!hidden); }, diff --git a/src/plugins/layers/makeDraggable.js b/src/plugins/layers/makeDraggable.js index f805bc7f..a8584d0b 100644 --- a/src/plugins/layers/makeDraggable.js +++ b/src/plugins/layers/makeDraggable.js @@ -32,7 +32,22 @@ function clampToViewport(el, margin = 40) { el.style.top = top + 'px'; } -export function makeDraggable(el, storageKey, skipPositionLoad = false) { +/** + * Helper for snapping to a grid + */ +function snapToGrid(value, gridSize) { + if (!gridSize) return value; + return Math.round(value / gridSize) * gridSize; +} + +export function makeDraggable( + el, + storageKey, + { + skipPositionLoad = false, + snap = 0, // pixels; 0 disables snapping + } = {}, +) { if (!el) return; // Cancel any previous listeners for this storageKey (e.g. after layout change) @@ -142,6 +157,7 @@ export function makeDraggable(el, storageKey, skipPositionLoad = false) { (e) => { if (e.button !== 0) return; isDragging = true; + updateCursor(); didDrag = false; startX = e.clientX; startY = e.clientY; @@ -162,11 +178,20 @@ export function makeDraggable(el, storageKey, skipPositionLoad = false) { 'mousemove', (e) => { if (!isDragging) return; + if (!didDrag && (Math.abs(e.clientX - startX) > 2 || Math.abs(e.clientY - startY) > 2)) { didDrag = true; } - el.style.left = startLeft + (e.clientX - startX) + 'px'; - el.style.top = startTop + (e.clientY - startY) + 'px'; + + let nextLeft = startLeft + (e.clientX - startX); + let nextTop = startTop + (e.clientY - startY); + + // Snap to grid if enabled + nextLeft = snapToGrid(nextLeft, snap); + nextTop = snapToGrid(nextTop, snap); + + el.style.left = nextLeft + 'px'; + el.style.top = nextTop + 'px'; }, { signal }, ); @@ -174,7 +199,7 @@ export function makeDraggable(el, storageKey, skipPositionLoad = false) { // --- Mouseup: stop drag, clamp, save --- document.addEventListener( 'mouseup', - () => { + (e) => { if (!isDragging) return; isDragging = false; el.style.opacity = '1'; @@ -182,7 +207,11 @@ export function makeDraggable(el, storageKey, skipPositionLoad = false) { updateCursor(); suppressClick = didDrag; - // Clamp so element can't be lost off-screen + if (snap) { + el.style.left = snapToGrid(el.offsetLeft, snap) + 'px'; + el.style.top = snapToGrid(el.offsetTop, snap) + 'px'; + } + clampToViewport(el); const topPercent = (el.offsetTop / window.innerHeight) * 100; diff --git a/src/plugins/layers/useGrayLine.js b/src/plugins/layers/useGrayLine.js index a7c2503d..9c497646 100644 --- a/src/plugins/layers/useGrayLine.js +++ b/src/plugins/layers/useGrayLine.js @@ -286,24 +286,14 @@ export function useLayer({ enabled = false, opacity = 0.5, map = null }) { const GrayLineControl = L.Control.extend({ options: { position: 'topright' }, onAdd: function () { - const container = L.DomUtil.create('div', 'grayline-control'); - container.style.cssText = ` - background: var(--bg-panel); - padding: 12px; - border-radius: 5px; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--text-primary); - border: 1px solid var(--border-color); - box-shadow: 0 2px 8px rgba(0,0,0,0.3); - min-width: 200px; - `; + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const container = L.DomUtil.create('div', 'grayline-control', panelWrapper); const now = new Date(); const timeStr = now.toUTCString(); container.innerHTML = ` -
🌅 Gray Line
+
🌅 Gray Line
UTC TIME
@@ -338,7 +328,7 @@ export function useLayer({ enabled = false, opacity = 0.5, map = null }) { L.DomEvent.disableClickPropagation(container); L.DomEvent.disableScrollPropagation(container); - return container; + return panelWrapper; }, }); @@ -362,7 +352,7 @@ export function useLayer({ enabled = false, opacity = 0.5, map = null }) { } catch (e) {} } - makeDraggable(container, 'grayline-position'); + makeDraggable(container, 'grayline-position', { snap: 5 }); addMinimizeToggle(container, 'grayline-position', { contentClassName: 'grayline-panel-content', buttonClassName: 'grayline-minimize-btn', diff --git a/src/plugins/layers/useLightning.js b/src/plugins/layers/useLightning.js index 152001f4..809e5981 100644 --- a/src/plugins/layers/useLightning.js +++ b/src/plugins/layers/useLightning.js @@ -14,7 +14,7 @@ export const metadata = { category: 'weather', defaultEnabled: false, defaultOpacity: 0.9, - version: '2.0.0', + version: '2.0.1', }; // LZW decompression - Blitzortung uses LZW compression for WebSocket data @@ -45,15 +45,17 @@ function lzwDecode(compressed) { } // Haversine formula for distance calculation -function calculateDistance(lat1, lon1, lat2, lon2, unit = 'km') { - const R = unit === 'km' ? 6371.14 : 3963.1; // Earth radius in km or miles +function calculateDistance(lat1, lon1, lat2, lon2) { + // Calculate the distance in both km and miles, returning both + const Rkm = 6371.14; // Earth radius in km + const Rmiles = 3963.1; // Earth radius in miles const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; + return { km: Rkm * c, miles: Rmiles * c }; } // Strike age colors (fading over time) @@ -65,7 +67,7 @@ function getStrikeColor(ageMinutes) { return '#8B4513'; // Brown (very old, >30 min) } -export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemoryMode = false }) { +export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemoryMode = false, allUnits }) { const [strikeMarkers, setStrikeMarkers] = useState([]); const [lightningData, setLightningData] = useState([]); const [statsControl, setStatsControl] = useState(null); @@ -81,7 +83,9 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory // Low memory mode limits const MAX_STRIKES = lowMemoryMode ? 100 : 500; - const STRIKE_RETENTION_MS = lowMemoryMode ? 60000 : 300000; // 1 min vs 5 min + const STRIKE_RETENTION_MS = 1800000; // 30 min + + const unitsStr = allUnits.dist === 'metric' ? 'km' : 'miles'; // Fetch WebSocket key from Blitzortung (fallback to 111) useEffect(() => { @@ -131,8 +135,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory id: `strike_${data.time}_${data.lat}_${data.lon}`, lat: parseFloat(data.lat), lon: parseFloat(data.lon), - timestamp: parseInt(data.time), - age: (Date.now() - parseInt(data.time)) / 1000, + timestamp: parseInt(data.time / 1000000), intensity: Math.abs(data.pol || 0), polarity: (data.pol || 0) >= 0 ? 'positive' : 'negative', altitude: data.alt || 0, @@ -245,12 +248,14 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory const newMarkers = []; const currentStrikeIds = new Set(); + const now = Date.now(); lightningData.forEach((strike) => { - const { id, lat, lon, age, intensity, polarity } = strike; + const { id, lat, lon, timestamp, intensity, polarity } = strike; currentStrikeIds.add(id); - const ageMinutes = age / 60; + const ageSeconds = (now - timestamp) / 1000; + const ageMinutes = ageSeconds / 60; // Only animate NEW strikes (not seen before) const isNewStrike = !previousStrikeIds.current.has(id); @@ -303,7 +308,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory }); // Popup with strike details - const ageStr = ageMinutes < 1 ? `${Math.round(age)}s ago` : `${Math.round(ageMinutes)}m ago`; + const ageStr = ageMinutes < 1 ? `${Math.round(ageSeconds)}s ago` : `${Math.round(ageMinutes)}m ago`; marker.bindPopup(`
@@ -444,19 +449,11 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory options: { position: 'topright' }, onAdd: function () { console.log('[Lightning] StatsControl onAdd called'); - const div = L.DomUtil.create('div', 'lightning-stats'); - div.style.cssText = ` - background: var(--bg-panel); - padding: 10px; - border-radius: 8px; - border: 1px solid var(--border-color); - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--text-primary); - min-width: 180px; - `; + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const div = L.DomUtil.create('div', 'lightning-stats', panelWrapper); + div.innerHTML = ` -
⚡️ Lightning Activity
+
⚡️ Lightning Activity
Connecting...
`; @@ -465,7 +462,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory L.DomEvent.disableScrollPropagation(div); console.log('[Lightning] Stats panel div created'); - return div; + return panelWrapper; }, }); @@ -495,7 +492,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory } } - makeDraggable(container, 'lightning-stats-position'); + makeDraggable(container, 'lightning-stats-position', { snap: 5 }); addMinimizeToggle(container, 'lightning-stats-position', { contentClassName: 'lightning-panel-content', buttonClassName: 'lightning-minimize-btn', @@ -602,8 +599,8 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory const nearbyNewStrikes = lightningData.filter((strike) => { if (strike.timestamp < ONE_MINUTE_AGO) return false; - const distance = calculateDistance(stationLat, stationLon, strike.lat, strike.lon, 'km'); - return distance <= ALERT_RADIUS_KM; + const distance = calculateDistance(stationLat, stationLon, strike.lat, strike.lon); + return distance.km <= ALERT_RADIUS_KM; }); // Flash the stats panel red if there are nearby strikes @@ -692,28 +689,17 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory const ProximityControl = L.Control.extend({ options: { position: 'bottomright' }, onAdd: function () { - const div = L.DomUtil.create('div', 'lightning-proximity'); - div.style.cssText = ` - background: var(--bg-panel); - padding: 10px; - border-radius: 8px; - border: 1px solid var(--border-color); - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--text-primary); - min-width: 200px; - max-width: 280px; - `; - div.innerHTML = ` -
📍 Nearby Strikes (30km)
-
No recent strikes
- `; + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const div = L.DomUtil.create('div', 'lightning-proximity', panelWrapper); + + // Unfortunately, to fit both km and miles in the header we need to override the font size + div.innerHTML = `
📍 Nearby Strikes(30km/18.6miles)
No recent strikes
`; // Prevent map interaction L.DomEvent.disableClickPropagation(div); L.DomEvent.disableScrollPropagation(div); - return div; + return panelWrapper; }, }); @@ -737,9 +723,8 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory // Default to CENTER of screen (not corner!) container.style.position = 'fixed'; - container.style.top = '50%'; - container.style.left = '50%'; - container.style.transform = 'translate(-50%, -50%)'; + container.style.top = '45%'; // NOTE: using 45% instead of 50% with transform: translateX/Y due to dragging issues + container.style.left = '45%'; container.style.right = 'auto'; container.style.bottom = 'auto'; container.style.zIndex = '1001'; // Ensure it's on top @@ -780,7 +765,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory positionLoaded = true; console.log('[Lightning] Proximity: Converted pixel to percentage:', { topPercent, leftPercent }); } else { - console.log('[Lightning] Proximity: Saved pixel position off-screen, using center'); + console.log('[Lightning] Proximity: Saved pixel position off-screen, using default'); localStorage.removeItem('lightning-proximity-position'); } } @@ -790,7 +775,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory } // Make draggable - pass flag to skip position loading since we already did it - makeDraggable(container, 'lightning-proximity-position', positionLoaded); + makeDraggable(container, 'lightning-proximity-position', { skipPositionLoad: positionLoaded, snap: 5 }); addMinimizeToggle(container, 'lightning-proximity-position', { contentClassName: 'lightning-panel-content', buttonClassName: 'lightning-minimize-btn', @@ -851,15 +836,15 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory const distance = calculateDistance(stationLat, stationLon, strike.lat, strike.lon, 'km'); return { ...strike, distance }; }) - .filter((strike) => strike.distance <= PROXIMITY_RADIUS_KM) - .sort((a, b) => a.distance - b.distance); // Sort by distance (closest first) + .filter((strike) => strike.distance.km <= PROXIMITY_RADIUS_KM) + .sort((a, b) => a.distance.km - b.distance.km); // Sort by distance (closest first) let contentHTML = ''; if (nearbyStrikes.length === 0) { contentHTML = ` -
- ✅ No strikes within 30km
+
+ ✅ No strikes within 30km (18.6 miles)
All clear
`; @@ -868,6 +853,8 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory const ageMinutes = Math.floor((now - closestStrike.timestamp) / 60000); const ageSeconds = Math.floor((now - closestStrike.timestamp) / 1000); const ageStr = ageMinutes > 0 ? `${ageMinutes}m ago` : `${ageSeconds}s ago`; + const closestStrikeDistance = + allUnits.dist === 'metric' ? closestStrike.distance.km : closestStrike.distance.miles; contentHTML = `
@@ -875,7 +862,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory ⚡ ${nearbyStrikes.length} strike${nearbyStrikes.length > 1 ? 's' : ''} detected
- Closest: ${closestStrike.distance.toFixed(1)} km
+ Closest: ${closestStrikeDistance.toFixed(1)} ${unitsStr}
Time: ${ageStr}
Polarity: ${closestStrike.polarity === 'positive' ? '+' : '-'} ${Math.round(closestStrike.intensity)} kA
@@ -888,9 +875,10 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory .map((strike, idx) => { const age = Math.floor((now - strike.timestamp) / 1000); const timeStr = age < 60 ? `${age}s` : `${Math.floor(age / 60)}m`; + const dist = (allUnits.dist === 'metric' ? strike.distance.km : strike.distance.miles).toFixed(1); return `
- ${idx + 1}. ${strike.distance.toFixed(1)} km • ${timeStr} • ${strike.polarity === 'positive' ? '+' : '-'}${Math.round(strike.intensity)} kA + ${idx + 1}. ${dist} ${unitsStr} • ${timeStr} • ${strike.polarity === 'positive' ? '+' : '-'}${Math.round(strike.intensity)} kA
`; }) diff --git a/src/plugins/layers/useMUFMap.js b/src/plugins/layers/useMUFMap.js index ad77697e..8dbf041d 100644 --- a/src/plugins/layers/useMUFMap.js +++ b/src/plugins/layers/useMUFMap.js @@ -295,34 +295,16 @@ export function useLayer({ map, enabled, opacity }) { const MUFControl = L.Control.extend({ options: { position: 'topright' }, - onAdd() { - const container = L.DomUtil.create('div', 'muf-map-control'); - L.DomEvent.disableClickPropagation(container); - L.DomEvent.disableScrollPropagation(container); + onAdd: function () { + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const container = L.DomUtil.create('div', 'muf-map-control', panelWrapper); container.innerHTML = ` -
-
- 📡 MUF Map - -
-
+
📡 MUF Map
+
- 3 MHz - 14 - 28 - 40+ + 3 MHz + 14 + 28 + 40+
-
+
${loading ? 'Loading...' : stationCount > 0 ? `${stationCount} ionosondes` : 'Waiting for data...'}
-
-
`; - return container; + + // Prevent map interaction when clicking/dragging on this control + L.DomEvent.disableClickPropagation(container); + L.DomEvent.disableScrollPropagation(container); + + return panelWrapper; }, }); @@ -356,13 +341,14 @@ export function useLayer({ map, enabled, opacity }) { map.addControl(controlRef.current); setTimeout(() => { - const container = controlRef.current?._container; - if (!container) return; - addMinimizeToggle(container, 'muf-map-position', { - contentClassName: 'muf-panel-content', - buttonClassName: 'muf-minimize-btn', - }); - makeDraggable(container, 'muf-map-position'); + const container = document.querySelector('.muf-map-control'); + if (container) { + makeDraggable(container, 'muf-map-position', { snap: 5 }); + addMinimizeToggle(container, 'muf-map-position', { + contentClassName: 'muf-panel-content', + buttonClassName: 'muf-minimize-btn', + }); + } }, 150); }, [enabled, map, stations, loading]); diff --git a/src/plugins/layers/useN3FJPLoggedQSOs.js b/src/plugins/layers/useN3FJPLoggedQSOs.js index 19ad9699..e392e8f1 100644 --- a/src/plugins/layers/useN3FJPLoggedQSOs.js +++ b/src/plugins/layers/useN3FJPLoggedQSOs.js @@ -20,6 +20,9 @@ const POLL_MS = 2000; const STORAGE_MINUTES_KEY = 'n3fjp_display_minutes'; const STORAGE_COLOR_KEY = 'n3fjp_line_color'; +// Sanitize CSS color values from localStorage to prevent innerHTML injection +const sanitizeColor = (c) => /^(#[0-9a-f]{3,8}|[a-z]{3,20})$/i.test(c) ? c : '#3388ff'; + export function useLayer({ enabled = false, opacity = 0.9, map = null }) { const [layersRef, setLayersRef] = useState([]); const [qsos, setQsos] = useState([]); @@ -35,7 +38,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { }); const [lineColor, setLineColor] = useState(() => { - return localStorage.getItem(STORAGE_COLOR_KEY) || '#3388ff'; // Leaflet default blue-ish + return sanitizeColor(localStorage.getItem(STORAGE_COLOR_KEY) || '#3388ff'); }); // Poll the server for QSOs @@ -81,20 +84,13 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { const Control = L.Control.extend({ options: { position: 'topright' }, - onAdd() { - const div = L.DomUtil.create('div', 'n3fjp-control'); - div.style.cssText = ` - background: var(--bg-panel); - padding: 10px; - border-radius: 8px; - border: 1px solid var(--border-color); - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--text-primary); - min-width: 190px; - `; + onAdd: function () { + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const div = L.DomUtil.create('div', 'n3fjp-control', panelWrapper); + div.innerHTML = ` -
🗺️ N3FJP Logged QSOs
+
🗺️ N3FJP Logged QSOs
+
QSOs: ${qsos.length}
Display: ${displayMinutes} min
@@ -105,7 +101,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { L.DomEvent.disableClickPropagation(div); L.DomEvent.disableScrollPropagation(div); - return div; + return panelWrapper; }, }); @@ -113,26 +109,26 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { map.addControl(controlRef.current); setTimeout(() => { - const container = controlRef.current?._container; - if (!container) return; + const container = document.querySelector('.n3fjp-control'); + if (container) { + const saved = localStorage.getItem('n3fjp-position'); + if (saved) { + try { + const { top, left } = JSON.parse(saved); + container.style.position = 'fixed'; + container.style.top = top + 'px'; + container.style.left = left + 'px'; + container.style.right = 'auto'; + container.style.bottom = 'auto'; + } catch {} + } - const saved = localStorage.getItem('n3fjp-position'); - if (saved) { - try { - const { top, left } = JSON.parse(saved); - container.style.position = 'fixed'; - container.style.top = top + 'px'; - container.style.left = left + 'px'; - container.style.right = 'auto'; - container.style.bottom = 'auto'; - } catch {} + makeDraggable(container, 'n3fjp-position', { snap: 5 }); + addMinimizeToggle(container, 'n3fjp-position', { + contentClassName: 'n3fjp-panel-content', + buttonClassName: 'n3fjp-minimize-btn', + }); } - - addMinimizeToggle(container, 'n3fjp-position', { - contentClassName: 'n3fjp-panel-content', - buttonClassName: 'n3fjp-minimize-btn', - }); - makeDraggable(container, 'n3fjp-position'); }, 150); return () => { @@ -167,7 +163,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { if (Number.isFinite(m)) setDisplayMinutes(m); } catch {} try { - const c = localStorage.getItem(STORAGE_COLOR_KEY) || '#3388ff'; + const c = sanitizeColor(localStorage.getItem(STORAGE_COLOR_KEY) || '#3388ff'); setLineColor(c); } catch {} }; diff --git a/src/plugins/layers/useRBN.js b/src/plugins/layers/useRBN.js index 2a5ceba2..6e28e2ac 100644 --- a/src/plugins/layers/useRBN.js +++ b/src/plugins/layers/useRBN.js @@ -437,11 +437,11 @@ export function useLayer({ marker.bindPopup(`
- 📡 ${skimmerCall}
- Heard: ${callsign}
- SNR: ${snr} dB
- Band: ${band}
- Freq: ${(freq / 1000).toFixed(1)} kHz
+ 📡 ${skimmerCall}
+ Heard: ${callsign}
+ SNR: ${snr} dB
+ Band: ${band}
+ Freq: ${(freq / 1000).toFixed(1)} kHz
Grid: ${skimmerGrid}
Time: ${timestamp.toLocaleTimeString()}
@@ -454,29 +454,23 @@ export function useLayer({ // Create control panel useEffect(() => { - if (!map || !enabled) return; - - // Create control panel - const control = L.control({ position: 'topright' }); - - control.onAdd = function () { - const div = L.DomUtil.create('div', 'leaflet-bar leaflet-control rbn-control'); - div.style.background = 'var(--bg-panel)'; - div.style.padding = '10px'; - div.style.borderRadius = '8px'; - div.style.minWidth = '250px'; - div.style.color = 'var(--text-primary)'; - div.style.fontFamily = "'JetBrains Mono', monospace"; - div.style.fontSize = '12px'; - div.style.border = '1px solid var(--border-color)'; - - div.innerHTML = ` -
- 📡 RBN: ${callsign} -
-
- Spots: 0 | Skimmers: 0
- Avg SNR: 0 dB + if (!enabled || !map || controlRef.current) return; + + const Control = L.Control.extend({ + options: { position: 'topright' }, + onAdd: function () { + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const div = L.DomUtil.create('div', 'rbn-control', panelWrapper); + div.style.cssText = ` + min-width: 250px; + max-width: 300px; + `; + div.innerHTML = ` +
📡 RBN: ${callsign}
+ +
+ Spots: 0 | Skimmers: 0
+ Avg SNR: 0 dB
@@ -513,88 +507,91 @@ export function useLayer({
Data: reversebeacon.net | Update: 10sec
- `; - - // Add event listeners - setTimeout(() => { - const bandSelect = document.getElementById('rbn-band-select'); - const timeSlider = document.getElementById('rbn-time-slider'); - const timeValue = document.getElementById('rbn-time-value'); - const snrSlider = document.getElementById('rbn-snr-slider'); - const snrValue = document.getElementById('rbn-snr-value'); - const pathsCheck = document.getElementById('rbn-paths-check'); - - if (bandSelect) { - bandSelect.value = selectedBand; - bandSelect.addEventListener('change', (e) => setSelectedBand(e.target.value)); - } - - if (timeSlider && timeValue) { - // Set initial value - timeSlider.value = timeWindow; - if (timeWindow < 1) { - timeValue.textContent = (timeWindow * 60).toFixed(0) + 's'; - } else { - timeValue.textContent = timeWindow.toFixed(1) + 'min'; + `; + + // Add event listeners + setTimeout(() => { + const bandSelect = document.getElementById('rbn-band-select'); + const timeSlider = document.getElementById('rbn-time-slider'); + const timeValue = document.getElementById('rbn-time-value'); + const snrSlider = document.getElementById('rbn-snr-slider'); + const snrValue = document.getElementById('rbn-snr-value'); + const pathsCheck = document.getElementById('rbn-paths-check'); + + if (bandSelect) { + bandSelect.value = selectedBand; + bandSelect.addEventListener('change', (e) => setSelectedBand(e.target.value)); } - timeSlider.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - // Display as seconds if < 1 minute, otherwise minutes - if (val < 1) { - timeValue.textContent = (val * 60).toFixed(0) + 's'; + if (timeSlider && timeValue) { + // Set initial value + timeSlider.value = timeWindow; + if (timeWindow < 1) { + timeValue.textContent = (timeWindow * 60).toFixed(0) + 's'; } else { - timeValue.textContent = val.toFixed(1) + 'min'; + timeValue.textContent = timeWindow.toFixed(1) + 'min'; } - setTimeWindow(val); - }); - } - if (snrSlider && snrValue) { - snrSlider.value = minSNR; - snrValue.textContent = minSNR; - snrSlider.addEventListener('input', (e) => { - const val = e.target.value; - snrValue.textContent = val; - setMinSNR(parseInt(val)); - }); - } + timeSlider.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + // Display as seconds if < 1 minute, otherwise minutes + if (val < 1) { + timeValue.textContent = (val * 60).toFixed(0) + 's'; + } else { + timeValue.textContent = val.toFixed(1) + 'min'; + } + setTimeWindow(val); + }); + } - if (pathsCheck) { - pathsCheck.checked = showPaths; - pathsCheck.addEventListener('change', (e) => setShowPaths(e.target.checked)); - } - }, 100); + if (snrSlider && snrValue) { + snrSlider.value = minSNR; + snrValue.textContent = minSNR; + snrSlider.addEventListener('input', (e) => { + const val = e.target.value; + snrValue.textContent = val; + setMinSNR(parseInt(val)); + }); + } - L.DomEvent.disableClickPropagation(div); - L.DomEvent.disableScrollPropagation(div); + if (pathsCheck) { + pathsCheck.checked = showPaths; + pathsCheck.addEventListener('change', (e) => setShowPaths(e.target.checked)); + } + }, 100); - return div; - }; + L.DomEvent.disableClickPropagation(div); + L.DomEvent.disableScrollPropagation(div); + + return panelWrapper; + }, + }); - control.addTo(map); + const control = new Control(); + map.addControl(control); controlRef.current = control; // Make the control draggable and minimizable // Use setTimeout to ensure DOM is ready setTimeout(() => { - const controlElement = control.getContainer(); - if (controlElement) { + // const container = control.getContainer(); + const container = document.querySelector('.rbn-control'); + if (container) { // Apply saved position IMMEDIATELY before making draggable const saved = localStorage.getItem('rbn-panel-position'); if (saved) { try { const { top, left } = JSON.parse(saved); - controlElement.style.position = 'fixed'; - controlElement.style.top = top + 'px'; - controlElement.style.left = left + 'px'; - controlElement.style.right = 'auto'; - controlElement.style.bottom = 'auto'; + container.style.position = 'fixed'; + container.style.top = top + 'px'; + container.style.left = left + 'px'; + container.style.right = 'auto'; + container.style.bottom = 'auto'; } catch (e) {} } - makeDraggable(controlElement, 'rbn-panel-position'); - addMinimizeToggle(controlElement, 'rbn-panel', { + makeDraggable(container, 'rbn-panel-position', { snap: 5 }); + addMinimizeToggle(container, 'rbn-panel-position', { contentClassName: 'rbn-panel-content', buttonClassName: 'rbn-minimize-btn', }); @@ -622,8 +619,8 @@ export function useLayer({ if (statsDisplay) { statsDisplay.innerHTML = ` - Spots: ${stats.total} | Skimmers: ${stats.skimmers}
- Avg SNR: ${stats.avgSNR} dB + Spots: ${stats.total} | Skimmers: ${stats.skimmers}
+ Avg SNR: ${stats.avgSNR} dB `; } }, [enabled, stats]); diff --git a/src/plugins/layers/useVOACAPHeatmap.js b/src/plugins/layers/useVOACAPHeatmap.js index 8b0fd52b..6c9ef77c 100644 --- a/src/plugins/layers/useVOACAPHeatmap.js +++ b/src/plugins/layers/useVOACAPHeatmap.js @@ -179,7 +179,7 @@ export function useLayer({ map, enabled, opacity, locator }) { // Create control panel useEffect(() => { - if (!map || !enabled) return; + if (!enabled || !map || controlRef.current) return; // Avoid duplicate controls if (controlRef.current) { @@ -192,9 +192,8 @@ export function useLayer({ map, enabled, opacity, locator }) { const VOACAPControl = L.Control.extend({ options: { position: 'topright' }, onAdd: function () { - const container = L.DomUtil.create('div', 'voacap-heatmap-control'); - L.DomEvent.disableClickPropagation(container); - L.DomEvent.disableScrollPropagation(container); + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const container = L.DomUtil.create('div', 'voacap-heatmap-control', panelWrapper); const bandOptions = BANDS.map( (b, i) => ``, @@ -213,89 +212,73 @@ export function useLayer({ map, enabled, opacity, locator }) { .join(''); container.innerHTML = ` -
-
- 🌐 VOACAP Heatmap - -
-
+
🌐 VOACAP Heatmap
+
- +
- +
- +
- +
- Poor + Poor - Low + Low - Fair + Fair - Good + Good
-
+
${loading ? 'Loading...' : data ? `${data.mode || 'SSB'} ${data.power || 100}W | SFI: ${data.solarData?.sfi} K: ${data.solarData?.kIndex}` : 'Ready'}
-
-
`; + L.DomEvent.disableClickPropagation(container); + L.DomEvent.disableScrollPropagation(container); - return container; + return panelWrapper; }, }); - controlRef.current = new VOACAPControl(); - map.addControl(controlRef.current); + const control = new VOACAPControl(); + map.addControl(control); + controlRef.current = control; // Helper to update both plugin state AND global config in localStorage const updateGlobalConfig = (mode, power) => { @@ -311,28 +294,28 @@ export function useLayer({ map, enabled, opacity, locator }) { // Wire up event handlers after DOM is ready setTimeout(() => { - const container = controlRef.current?._container; - if (!container) return; + const container = document.querySelector('.voacap-heatmap-control'); + if (container) { + // Apply saved position + const saved = localStorage.getItem('voacap-heatmap-position'); + if (saved) { + try { + const { top, left } = JSON.parse(saved); + container.style.position = 'fixed'; + container.style.top = top + 'px'; + container.style.left = left + 'px'; + container.style.right = 'auto'; + container.style.bottom = 'auto'; + } catch (e) {} + } - // Apply saved position - const saved = localStorage.getItem('voacap-heatmap-position'); - if (saved) { - try { - const { top, left } = JSON.parse(saved); - container.style.position = 'fixed'; - container.style.top = top + 'px'; - container.style.left = left + 'px'; - container.style.right = 'auto'; - container.style.bottom = 'auto'; - } catch (e) {} + makeDraggable(container, 'voacap-heatmap-position', { snap: 5 }); + addMinimizeToggle(container, 'voacap-heatmap-position', { + contentClassName: 'voacap-panel-content', + buttonClassName: 'voacap-minimize-btn', + }); } - addMinimizeToggle(container, 'voacap-heatmap-position', { - contentClassName: 'voacap-panel-content', - buttonClassName: 'voacap-minimize-btn', - }); - makeDraggable(container, 'voacap-heatmap-position'); - const bandSelect = document.getElementById('voacap-band-select'); const gridSelect = document.getElementById('voacap-grid-select'); const modeSelect = document.getElementById('voacap-mode-select'); diff --git a/src/plugins/layers/useWSPR.js b/src/plugins/layers/useWSPR.js index 8800bde6..d1afd6e6 100644 --- a/src/plugins/layers/useWSPR.js +++ b/src/plugins/layers/useWSPR.js @@ -399,21 +399,11 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe const FilterControl = L.Control.extend({ options: { position: 'topright' }, onAdd: function () { - const container = L.DomUtil.create('div', 'wspr-filter-control'); - container.style.cssText = ` - background: var(--bg-panel); - padding: 12px; - border-radius: 5px; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--text-primary); - border: 1px solid var(--border-color); - box-shadow: 0 2px 8px rgba(0,0,0,0.3); - min-width: 180px; - `; + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const container = L.DomUtil.create('div', 'wspr-filter-control', panelWrapper); container.innerHTML = ` -
🎛️ Filters
+
🎛️ Filters
@@ -498,7 +488,7 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe L.DomEvent.disableClickPropagation(container); L.DomEvent.disableScrollPropagation(container); - return container; + return panelWrapper; }, }); @@ -524,7 +514,7 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe } catch (e) {} } - makeDraggable(container, 'wspr-filter-position'); + makeDraggable(container, 'wspr-filter-position', { snap: 5 }); addMinimizeToggle(container, 'wspr-filter-position', { contentClassName: 'wspr-panel-content', buttonClassName: 'wspr-minimize-btn', @@ -594,20 +584,12 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe const StatsControl = L.Control.extend({ options: { position: 'topleft' }, onAdd: function () { - const div = L.DomUtil.create('div', 'wspr-stats'); - div.style.cssText = ` - background: var(--bg-panel); - padding: 12px; - border-radius: 5px; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--text-primary); - border: 1px solid var(--border-color); - box-shadow: 0 2px 8px rgba(0,0,0,0.3); - min-width: 160px; - `; + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const div = L.DomUtil.create('div', 'wspr-stats', panelWrapper); + div.innerHTML = ` -
📊 WSPR Activity
+
📊 WSPR Activity
+
Propagation Score
--/100
@@ -623,7 +605,7 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe L.DomEvent.disableClickPropagation(div); L.DomEvent.disableScrollPropagation(div); - return div; + return panelWrapper; }, }); @@ -648,7 +630,7 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe } catch (e) {} } - makeDraggable(container, 'wspr-stats-position'); + makeDraggable(container, 'wspr-stats-position', { snap: 5 }); addMinimizeToggle(container, 'wspr-stats-position', { contentClassName: 'wspr-panel-content', buttonClassName: 'wspr-minimize-btn', @@ -660,19 +642,12 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe const LegendControl = L.Control.extend({ options: { position: 'bottomright' }, onAdd: function () { - const div = L.DomUtil.create('div', 'wspr-legend'); - div.style.cssText = ` - background: var(--bg-panel); - padding: 10px; - border-radius: 5px; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--text-primary); - border: 1px solid var(--border-color); - box-shadow: 0 2px 8px rgba(0,0,0,0.3); - `; + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const div = L.DomUtil.create('div', 'wspr-legend', panelWrapper); + div.innerHTML = ` -
📡 Signal Strength
+
📡 Signal Strength
+
Excellent (> 5 dB)
Good (0 to 5 dB)
Moderate (-10 to 0 dB)
@@ -682,9 +657,10 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe Best DX Paths
`; - return div; + return panelWrapper; }, }); + const legend = new LegendControl(); map.addControl(legend); legendControlRef.current = legend; @@ -706,7 +682,7 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe } catch (e) {} } - makeDraggable(container, 'wspr-legend-position'); + makeDraggable(container, 'wspr-legend-position', { snap: 5 }); addMinimizeToggle(container, 'wspr-legend-position', { contentClassName: 'wspr-panel-content', buttonClassName: 'wspr-minimize-btn', @@ -718,26 +694,17 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe const ChartControl = L.Control.extend({ options: { position: 'bottomleft' }, onAdd: function () { - const div = L.DomUtil.create('div', 'wspr-chart'); - div.style.cssText = ` - background: var(--bg-panel); - padding: 10px; - border-radius: 5px; - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-primary); - border: 1px solid var(--border-color); - box-shadow: 0 2px 8px rgba(0,0,0,0.3); - min-width: 160px; - `; + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const div = L.DomUtil.create('div', 'wspr-chart', panelWrapper); + div.innerHTML = - '
📊 Band Activity
Loading...
'; + '
📊 Band Activity
Loading...
'; // Prevent map interaction when clicking/dragging on this control L.DomEvent.disableClickPropagation(div); L.DomEvent.disableScrollPropagation(div); - return div; + return panelWrapper; }, }); @@ -762,7 +729,7 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe } catch (e) {} } - makeDraggable(container, 'wspr-chart-position'); + makeDraggable(container, 'wspr-chart-position', { snap: 5 }); addMinimizeToggle(container, 'wspr-chart-position', { contentClassName: 'wspr-panel-content', buttonClassName: 'wspr-minimize-btn', @@ -1143,7 +1110,8 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe } else { // Initial render before minimize toggle is added statsContainer.innerHTML = ` -
📊 WSPR Activity
+
📊 WSPR Activity
+ ${contentHTML} `; } @@ -1189,7 +1157,8 @@ export function useLayer({ enabled = false, map = null, callsign, locator, lowMe } else { // Initial render before minimize toggle is added chartContainer.innerHTML = ` -
📊 Band Activity
+
📊 Band Activity
+ ${chartContentHTML} `; } diff --git a/src/store/layoutStore.js b/src/store/layoutStore.js index a06fbe1d..2ad23ee6 100644 --- a/src/store/layoutStore.js +++ b/src/store/layoutStore.js @@ -18,7 +18,22 @@ export const DEFAULT_LAYOUT = { tabSetEnableDrag: true, tabSetEnableTabStrip: true, }, - borders: [], + borders: [ + { + type: 'border', + location: 'left', + id: 'left-border-tabset', + children: [ + { + type: 'tab', + name: 'Lock Layout', + component: 'lock-layout', + id: 'lock-layout-tab', + enableClose: false, + }, + ], + }, + ], layout: { type: 'row', weight: 100, @@ -104,6 +119,7 @@ export const PANEL_DEFINITIONS = { 'world-map': { name: 'World Map', icon: '🗺️', description: 'Interactive world map' }, 'rig-control': { name: 'Rig Control', icon: '📻', description: 'Transceiver control and feedback' }, 'on-air': { name: 'On Air', icon: '🔴', description: 'Large TX status indicator' }, + 'lock-layout': { name: 'Lock the Layout', icon: '🔒', description: 'Lock the layout' }, }; // Load layout from localStorage @@ -113,7 +129,13 @@ export const loadLayout = () => { if (stored) { const parsed = JSON.parse(stored); // Validate basic structure - if (parsed.global && parsed.layout) { + if (parsed.global && parsed.layout && parsed.borders) { + // Use of the Left Border in the dockable layout has been added + // if the user does not have the defined border saved, add the default + if (parsed.borders.length === 0) { + parsed.borders = DEFAULT_LAYOUT.borders; + saveLayout(parsed); + } return parsed; } } diff --git a/src/styles/flexlayout-openhamclock.css b/src/styles/flexlayout-openhamclock.css index 0933c19b..a3500986 100644 --- a/src/styles/flexlayout-openhamclock.css +++ b/src/styles/flexlayout-openhamclock.css @@ -46,7 +46,9 @@ /* ============================================ TAB BUTTONS (Panel Headers) + BORDER BUTTONS (Border Containers) ============================================ */ +.flexlayout__border_button, .flexlayout__tab_button { background: transparent !important; border: none !important; @@ -61,17 +63,20 @@ transition: all 0.15s ease !important; } +.flexlayout__border_button:hover, .flexlayout__tab_button:hover { background: rgba(0, 255, 204, 0.1) !important; color: #e2e8f0 !important; } +.flexlayout__border_button--selected, .flexlayout__tab_button--selected { background: rgba(0, 255, 204, 0.15) !important; color: #00ffcc !important; border-bottom: 2px solid #00ffcc !important; } +.flexlayout__border_button_content, .flexlayout__tab_button_content { display: flex; align-items: center; diff --git a/src/styles/main.css b/src/styles/main.css index fe622449..eb012b85 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -360,6 +360,30 @@ body::before { border-top-color: rgba(10, 14, 20, 0.9) !important; } +/* ============================================ + LOCK LAYOUT PANEL + ============================================ */ +.panel-layout-lock-button { + display: flex; + align-items: center; + gap: 4px; + color: var(--text-muted); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 3px 8px; + font-size: 11px; + font-family: 'JetBrains Mono', monospace; + cursor: pointer; + margin: 1em auto; +} + +.panel-layout-lock-button.locked { + color: var(--accent-amber); + background: rgba(255, 170, 0, 0.15); + border-color: var(--accent-amber); +} + /* ============================================ CUSTOM MARKER STYLES ============================================ */ @@ -489,6 +513,51 @@ body::before { letter-spacing: 0.5px; } +/* ============================================ + Leaflet Control Panels + ============================================ */ +.panel-wrapper > div { + transition: all 0.3s ease; + background: var(--bg-panel); + border-radius: 5px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--text-primary); + border: 1px solid var(--border-color); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + min-width: 200px; + max-width: 280px; +} + +.panel-wrapper > div:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important; +} + +.panel-wrapper > div .floating-panel-header { + user-select: none; + font-family: 'JetBrains Mono', monospace; + font-weight: 700; + margin: 0; + padding: 10px; + font-size: 13px; + color: var(--accent-cyan); + letter-spacing: 0.5px; +} + +/* minimize toggle */ +.panel-wrapper > div .floating-panel-header button { + color: var(--text-secondary); + width: 1em; + min-width: 1em; + height: 1em; + background: none; + border: none; + padding: 0; + margin: 0; + font-size: 1.1em; + line-height: 1em; +} + /* ============================================ DX CLUSTER MAP TOOLTIPS ============================================ */ @@ -599,7 +668,6 @@ body::before { } /* Theme controls */ - #theme-selector-component .theme-select-button { padding: 10px; background: var(--bg-tertiary); @@ -704,22 +772,6 @@ body::before { animation: wspr-marker-pulse 2s ease-in-out infinite; } -/* Control panel transitions */ -.wspr-filter-control, -.wspr-stats, -.wspr-legend, -.wspr-chart { - transition: all 0.3s ease; -} - -.wspr-filter-control:hover, -.wspr-stats:hover, -.wspr-legend:hover, -.wspr-chart:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important; -} - /* Filter input styles */ .wspr-filter-control select, .wspr-filter-control input[type='range'] { @@ -962,6 +1014,17 @@ body::before { z-index: 10000 !important; } +/* content spacing in panels */ +.wspr-panel-content, +.lightning-panel-content, +.grayline-panel-content, +.muf-panel-content, +.n3fjp-panel-content, +.rbn-panel-content, +.voacap-panel-content { + padding: 0 10px 10px 10px; +} + /* ============================================ RESPONSIVE: Phone & Small Screen Overrides ============================================ */ diff --git a/src/utils/ctyLookup.js b/src/utils/ctyLookup.js index 8508379d..4acfb1d4 100644 --- a/src/utils/ctyLookup.js +++ b/src/utils/ctyLookup.js @@ -18,6 +18,7 @@ import { apiFetch } from './apiFetch'; let prefixes = null; // { PREFIX: { entity, dxcc, cq, itu, cont, lat, lon } } let exact = null; // { CALLSIGN: { ... } } +let entities = []; // [{ entity, dxcc, cq, itu, cont, lat, lon }] let loaded = false; let loading = false; @@ -37,8 +38,10 @@ export async function initCtyLookup() { if (data?.prefixes && data?.exact) { prefixes = data.prefixes; exact = data.exact; + entities = Array.isArray(data.entities) ? data.entities : []; loaded = true; console.log(`[CTY] Loaded: ${Object.keys(prefixes).length} prefixes, ${Object.keys(exact).length} exact calls`); + window.dispatchEvent(new CustomEvent('openhamclock-cty-loaded')); } } catch (err) { console.warn('[CTY] Failed to load cty.dat data:', err.message); @@ -54,6 +57,10 @@ export function isCtyLoaded() { return loaded; } +export function getCtyEntities() { + return entities; +} + /** * Look up a callsign in the cty.dat database. * Returns null if not found or data not yet loaded.