mirror of
git://f0xx.org/android_cast
synced 2026-07-29 03:38:52 +03:00
sync / interactive remote shell / remote web interface to WG connected mobile
This commit is contained in:
@@ -26,18 +26,16 @@ if ($method === 'GET') {
|
||||
json_out(['ok' => true, 'events' => RemoteAccessRepository::listEvents((int)($_GET['limit'] ?? 100))]);
|
||||
}
|
||||
if ($action === 'dashboard') {
|
||||
$sessions = RemoteAccessRepository::listSessions('');
|
||||
$active = array_values(array_filter($sessions, static fn($s) => in_array($s['status'] ?? '', ['pending', 'active'], true)));
|
||||
$inactive = array_values(array_filter($sessions, static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true)));
|
||||
$payload = RemoteAccessRepository::buildDashboardPayload(
|
||||
rtrim(Auth::basePath(), '/'),
|
||||
'/app/androidcast_project'
|
||||
);
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'devices' => RemoteAccessRepository::listDevicesForDashboard(
|
||||
rtrim(Auth::basePath(), '/'),
|
||||
'/app/androidcast_project'
|
||||
),
|
||||
'active_sessions' => $active,
|
||||
'inactive_sessions' => array_slice($inactive, 0, 50),
|
||||
'recent_events' => RemoteAccessRepository::listEvents(30),
|
||||
'devices' => $payload['devices'],
|
||||
'active_sessions' => $payload['active_sessions'],
|
||||
'inactive_sessions' => $payload['inactive_sessions'],
|
||||
'recent_events' => $payload['recent_events'],
|
||||
'permissions' => [
|
||||
'can_operate' => Rbac::can('remote_access_operate'),
|
||||
'can_admin' => Rbac::can('remote_access_admin'),
|
||||
|
||||
@@ -411,6 +411,8 @@
|
||||
lines.push(pollLabel + ': ' + d.poll_source_ip);
|
||||
}
|
||||
if (d.vpn_ip) lines.push('VPN IP: ' + d.vpn_ip);
|
||||
if (d.vpn_route_scope) lines.push('VPN route scope: ' + d.vpn_route_scope);
|
||||
if (d.vpn_app_scope) lines.push('VPN app scope: ' + d.vpn_app_scope);
|
||||
if (d.vpn_public_key) lines.push('VPN public key: ' + d.vpn_public_key);
|
||||
if (d.wg_rx_bytes != null || d.wg_tx_bytes != null) {
|
||||
const rx = formatBytes(d.wg_rx_bytes || 0);
|
||||
|
||||
@@ -5,12 +5,16 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Alpine BE: default `php` may be 8.5 without session; php-fpm81 uses 8.1.
|
||||
# Alpine BE: default `php` may be 8.5 without session/PDO; php-fpm81 uses 8.1.
|
||||
php_bin() {
|
||||
if [[ -n "${PHP_BIN:-}" ]]; then
|
||||
echo "$PHP_BIN"
|
||||
return
|
||||
fi
|
||||
if command -v php81 >/dev/null 2>&1 && php81 -m 2>/dev/null | grep -qiE '^(session|pdo_mysql|pdo_sqlite)$'; then
|
||||
echo php81
|
||||
return
|
||||
fi
|
||||
if php -m 2>/dev/null | grep -qi '^session$'; then
|
||||
echo php
|
||||
return
|
||||
@@ -34,6 +38,13 @@ echo "== Remote access production verify =="
|
||||
echo "backend: $ROOT"
|
||||
echo
|
||||
|
||||
# MariaDB rejects PostgreSQL-style NULLS LAST/FIRST (SQLite dev may hide this).
|
||||
if grep -R --include='*.php' -E 'NULLS (LAST|FIRST)' "$ROOT/src" >/dev/null 2>&1; then
|
||||
bad "MariaDB-incompatible SQL (NULLS LAST/FIRST) under src/"
|
||||
else
|
||||
ok "no NULLS LAST/FIRST in PHP SQL"
|
||||
fi
|
||||
|
||||
# config.php
|
||||
CFG="$ROOT/config/config.php"
|
||||
if [[ ! -f "$CFG" ]]; then
|
||||
@@ -97,6 +108,13 @@ foreach (["remote_access_devices","remote_access_sessions","remote_access_events
|
||||
echo "OK MariaDB/SQLite remote_access tables present\n";
|
||||
' || fail=1
|
||||
|
||||
$DB_RUN -r '
|
||||
require "'"$ROOT"'/src/bootstrap.php";
|
||||
RemoteAccessRepository::ensureSchema();
|
||||
RemoteAccessRepository::listDevices();
|
||||
echo "OK listDevices() SQL executes\n";
|
||||
' || bad "listDevices() failed (check ORDER BY / MariaDB syntax)"
|
||||
|
||||
# Orphan wg peers (dry-run; run sync_wg_peers.php --apply on BE to fix)
|
||||
if [[ -f "$ROOT/scripts/sync_wg_peers.php" ]]; then
|
||||
WG_SYNC="$($DB_RUN "$ROOT/scripts/sync_wg_peers.php" --dry-run 2>/dev/null || true)"
|
||||
|
||||
@@ -16,46 +16,44 @@ final class RemoteAccessRepository {
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
public const STATUS_CLOSED_TIMEOUT = 'closed_timeout';
|
||||
|
||||
private static bool $schemaReady = false;
|
||||
|
||||
public static function ensureSchema(): void {
|
||||
if (self::$schemaReady) {
|
||||
return;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
if (!Database::tableExists($pdo, 'remote_access_devices')) {
|
||||
self::createTables($pdo);
|
||||
self::$schemaReady = true;
|
||||
return;
|
||||
}
|
||||
self::ensureSessionColumns($pdo);
|
||||
self::ensureRsshColumns($pdo);
|
||||
self::ensureDeviceMetaColumn($pdo);
|
||||
self::ensureLastClientIpColumn($pdo);
|
||||
self::$schemaReady = true;
|
||||
}
|
||||
|
||||
private static function addColumnIfMissing(PDO $pdo, string $table, string $column, string $ddl): void {
|
||||
if (!Database::tableExists($pdo, $table)) {
|
||||
return;
|
||||
}
|
||||
$names = Database::columnNames($pdo, $table);
|
||||
if (in_array($column, $names, true)) {
|
||||
return;
|
||||
}
|
||||
$pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $ddl);
|
||||
}
|
||||
|
||||
private static function ensureLastClientIpColumn(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'remote_access_devices')) {
|
||||
return;
|
||||
}
|
||||
$col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL';
|
||||
try {
|
||||
$pdo->exec('ALTER TABLE remote_access_devices ADD COLUMN last_client_ip ' . $col);
|
||||
} catch (PDOException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
self::addColumnIfMissing($pdo, 'remote_access_devices', 'last_client_ip', $col);
|
||||
}
|
||||
|
||||
private static function ensureDeviceMetaColumn(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'remote_access_devices')) {
|
||||
return;
|
||||
}
|
||||
$col = Database::isMysql() ? 'LONGTEXT NULL' : 'TEXT NULL';
|
||||
try {
|
||||
$pdo->exec('ALTER TABLE remote_access_devices ADD COLUMN device_meta_json ' . $col);
|
||||
} catch (PDOException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
self::addColumnIfMissing($pdo, 'remote_access_devices', 'device_meta_json', $col);
|
||||
}
|
||||
|
||||
/** @param list<string> $where @param list<mixed> $params */
|
||||
@@ -113,30 +111,13 @@ final class RemoteAccessRepository {
|
||||
'rssh_local_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL',
|
||||
];
|
||||
foreach ($cols as $name => $ddl) {
|
||||
try {
|
||||
$pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN ' . $name . ' ' . $ddl);
|
||||
} catch (PDOException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
self::addColumnIfMissing($pdo, 'remote_access_sessions', $name, $ddl);
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureSessionColumns(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'remote_access_sessions')) {
|
||||
return;
|
||||
}
|
||||
$col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL';
|
||||
try {
|
||||
$pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN wg_client_public_key ' . $col);
|
||||
} catch (PDOException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
self::addColumnIfMissing($pdo, 'remote_access_sessions', 'wg_client_public_key', $col);
|
||||
}
|
||||
|
||||
private static function createTables(PDO $pdo): void {
|
||||
@@ -185,6 +166,14 @@ final class RemoteAccessRepository {
|
||||
}
|
||||
|
||||
$deviceMeta = is_array($heartbeat['device'] ?? null) ? $heartbeat['device'] : [];
|
||||
$routeScope = trim((string) ($heartbeat['vpn_route_scope'] ?? ''));
|
||||
$appScope = trim((string) ($heartbeat['vpn_app_scope'] ?? ''));
|
||||
if ($routeScope !== '') {
|
||||
$deviceMeta['vpn_route_scope'] = $routeScope;
|
||||
}
|
||||
if ($appScope !== '') {
|
||||
$deviceMeta['vpn_app_scope'] = $appScope;
|
||||
}
|
||||
|
||||
self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp, $deviceMeta);
|
||||
|
||||
@@ -853,22 +842,89 @@ final class RemoteAccessRepository {
|
||||
return Database::isMysql() ? date('Y-m-d H:i:s') : gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/** @return array{devices: list<array<string, mixed>>, active_sessions: list<array<string, mixed>>, inactive_sessions: list<array<string, mixed>>, recent_events: list<array<string, mixed>>} */
|
||||
public static function buildDashboardPayload(string $issuesBase, string $projectBase): array {
|
||||
self::ensureSchema();
|
||||
$sessions = self::listSessions('');
|
||||
$active = array_values(array_filter(
|
||||
$sessions,
|
||||
static fn($s) => in_array($s['status'] ?? '', ['pending', 'active'], true)
|
||||
));
|
||||
$inactive = array_values(array_filter(
|
||||
$sessions,
|
||||
static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true)
|
||||
));
|
||||
|
||||
return [
|
||||
'devices' => self::listDevicesForDashboard($issuesBase, $projectBase),
|
||||
'active_sessions' => $active,
|
||||
'inactive_sessions' => array_slice($inactive, 0, 50),
|
||||
'recent_events' => self::listEvents(30),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public static function listDevicesForDashboard(string $issuesBase, string $projectBase): array {
|
||||
$devices = self::listDevices();
|
||||
if ($devices === []) {
|
||||
return [];
|
||||
}
|
||||
$deviceIds = array_values(array_filter(array_map(
|
||||
static fn($row) => (string) ($row['device_id'] ?? ''),
|
||||
$devices
|
||||
)));
|
||||
$sessionRows = self::batchActiveSessionRows($deviceIds);
|
||||
$wgPeers = WireGuardTrafficStats::peerMap();
|
||||
$out = [];
|
||||
foreach ($devices as $row) {
|
||||
$out[] = self::enrichDeviceRow($row, $issuesBase, $projectBase);
|
||||
$deviceId = (string) ($row['device_id'] ?? '');
|
||||
$out[] = self::enrichDeviceRow(
|
||||
$row,
|
||||
$issuesBase,
|
||||
$projectBase,
|
||||
$sessionRows[$deviceId] ?? null,
|
||||
$wgPeers
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private static function enrichDeviceRow(array $row, string $issuesBase, string $projectBase): array {
|
||||
/** @param list<string> $deviceIds @return array<string, array<string, mixed>> */
|
||||
private static function batchActiveSessionRows(array $deviceIds): array {
|
||||
$deviceIds = array_values(array_unique(array_filter(array_map('strval', $deviceIds))));
|
||||
if ($deviceIds === []) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($deviceIds), '?'));
|
||||
$st = Database::pdo()->prepare(
|
||||
"SELECT device_id, tunnel, wg_client_address, wg_client_public_key
|
||||
FROM remote_access_sessions
|
||||
WHERE status IN ('pending', 'active') AND device_id IN ($placeholders)
|
||||
ORDER BY id DESC"
|
||||
);
|
||||
$st->execute($deviceIds);
|
||||
$map = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$did = (string) ($row['device_id'] ?? '');
|
||||
if ($did !== '' && !isset($map[$did])) {
|
||||
$map[$did] = $row;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row @param array<string, mixed>|null $sessionRow @param array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}>|null $wgPeers */
|
||||
private static function enrichDeviceRow(
|
||||
array $row,
|
||||
string $issuesBase,
|
||||
string $projectBase,
|
||||
?array $sessionRow = null,
|
||||
?array $wgPeers = null
|
||||
): array {
|
||||
$deviceId = (string) ($row['device_id'] ?? '');
|
||||
$companyId = (int) ($row['company_id'] ?? 1);
|
||||
$storedMeta = self::decodeDeviceMeta($row['device_meta_json'] ?? null);
|
||||
$ctx = self::lookupDeviceContext($deviceId, $companyId, $storedMeta);
|
||||
$ctx = self::lookupDeviceContextFromMeta($deviceId, $companyId, $storedMeta);
|
||||
$row['needs_whitelist'] = self::deviceNeedsWhitelist($row);
|
||||
$row['status_label'] = self::deviceStatusLabel($row);
|
||||
$row['device_name'] = $ctx['device_name'] ?? '';
|
||||
@@ -885,7 +941,9 @@ final class RemoteAccessRepository {
|
||||
$row['poll_source_ip'] = self::resolvePollSourceIp($row);
|
||||
$row['poll_source_is_private'] = self::isPrivateIp($row['poll_source_ip']);
|
||||
$row['device_wan_ip'] = trim((string) ($storedMeta['wan_ip'] ?? ''));
|
||||
$net = self::lookupDeviceSessionNetwork($deviceId);
|
||||
$row['vpn_route_scope'] = trim((string) ($storedMeta['vpn_route_scope'] ?? ''));
|
||||
$row['vpn_app_scope'] = trim((string) ($storedMeta['vpn_app_scope'] ?? ''));
|
||||
$net = self::sessionNetworkFromRow($sessionRow, $wgPeers);
|
||||
$row['vpn_ip'] = $net['vpn_ip'] ?? '';
|
||||
$row['vpn_public_key'] = $net['vpn_public_key'] ?? '';
|
||||
$row['wg_rx_bytes'] = $net['wg_rx_bytes'] ?? 0;
|
||||
@@ -1002,8 +1060,8 @@ final class RemoteAccessRepository {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/** @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */
|
||||
private static function lookupDeviceSessionNetwork(string $deviceId): array {
|
||||
/** @param array<string, mixed>|null $sessionRow @param array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}>|null $wgPeers @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */
|
||||
private static function sessionNetworkFromRow(?array $sessionRow, ?array $wgPeers): array {
|
||||
$empty = [
|
||||
'vpn_ip' => '',
|
||||
'vpn_public_key' => '',
|
||||
@@ -1012,29 +1070,20 @@ final class RemoteAccessRepository {
|
||||
'wg_latest_handshake' => 0,
|
||||
'wg_endpoint' => '',
|
||||
];
|
||||
if ($deviceId === '') {
|
||||
if (!is_array($sessionRow)) {
|
||||
return $empty;
|
||||
}
|
||||
$st = Database::pdo()->prepare(
|
||||
"SELECT tunnel, wg_client_address, wg_client_public_key
|
||||
FROM remote_access_sessions
|
||||
WHERE device_id = ? AND status IN ('pending', 'active')
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
$st->execute([$deviceId]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($row)) {
|
||||
return $empty;
|
||||
}
|
||||
$tunnel = (string) ($row['tunnel'] ?? '');
|
||||
$tunnel = (string) ($sessionRow['tunnel'] ?? '');
|
||||
if ($tunnel !== '' && $tunnel !== 'wireguard') {
|
||||
return $empty;
|
||||
}
|
||||
$addr = trim((string) ($row['wg_client_address'] ?? ''));
|
||||
$addr = trim((string) ($sessionRow['wg_client_address'] ?? ''));
|
||||
$vpnIp = $addr !== '' ? preg_replace('/\/\d+$/', '', $addr) ?? $addr : '';
|
||||
$pub = trim((string) ($row['wg_client_public_key'] ?? ''));
|
||||
|
||||
$stats = $pub !== '' ? WireGuardTrafficStats::forPublicKey($pub) : null;
|
||||
$pub = trim((string) ($sessionRow['wg_client_public_key'] ?? ''));
|
||||
$stats = ($wgPeers !== null && $pub !== '') ? ($wgPeers[$pub] ?? null) : null;
|
||||
if ($stats === null && $pub !== '') {
|
||||
$stats = WireGuardTrafficStats::forPublicKey($pub);
|
||||
}
|
||||
|
||||
return [
|
||||
'vpn_ip' => is_string($vpnIp) ? trim($vpnIp) : '',
|
||||
@@ -1046,6 +1095,72 @@ final class RemoteAccessRepository {
|
||||
];
|
||||
}
|
||||
|
||||
/** Fast dashboard enrichment from heartbeat meta (no reports/graph table scans). */
|
||||
private static function lookupDeviceContextFromMeta(
|
||||
string $deviceId,
|
||||
int $companyId,
|
||||
array $storedMeta = []
|
||||
): array {
|
||||
if ($deviceId === '') {
|
||||
return [];
|
||||
}
|
||||
unset($companyId);
|
||||
$deviceName = trim((string) ($storedMeta['name'] ?? ''));
|
||||
$manufacturer = trim((string) ($storedMeta['manufacturer'] ?? ''));
|
||||
$brand = trim((string) ($storedMeta['brand'] ?? ''));
|
||||
$model = trim((string) ($storedMeta['model'] ?? ''));
|
||||
$hardwareDevice = trim((string) ($storedMeta['device'] ?? ''));
|
||||
$product = trim((string) ($storedMeta['product'] ?? ''));
|
||||
$osRelease = trim((string) ($storedMeta['release'] ?? ''));
|
||||
$sdkInt = isset($storedMeta['sdk_int']) ? (int) $storedMeta['sdk_int'] : null;
|
||||
$abis = is_array($storedMeta['abis'] ?? null) ? $storedMeta['abis'] : [];
|
||||
$appVersion = trim((string) ($storedMeta['app_version'] ?? ''));
|
||||
$deviceDisplay = $deviceName !== ''
|
||||
? $deviceName
|
||||
: trim($manufacturer . ' ' . $model);
|
||||
|
||||
return [
|
||||
'issue_count' => 0,
|
||||
'latest_issue_id' => null,
|
||||
'graph_session_count' => 0,
|
||||
'device_name' => $deviceName,
|
||||
'device_display' => $deviceDisplay,
|
||||
'manufacturer' => $manufacturer,
|
||||
'brand' => $brand,
|
||||
'model' => $model,
|
||||
'hardware_device' => $hardwareDevice,
|
||||
'product' => $product,
|
||||
'os_release' => $osRelease,
|
||||
'sdk_int' => $sdkInt,
|
||||
'abis' => $abis,
|
||||
'app_version' => $appVersion,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */
|
||||
private static function lookupDeviceSessionNetwork(string $deviceId): array {
|
||||
if ($deviceId === '') {
|
||||
return [
|
||||
'vpn_ip' => '',
|
||||
'vpn_public_key' => '',
|
||||
'wg_rx_bytes' => 0,
|
||||
'wg_tx_bytes' => 0,
|
||||
'wg_latest_handshake' => 0,
|
||||
'wg_endpoint' => '',
|
||||
];
|
||||
}
|
||||
$st = Database::pdo()->prepare(
|
||||
"SELECT tunnel, wg_client_address, wg_client_public_key
|
||||
FROM remote_access_sessions
|
||||
WHERE device_id = ? AND status IN ('pending', 'active')
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
$st->execute([$deviceId]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return self::sessionNetworkFromRow(is_array($row) ? $row : null, null);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $storedMeta @return array<string, mixed> */
|
||||
private static function lookupDeviceContext(string $deviceId, int $companyId, array $storedMeta = []): array {
|
||||
if ($deviceId === '') {
|
||||
@@ -1087,21 +1202,27 @@ final class RemoteAccessRepository {
|
||||
}
|
||||
|
||||
if (Database::tableExists($pdo, 'reports')) {
|
||||
$like = '%' . $deviceId . '%';
|
||||
$cntSt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.payload_json LIKE ?'
|
||||
);
|
||||
$cntSt->execute([$companyId, $like]);
|
||||
$issueCount = (int) $cntSt->fetchColumn();
|
||||
$reportsDeviceId = self::resolveDeviceDbId($deviceId, $companyId);
|
||||
if ($reportsDeviceId !== null) {
|
||||
$cntSt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.device_id = ?'
|
||||
);
|
||||
$cntSt->execute([$companyId, $reportsDeviceId]);
|
||||
$issueCount = (int) $cntSt->fetchColumn();
|
||||
|
||||
$latestSt = $pdo->prepare(
|
||||
'SELECT r.id, r.device_model, r.app_version, r.payload_json
|
||||
FROM reports r
|
||||
WHERE r.company_id = ? AND r.payload_json LIKE ?
|
||||
ORDER BY r.received_at_ms DESC LIMIT 1'
|
||||
);
|
||||
$latestSt->execute([$companyId, $like]);
|
||||
$latest = $latestSt->fetch(PDO::FETCH_ASSOC);
|
||||
$latestSt = $pdo->prepare(
|
||||
'SELECT r.id, r.device_model, r.app_version, r.payload_json
|
||||
FROM reports r
|
||||
WHERE r.company_id = ? AND r.device_id = ?
|
||||
ORDER BY r.received_at_ms DESC LIMIT 1'
|
||||
);
|
||||
$latestSt->execute([$companyId, $reportsDeviceId]);
|
||||
$latest = $latestSt->fetch(PDO::FETCH_ASSOC);
|
||||
} else {
|
||||
// Avoid payload_json LIKE scans (full table) on unregistered devices.
|
||||
$issueCount = 0;
|
||||
$latest = null;
|
||||
}
|
||||
if (is_array($latest)) {
|
||||
$latestIssueId = (int) ($latest['id'] ?? 0) ?: null;
|
||||
if ($appVersion === '') {
|
||||
@@ -1163,6 +1284,28 @@ final class RemoteAccessRepository {
|
||||
];
|
||||
}
|
||||
|
||||
private static function resolveDeviceDbId(string $deviceId, int $companyId): ?int {
|
||||
static $cache = [];
|
||||
$key = $companyId . ':' . $deviceId;
|
||||
if (array_key_exists($key, $cache)) {
|
||||
return $cache[$key];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
if (!Database::tableExists($pdo, 'devices')) {
|
||||
$cache[$key] = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
$st = $pdo->prepare(
|
||||
'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1'
|
||||
);
|
||||
$st->execute([$companyId, $deviceId]);
|
||||
$id = $st->fetchColumn();
|
||||
$cache[$key] = ($id !== false && $id !== null && $id !== '') ? (int) $id : null;
|
||||
|
||||
return $cache[$key];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $ctx @return list<array{label: string, href: string}> */
|
||||
private static function deviceLinks(string $deviceId, string $issuesBase, string $projectBase, array $ctx): array {
|
||||
if ($deviceId === '') {
|
||||
|
||||
Reference in New Issue
Block a user