From 9edcbec7827ac0627bbd884176acb95cc1955726 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 23 Jun 2026 12:29:31 +0200 Subject: [PATCH] initial --- README.md | 3 + composer.json | 25 +++++++ config/config.example.php | 2 + public/api/heartbeat.php | 65 ++++++++++++++++ public/api/upload.php | 53 +++++++++++++ public/index.php | 2 + sql/migrations/001_rbac_companies.sql | 46 ++++++++++++ src/DeviceRepository.php | 103 ++++++++++++++++++++++++++ 8 files changed, 299 insertions(+) create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/config.example.php create mode 100644 public/api/heartbeat.php create mode 100644 public/api/upload.php create mode 100644 public/index.php create mode 100644 sql/migrations/001_rbac_companies.sql create mode 100644 src/DeviceRepository.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c5440a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ac-ms-devices + +Microservice API. Remote: `git://f0xx.org/ac/ac-ms-devices` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d03d225 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "androidcast/ms-devices", + "description": "Device registry", + "type": "project", + "require": { + "php": ">=8.1", + "androidcast/platform-php": "dev-next", + "androidcast/platform-db": "dev-next" + }, + "repositories": [ + { + "type": "vcs", + "url": "git://f0xx.org/ac/ac-platform-php" + }, + { + "type": "vcs", + "url": "git://f0xx.org/ac/ac-platform-db" + } + ], + "autoload": { + "classmap": [ + "src/" + ] + } +} diff --git a/config/config.example.php b/config/config.example.php new file mode 100644 index 0000000..9a59c16 --- /dev/null +++ b/config/config.example.php @@ -0,0 +1,2 @@ + ['driver' => 'sqlite']]; diff --git a/public/api/heartbeat.php b/public/api/heartbeat.php new file mode 100644 index 0000000..af2bcae --- /dev/null +++ b/public/api/heartbeat.php @@ -0,0 +1,65 @@ + 'method_not_allowed']); + exit; +} + +$raw = file_get_contents('php://input') ?: ''; +$req = json_decode($raw, true); +if (!is_array($req)) { + http_response_code(400); + echo json_encode(['error' => 'invalid_json']); + exit; +} + +$heartbeat = $req['heartbeat'] ?? []; +if (!is_array($heartbeat)) { + $heartbeat = []; +} + +$type = (string)($heartbeat['type'] ?? 'generic'); +$srvEpoch = time(); +$srvTz = date('O'); +$clientIp = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '')); + +$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') { + require_once __DIR__ . '/../../src/RemoteAccessRepository.php'; + $ra = RemoteAccessRepository::handleDeviceHeartbeat($heartbeat, $clientIp); + $resp['heartbeat'] = array_merge($resp['heartbeat'], $ra); +} + +echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/public/api/upload.php b/public/api/upload.php new file mode 100644 index 0000000..adf85b8 --- /dev/null +++ b/public/api/upload.php @@ -0,0 +1,53 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 25 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 edbab2563cb9d5ba1c9a6a6272071ac12efa782274c7d1626f088a951033d5a6 + */ +declare(strict_types=1); +require_once __DIR__ . '/../../src/bootstrap.php'; + +$raw = read_crash_upload_body(); +if ($raw === null) { + json_out(['ok' => false, 'error' => 'empty body'], 400); +} +if ($raw === false) { + json_out(['ok' => false, 'error' => 'invalid gzip body'], 400); +} +$data = json_decode($raw, true); +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} +if ((int) ($data['schema_version'] ?? 0) !== 1) { + json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400); +} +try { + ReportRepository::insert($data); + json_out(['ok' => true, 'report_id' => $data['report_id'] ?? null]); +} catch (PDOException $e) { + $msg = $e->getMessage(); + if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) { + json_out(['ok' => false, 'error' => 'duplicate report_id'], 409); + } + error_log('crash upload PDO: ' . $msg); + log_crash_event('upload', 'PDO: ' . $msg); + $out = ['ok' => false, 'error' => 'store failed']; + if (cfg('debug')) { + $out['hint'] = $msg; + } + json_out($out, 500); +} catch (Throwable $e) { + error_log('crash upload: ' . $e->getMessage()); + log_crash_event('upload', $e->getMessage()); + $out = ['ok' => false, 'error' => 'store failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + } + json_out($out, 500); +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..922b7c2 --- /dev/null +++ b/public/index.php @@ -0,0 +1,2 @@ + $v !== ''); + if ($parts === []) { + return 'unknown'; + } + return substr(hash('sha256', implode('|', $parts)), 0, 32); + } + + public static function upsertFromPayload(int $companyId, array $device, string $source = 'auto'): int { + $companyId = max(1, $companyId); + $externalId = self::externalKey($device); + $now = (int) round(microtime(true) * 1000); + $manufacturer = trim((string) ($device['manufacturer'] ?? '')); + $model = trim((string) ($device['model'] ?? '')); + $display = trim($manufacturer . ' ' . $model); + $meta = json_encode($device, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($meta === false) { + $meta = '{}'; + } + + $pdo = Database::pdo(); + if (Database::isMysql()) { + $stmt = $pdo->prepare( + 'INSERT INTO devices (company_id, external_id, display_name, manufacturer, model, source, + registered_at_ms, last_seen_at_ms, meta_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), + manufacturer = VALUES(manufacturer), + model = VALUES(model), + last_seen_at_ms = VALUES(last_seen_at_ms), + meta_json = VALUES(meta_json)' + ); + $stmt->execute([ + $companyId, + $externalId, + $display !== '' ? $display : null, + $manufacturer !== '' ? $manufacturer : null, + $model !== '' ? $model : null, + $source, + $now, + $now, + $meta, + ]); + $sel = $pdo->prepare( + 'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1' + ); + $sel->execute([$companyId, $externalId]); + return (int) $sel->fetchColumn(); + } + + $stmt = $pdo->prepare( + 'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1' + ); + $stmt->execute([$companyId, $externalId]); + $existing = $stmt->fetchColumn(); + if ($existing !== false) { + $upd = $pdo->prepare( + 'UPDATE devices SET display_name = ?, manufacturer = ?, model = ?, + last_seen_at_ms = ?, meta_json = ? WHERE id = ?' + ); + $upd->execute([ + $display !== '' ? $display : null, + $manufacturer !== '' ? $manufacturer : null, + $model !== '' ? $model : null, + $now, + $meta, + (int) $existing, + ]); + return (int) $existing; + } + $ins = $pdo->prepare( + 'INSERT INTO devices (company_id, external_id, display_name, manufacturer, model, source, + registered_at_ms, last_seen_at_ms, meta_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $ins->execute([ + $companyId, + $externalId, + $display !== '' ? $display : null, + $manufacturer !== '' ? $manufacturer : null, + $model !== '' ? $model : null, + $source, + $now, + $now, + $meta, + ]); + return (int) $pdo->lastInsertId(); + } +}