fix: HTTPS/WebSocket mixed-content — add PHP SSE relay for scale gateway

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)
This commit is contained in:
dadaloop82
2026-04-15 20:31:54 +00:00
parent a146ba124a
commit 099a6cc4e8
3 changed files with 327 additions and 40 deletions
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* EverShelf Scale Gateway — Connection ping / test
*
* Performs a WebSocket handshake with the gateway and returns
* {"ok":true} on success, {"ok":false,"error":"..."} on failure.
*
* Usage: GET /api/scale_ping.php?url=ws%3A%2F%2F192.168.1.100%3A8765
*/
header('Content-Type: application/json');
header('Cache-Control: no-cache');
$rawUrl = $_GET['url'] ?? '';
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
echo json_encode(['ok' => 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']);
}
+229
View File
@@ -0,0 +1,229 @@
<?php
/**
* EverShelf Scale Gateway — SSE Relay
*
* Bridges the Android BLE gateway (ws://) to Server-Sent Events (SSE) so that
* browsers on HTTPS pages can receive weight data without mixed-content errors.
*
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
*/
// ── Input validation ──────────────────────────────────────────────────────────
$rawUrl = $_GET['url'] ?? '';
// Only accept ws:// scheme with valid host and optional port/path
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
header('Content-Type: text/event-stream');
echo 'data: ' . json_encode(['type' => '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
}
+31 -40
View File
@@ -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 =====