From dafa15808c1919db2d7349a52db5ef96ad33c039 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sun, 12 Jul 2026 19:44:33 +0200 Subject: [PATCH] Add HeartbeatApi with legacy GET support for old app clients. Older builds probe heartbeat.php via GET (ntp/ip/ping); POST-only caused HTTP 405 on some devices. Co-authored-by: Cursor --- src/HeartbeatApi.php | 117 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/HeartbeatApi.php diff --git a/src/HeartbeatApi.php b/src/HeartbeatApi.php new file mode 100644 index 0000000..d3cbe49 --- /dev/null +++ b/src/HeartbeatApi.php @@ -0,0 +1,117 @@ + */ + public static function parseRequest(): array + { + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + if ($method === 'POST') { + $raw = file_get_contents('php://input') ?: ''; + $req = json_decode($raw, true); + if (!is_array($req)) { + return ['error' => 'invalid_json', 'code' => 400]; + } + $heartbeat = $req['heartbeat'] ?? []; + return ['heartbeat' => is_array($heartbeat) ? $heartbeat : [], 'code' => 0]; + } + if ($method === 'GET' || $method === 'HEAD') { + $type = (string) ($_GET['type'] ?? $_GET['heartbeat_type'] ?? 'ping'); + $heartbeat = ['type' => $type]; + if ($type === 'ntp') { + $heartbeat['date'] = (int) ($_GET['date'] ?? $_GET['client_date'] ?? 0); + $heartbeat['tz'] = (string) ($_GET['tz'] ?? $_GET['client_tz'] ?? ''); + $heartbeat['time_source'] = (string) ($_GET['time_source'] ?? 'device'); + } + return ['heartbeat' => $heartbeat, 'code' => 0]; + } + return ['error' => 'method_not_allowed', 'code' => 405]; + } + + /** @param array $heartbeat */ + public static function buildResponse(array $heartbeat, string $clientIp): array + { + $type = (string) ($heartbeat['type'] ?? 'generic'); + $srvEpoch = time(); + $srvTz = date('O'); + + $resp = [ + 'heartbeat' => [ + 'type' => $type, + 'server_date' => $srvEpoch, + 'server_tz' => $srvTz, + ], + ]; + + if ($type === 'ntp') { + $clientDate = (int) ($heartbeat['date'] ?? 0); + $clientTz = (string) ($heartbeat['tz'] ?? ''); + $timeSource = trim((string) ($heartbeat['time_source'] ?? 'device')); + if ($timeSource === '') { + $timeSource = 'device'; + } + $correction = $clientDate > 0 ? ($srvEpoch - $clientDate) : null; + $resp['heartbeat']['client_date'] = $clientDate; + $resp['heartbeat']['client_tz'] = $clientTz; + $resp['heartbeat']['time_source'] = $timeSource; + $resp['heartbeat']['correction_seconds'] = $correction; + $resp['heartbeat']['ntp_correction_s'] = $correction; + $resp['heartbeat']['enabled'] = true; + } + + if ($type === 'ip') { + $resp['heartbeat']['external_ip'] = $clientIp; + } + + if ($type === 'ra') { + if (!class_exists('RemoteAccessRepository', false)) { + return ['error' => 'ra_unavailable', 'code' => 503]; + } + $ra = RemoteAccessRepository::handleDeviceHeartbeat($heartbeat, $clientIp); + $resp['heartbeat'] = array_merge($resp['heartbeat'], $ra); + } + + return ['response' => $resp, 'code' => 200]; + } + + public static function emit(): void + { + header('Content-Type: application/json; charset=utf-8'); + + $parsed = self::parseRequest(); + if (($parsed['code'] ?? 0) === 405) { + http_response_code(405); + echo json_encode(['error' => 'method_not_allowed']); + return; + } + if (($parsed['code'] ?? 0) === 400) { + http_response_code(400); + echo json_encode(['error' => 'invalid_json']); + return; + } + + $clientIp = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '')); + if (str_contains($clientIp, ',')) { + $clientIp = trim(explode(',', $clientIp)[0]); + } + + $built = self::buildResponse((array) ($parsed['heartbeat'] ?? []), $clientIp); + if (($built['code'] ?? 200) === 503) { + http_response_code(503); + echo json_encode(['error' => 'ra_unavailable']); + return; + } + + if (class_exists('Auth', false) && Auth::user()) { + Auth::touchSession(); + } + + http_response_code(200); + echo json_encode($built['response'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } +}