1
0
mirror of git://f0xx.org/ac/ac-ms-devices synced 2026-07-29 00:59:18 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:31 +02:00
commit 9edcbec782
8 changed files with 299 additions and 0 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# ac-ms-devices
Microservice API. Remote: `git://f0xx.org/ac/ac-ms-devices`

25
composer.json Normal file
View File

@@ -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/"
]
}
}

View File

@@ -0,0 +1,2 @@
<?php
return ['db' => ['driver' => 'sqlite']];

65
public/api/heartbeat.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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);

53
public/api/upload.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
/*
* package examples/crash_reporter/backend/public/api/upload.php
* upload.php
* Created at: Wed 20 May 2026 14:31:55 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (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);
}

2
public/index.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
// API entry — wire nginx to public/

View File

@@ -0,0 +1,46 @@
-- Phase 1: companies, memberships, devices, report scoping.
-- Idempotent on fresh DB; existing installs: prefer app auto-migration (Database::ensureRbacSchema).
-- Manual run: mysql -u root -p androidcast_crashes < sql/migrations/001_rbac_companies.sql
USE androidcast_crashes;
CREATE TABLE IF NOT EXISTS companies (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_companies_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS company_memberships (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
company_id INT UNSIGNED NOT NULL,
role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member',
permissions_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_membership_user_company (user_id, company_id),
KEY idx_membership_company (company_id),
CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT fk_membership_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
company_id INT UNSIGNED NOT NULL,
external_id VARCHAR(128) NOT NULL,
display_name VARCHAR(256) NULL,
manufacturer VARCHAR(128) NULL,
model VARCHAR(128) NULL,
source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto',
registered_at_ms BIGINT NOT NULL,
last_seen_at_ms BIGINT NOT NULL,
meta_json LONGTEXT NULL,
UNIQUE KEY uq_devices_company_external (company_id, external_id),
KEY idx_devices_company (company_id),
CONSTRAINT fk_devices_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- reports.company_id / reports.device_id: run 002_reports_company_columns.sql as root (app user cannot ALTER)
INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default');

103
src/DeviceRepository.php Normal file
View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/** Phase 1 device registry — auto-register on crash upload. */
final class DeviceRepository {
public static function externalKey(array $device): string {
$parts = array_filter([
(string) ($device['manufacturer'] ?? ''),
(string) ($device['brand'] ?? ''),
(string) ($device['model'] ?? ''),
(string) ($device['product'] ?? ''),
(string) ($device['device'] ?? ''),
(string) ($device['serial'] ?? ''),
(string) ($device['android_id'] ?? ''),
], static fn ($v) => $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();
}
}