From 099a6cc4e8efafac172d387ac155b487cf7f4c40 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Wed, 15 Apr 2026 20:31:54 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20HTTPS/WebSocket=20mixed-content=20?= =?UTF-8?q?=E2=80=94=20add=20PHP=20SSE=20relay=20for=20scale=20gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser (HTTPS) cannot connect to ws:// directly (mixed-content block). Solution: PHP SSE relay bridges the gap server-side. - api/scale_relay.php: GET ?url=ws://ip:port PHP opens WS connection to Android gateway, streams JSON frames as Server-Sent Events to the browser over existing HTTPS connection. Includes WS handshake, masked client frames, frame decoder, keep-alive. - api/scale_ping.php: GET ?url=ws://ip:port One-shot connectivity test, returns {"ok":true} or {"ok":false,"error"}. - app.js: WebSocket -> EventSource (SSE) _scaleWs -> _scaleEs, connects to /api/scale_relay.php testScaleConnection() -> fetch /api/scale_ping.php (no more direct ws://) readScaleWeight(): removed get_weight send (scale streams continuously) --- api/scale_ping.php | 67 +++++++++++++ api/scale_relay.php | 229 ++++++++++++++++++++++++++++++++++++++++++++ assets/js/app.js | 71 ++++++-------- 3 files changed, 327 insertions(+), 40 deletions(-) create mode 100644 api/scale_ping.php create mode 100644 api/scale_relay.php diff --git a/api/scale_ping.php b/api/scale_ping.php new file mode 100644 index 0000000..5b399dc --- /dev/null +++ b/api/scale_ping.php @@ -0,0 +1,67 @@ + false, 'error' => 'Invalid gateway URL (must start with ws://)']); + exit; +} + +$parsed = parse_url($rawUrl); +$host = $parsed['host'] ?? ''; +$port = (int)($parsed['port'] ?? 8765); +$path = ($parsed['path'] ?? '') ?: '/'; + +if (!$host || $port < 1 || $port > 65535) { + echo json_encode(['ok' => false, 'error' => 'Invalid host or port']); + exit; +} + +// Try to open a TCP connection with a 5-second timeout +$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5); +if (!$sock) { + echo json_encode(['ok' => false, 'error' => "Cannot connect to {$host}:{$port} — {$errstr}"]); + exit; +} + +stream_set_timeout($sock, 5); + +// Perform WebSocket handshake +$wsKey = base64_encode(random_bytes(16)); +fwrite($sock, + "GET {$path} HTTP/1.1\r\n" . + "Host: {$host}:{$port}\r\n" . + "Upgrade: websocket\r\n" . + "Connection: Upgrade\r\n" . + "Sec-WebSocket-Key: {$wsKey}\r\n" . + "Sec-WebSocket-Version: 13\r\n" . + "\r\n" +); + +// Read HTTP response (looking for 101 Switching Protocols) +$resp = ''; +while (!feof($sock)) { + $line = fgets($sock, 1024); + if ($line === false) break; + $resp .= $line; + if ($line === "\r\n") break; +} + +fclose($sock); + +if (str_contains($resp, '101')) { + echo json_encode(['ok' => true]); +} else { + echo json_encode(['ok' => false, 'error' => 'WebSocket handshake failed — check that the gateway app is running']); +} diff --git a/api/scale_relay.php b/api/scale_relay.php new file mode 100644 index 0000000..2e90935 --- /dev/null +++ b/api/scale_relay.php @@ -0,0 +1,229 @@ + 'error', 'message' => 'Invalid gateway URL (must start with ws://)']) . "\n\n"; + exit; +} + +$parsed = parse_url($rawUrl); +$wsHost = $parsed['host'] ?? ''; +$wsPort = (int)($parsed['port'] ?? 8765); +$wsPath = ($parsed['path'] ?? '') ?: '/'; + +if (!$wsHost || $wsPort < 1 || $wsPort > 65535) { + header('Content-Type: text/event-stream'); + echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Invalid host or port']) . "\n\n"; + exit; +} + +// ── SSE headers ─────────────────────────────────────────────────────────────── +header('Content-Type: text/event-stream'); +header('Cache-Control: no-cache, no-store, must-revalidate'); +header('X-Accel-Buffering: no'); // Disable nginx / Caddy buffering +header('Content-Encoding: identity'); // Prevent gzip/deflate compression + +set_time_limit(0); +ignore_user_abort(false); // stop when browser closes connection + +// Clear all PHP output-buffering levels so echo/flush goes straight to SAPI +while (ob_get_level() > 0) { + ob_end_clean(); +} + +// ── Connect to Android gateway ──────────────────────────────────────────────── +$sock = @stream_socket_client("tcp://{$wsHost}:{$wsPort}", $errno, $errstr, 5); +if (!$sock) { + echo 'data: ' . json_encode([ + 'type' => 'error', + 'message' => "Cannot connect to gateway ({$wsHost}:{$wsPort}): {$errstr}", + ]) . "\n\n"; + flush(); + exit; +} + +// ── WebSocket handshake (PHP acts as client) ────────────────────────────────── +// RFC 6455: client frames MUST be masked. +$wsKey = base64_encode(random_bytes(16)); + +stream_set_blocking($sock, true); +stream_set_timeout($sock, 5); + +fwrite($sock, + "GET {$wsPath} HTTP/1.1\r\n" . + "Host: {$wsHost}:{$wsPort}\r\n" . + "Upgrade: websocket\r\n" . + "Connection: Upgrade\r\n" . + "Sec-WebSocket-Key: {$wsKey}\r\n" . + "Sec-WebSocket-Version: 13\r\n" . + "\r\n" +); + +// Read HTTP 101 Switching Protocols response +$httpResp = ''; +$deadline = microtime(true) + 5; +while (microtime(true) < $deadline && !feof($sock)) { + $line = fgets($sock, 1024); + if ($line === false) break; + $httpResp .= $line; + if ($line === "\r\n") break; +} + +if (!str_contains($httpResp, '101')) { + fclose($sock); + echo 'data: ' . json_encode([ + 'type' => 'error', + 'message' => 'WebSocket handshake failed — is the gateway URL correct?', + ]) . "\n\n"; + flush(); + exit; +} + +// Switch to non-blocking for poll-based relay loop +stream_set_blocking($sock, false); +stream_set_timeout($sock, 0); + +// Ask gateway for current status immediately +wsSend($sock, json_encode(['type' => 'get_status'])); + +// ── SSE relay loop ──────────────────────────────────────────────────────────── +$buf = ''; +$lastPing = time(); + +while (!connection_aborted()) { + $chunk = @fread($sock, 8192); + + if ($chunk === false || (feof($sock) && $chunk === '')) { + // Gateway closed the connection + echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Gateway disconnected']) . "\n\n"; + flush(); + break; + } + + if ($chunk !== '') { + $buf .= $chunk; + while (($payload = wsRead($buf, $sock)) !== null) { + if ($payload === false) goto done; // close frame received + if ($payload === '') continue; // ping/pong control frames (handled inside wsRead) + echo "data: {$payload}\n\n"; + flush(); + } + } + + // SSE keep-alive comment + gateway WebSocket ping every 20 seconds + if (time() - $lastPing >= 20) { + echo ": keep-alive\n\n"; + flush(); + $lastPing = time(); + wsPing($sock); + } + + usleep(100_000); // 100 ms polling interval +} + +done: +fclose($sock); + +// ── WebSocket helpers ───────────────────────────────────────────────────────── + +/** Send a masked text frame from PHP (client) to the gateway (server). */ +function wsSend($sock, string $payload): void +{ + $len = strlen($payload); + $mask = random_bytes(4); + + if ($len < 126) { + $header = "\x81" . chr(0x80 | $len); + } elseif ($len < 65536) { + $header = "\x81\xFE" . pack('n', $len); // opcode=text, mask bit, 2-byte length + } else { + $header = "\x81\xFF" . pack('J', $len); // opcode=text, mask bit, 8-byte length + } + + $masked = ''; + for ($i = 0; $i < $len; $i++) { + $masked .= $payload[$i] ^ $mask[$i % 4]; + } + + @fwrite($sock, $header . $mask . $masked); +} + +/** Send a masked ping to keep the gateway connection alive. */ +function wsPing($sock): void +{ + @fwrite($sock, "\x89\x80" . random_bytes(4)); // FIN+ping, MASK bit, 4-byte mask, no payload +} + +/** + * Try to decode one complete WebSocket frame from the read buffer. + * The gateway (server) sends unmasked frames to PHP (client). + * + * Returns: + * string — decoded text/binary payload + * '' — control frame (ping/pong) handled internally, skip + * false — close frame received + * null — not enough data yet, read more + */ +function wsRead(string &$buf, $sock): string|null|false +{ + if (strlen($buf) < 2) return null; + + $b0 = ord($buf[0]); + $b1 = ord($buf[1]); + $op = $b0 & 0x0F; + $msk = ($b1 & 0x80) !== 0; // servers never mask, but handle defensively + $len = $b1 & 0x7F; + $off = 2; + + if ($len === 126) { + if (strlen($buf) < 4) return null; + $len = unpack('n', substr($buf, 2, 2))[1]; + $off = 4; + } elseif ($len === 127) { + if (strlen($buf) < 10) return null; + $len = unpack('J', substr($buf, 2, 8))[1]; + $off = 10; + } + + if ($msk) $off += 4; + if (strlen($buf) < $off + $len) return null; + + $maskKey = $msk ? substr($buf, $off - 4, 4) : null; + $payload = substr($buf, $off, $len); + + if ($msk && $maskKey) { + for ($i = 0; $i < strlen($payload); $i++) { + $payload[$i] = $payload[$i] ^ $maskKey[$i % 4]; + } + } + + // Consume frame from buffer + $buf = substr($buf, $off + $len); + + if ($op === 0x8) return false; // close frame + if ($op === 0x9) { // ping → send masked pong + $pLen = strlen($payload); + $mask = random_bytes(4); + $maskedPayload = ''; + for ($i = 0; $i < $pLen; $i++) { + $maskedPayload .= $payload[$i] ^ $mask[$i % 4]; + } + @fwrite($sock, "\x8A" . chr(0x80 | $pLen) . $mask . $maskedPayload); + return ''; + } + if ($op === 0xA) return ''; // pong — ignore + + return $payload; // 0x1 = text, 0x2 = binary +} diff --git a/assets/js/app.js b/assets/js/app.js index 32c63ad..c17cd0f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -61,7 +61,7 @@ const API_BASE = 'api/index.php'; // ===== SMART SCALE GATEWAY ===== // Connects to the Android BLE-WebSocket gateway and provides auto weight reading. -let _scaleWs = null; +let _scaleEs = null; // EventSource for the SSE relay let _scaleConnected = false; let _scaleDevice = null; let _scaleBattery = null; @@ -74,7 +74,7 @@ function scaleInit() { const indicator = document.getElementById('scale-status-indicator'); if (!s.scale_enabled || !s.scale_gateway_url) { if (indicator) indicator.style.display = 'none'; - if (_scaleWs) { try { _scaleWs.close(); } catch(e) {} _scaleWs = null; } + if (_scaleEs) { try { _scaleEs.close(); } catch(e) {} _scaleEs = null; } return; } if (indicator) indicator.style.display = ''; @@ -82,28 +82,21 @@ function scaleInit() { } function _scaleConnect(url) { - if (_scaleWs) { try { _scaleWs.close(); } catch(e) {} _scaleWs = null; } + if (_scaleEs) { try { _scaleEs.close(); } catch(e) {} _scaleEs = null; } if (_scaleReconnectTimer) { clearTimeout(_scaleReconnectTimer); _scaleReconnectTimer = null; } try { - _scaleWs = new WebSocket(url); - _scaleWs.onopen = () => { - _scaleUpdateStatus('searching'); - try { _scaleWs.send(JSON.stringify({ type: 'get_status' })); } catch(e) {} - }; - _scaleWs.onmessage = (evt) => { + // Connect via the PHP SSE relay so the HTTPS page is not blocked by mixed-content + _scaleEs = new EventSource('/api/scale_relay.php?url=' + encodeURIComponent(url)); + _scaleEs.onopen = () => _scaleUpdateStatus('searching'); + _scaleEs.onmessage = (evt) => { try { _scaleOnMessage(JSON.parse(evt.data)); } catch(e) {} }; - _scaleWs.onclose = () => { + _scaleEs.onerror = () => { _scaleConnected = false; _scaleDevice = null; _scaleUpdateStatus('disconnected'); - _scaleReconnectTimer = setTimeout(() => { - _scaleReconnectTimer = null; - const s = getSettings(); - if (s.scale_enabled && s.scale_gateway_url) _scaleConnect(s.scale_gateway_url); - }, 8000); + // EventSource auto-reconnects; no manual timer needed }; - _scaleWs.onerror = () => _scaleUpdateStatus('error'); } catch(e) { _scaleUpdateStatus('error'); } @@ -148,7 +141,7 @@ function _scaleUpdateStatus(state) { * @param {Function} getUnit — function that returns the current unit string ('g', 'ml', 'kg') */ function readScaleWeight(targetInputId, getUnit) { - if (!_scaleWs || _scaleWs.readyState !== WebSocket.OPEN) { + if (!_scaleConnected) { showToast('⚖️ ' + t('scale.not_connected'), 'error'); return; } @@ -170,7 +163,7 @@ function readScaleWeight(targetInputId, getUnit) { closeModal(); showToast(`⚖️ ${val} ${unit}`, 'success'); }; - try { _scaleWs.send(JSON.stringify({ type: 'get_weight' })); } catch(e) {} + // Weight data streams continuously via SSE; _scaleWeightCallback fires on the next stable reading } function _scaleShowReadingModal(targetInputId, unit) { @@ -227,33 +220,31 @@ function testScaleConnection() { statusEl.className = 'settings-status'; statusEl.style.display = 'block'; - let testWs; + const ac = new AbortController(); const timeout = setTimeout(() => { - if (testWs) testWs.close(); + ac.abort(); statusEl.textContent = '❌ ' + t('scale.timeout'); statusEl.className = 'settings-status error'; - }, 6000); - try { - testWs = new WebSocket(url); - testWs.onopen = () => { - try { testWs.send(JSON.stringify({ type: 'ping' })); } catch(e) {} - }; - testWs.onmessage = () => { + }, 8000); + fetch('/api/scale_ping.php?url=' + encodeURIComponent(url), { signal: ac.signal }) + .then(r => r.json()) + .then(data => { clearTimeout(timeout); - testWs.close(); - statusEl.textContent = '✅ ' + t('scale.connected_ok'); - statusEl.className = 'settings-status success'; - }; - testWs.onerror = () => { + if (data.ok) { + statusEl.textContent = '✅ ' + t('scale.connected_ok'); + statusEl.className = 'settings-status success'; + } else { + statusEl.textContent = '❌ ' + (data.error || t('scale.error_connect')); + statusEl.className = 'settings-status error'; + } + }) + .catch(e => { clearTimeout(timeout); - statusEl.textContent = '❌ ' + t('scale.error_connect'); - statusEl.className = 'settings-status error'; - }; - } catch(e) { - clearTimeout(timeout); - statusEl.textContent = '❌ ' + (e.message || t('scale.error_connect')); - statusEl.className = 'settings-status error'; - } + if (e.name !== 'AbortError') { + statusEl.textContent = '❌ ' + t('scale.error_connect'); + statusEl.className = 'settings-status error'; + } + }); } // ===== i18n TRANSLATION SYSTEM =====