mirror of
git://f0xx.org/ac/ac-ms-devices
synced 2026-07-29 00:59:18 +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