mirror of
git://f0xx.org/ac/ac-ms-devices
synced 2026-07-29 03:39:02 +03:00
initial
This commit is contained in:
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# ac-ms-devices
|
||||||
|
|
||||||
|
Microservice API. Remote: `git://f0xx.org/ac/ac-ms-devices`
|
||||||
25
composer.json
Normal file
25
composer.json
Normal 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/"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
config/config.example.php
Normal file
2
config/config.example.php
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
return ['db' => ['driver' => 'sqlite']];
|
||||||
65
public/api/heartbeat.php
Normal file
65
public/api/heartbeat.php
Normal 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
53
public/api/upload.php
Normal 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
2
public/index.php
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
// API entry — wire nginx to public/
|
||||||
46
sql/migrations/001_rbac_companies.sql
Normal file
46
sql/migrations/001_rbac_companies.sql
Normal 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
103
src/DeviceRepository.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user