mirror of
git://f0xx.org/ac/ac-ms-issues
synced 2026-07-29 00:58:52 +03:00
initial
This commit is contained in:
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# ac-ms-issues
|
||||
|
||||
Microservice API. Remote: `git://f0xx.org/ac/ac-ms-issues`
|
||||
25
composer.json
Normal file
25
composer.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "androidcast/ms-issues",
|
||||
"description": "Crash/report ingest and live cast APIs",
|
||||
"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']];
|
||||
77
public/api/diag.php
Normal file
77
public/api/diag.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!cfg('debug')) {
|
||||
json_out(['ok' => false, 'error' => 'diag disabled'], 403);
|
||||
}
|
||||
|
||||
$driver = Database::driver();
|
||||
$path = cfg('db.sqlite_path');
|
||||
$out = [
|
||||
'ok' => true,
|
||||
'db_driver' => $driver,
|
||||
'php_user' => get_current_user(),
|
||||
'storage_writable' => is_writable(dirname(__DIR__) . '/../storage'),
|
||||
'tables' => [],
|
||||
'reports_columns' => [],
|
||||
'test_insert' => null,
|
||||
];
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
$out['sqlite_path'] = $path;
|
||||
$out['sqlite_realpath'] = is_string($path) ? realpath($path) : null;
|
||||
$out['sqlite_exists'] = is_string($path) && is_file($path);
|
||||
$out['sqlite_writable'] = is_string($path) && is_writable($path);
|
||||
$out['data_dir_writable'] = is_string($path) && is_writable(dirname($path));
|
||||
} else {
|
||||
$m = cfg('db.mysql', []);
|
||||
$socket = trim((string) ($m['socket'] ?? ''));
|
||||
$out['mysql'] = [
|
||||
'host' => $m['host'] ?? '127.0.0.1',
|
||||
'port' => (int) ($m['port'] ?? 3306),
|
||||
'socket' => $socket,
|
||||
'socket_exists' => $socket !== '' && file_exists($socket),
|
||||
'database' => $m['database'] ?? 'androidcast_crashes',
|
||||
'username' => $m['username'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = Database::pdo();
|
||||
$tables = Database::listTables($pdo);
|
||||
$out['tables'] = $tables;
|
||||
$out['tickets_table'] = Database::ticketsTableExists($pdo);
|
||||
if (Database::isMysql()) {
|
||||
try {
|
||||
$out['mysql_database'] = $pdo->query('SELECT DATABASE()')->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
$out['mysql_database'] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
if (!$out['tickets_table']) {
|
||||
$out['tickets_migration'] = Database::ticketsMigrationHint();
|
||||
}
|
||||
if (in_array('reports', $tables, true)) {
|
||||
$cols = Database::columnNames($pdo, 'reports');
|
||||
$out['reports_columns'] = array_map(
|
||||
static fn (string $name) => ['name' => $name],
|
||||
$cols
|
||||
);
|
||||
}
|
||||
$pdo->beginTransaction();
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms,
|
||||
device_model, app_version, payload_json, tags_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$rid = 'diag-' . time();
|
||||
$stmt->execute([$rid, 'diag', 'java', 1, 1, null, null, '{}', '[]']);
|
||||
$pdo->rollBack();
|
||||
$out['test_insert'] = 'ok (rolled back)';
|
||||
} catch (Throwable $e) {
|
||||
$out['ok'] = false;
|
||||
$out['test_insert'] = $e->getMessage();
|
||||
}
|
||||
|
||||
json_out($out);
|
||||
114
public/api/live_cast.php
Normal file
114
public/api/live_cast.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$action = '';
|
||||
$body = [];
|
||||
|
||||
if ($method === 'GET') {
|
||||
$action = trim((string) ($_GET['action'] ?? 'active'));
|
||||
} else {
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
$action = trim((string) ($body['action'] ?? ''));
|
||||
}
|
||||
|
||||
$public = ($method === 'GET' && $action === 'session')
|
||||
|| ($method === 'POST' && $action === 'join_event');
|
||||
|
||||
if ($method === 'POST' && in_array($action, ['create_intent', 'heartbeat'], true)) {
|
||||
$platform = strtolower(trim((string) ($body['platform'] ?? '')));
|
||||
if ($action === 'create_intent' && str_starts_with($platform, 'education')) {
|
||||
$public = true;
|
||||
}
|
||||
if ($action === 'heartbeat') {
|
||||
$sid = trim((string) ($body['session_id'] ?? ''));
|
||||
if ($sid !== '') {
|
||||
$existing = LiveCastRepository::sessionById($sid);
|
||||
$existingPlatform = strtolower((string) ($existing['platform'] ?? ''));
|
||||
if ($existing !== null && str_starts_with($existingPlatform, 'education')) {
|
||||
$public = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$public) {
|
||||
Auth::check();
|
||||
}
|
||||
$actor = Auth::user() ?? [];
|
||||
|
||||
if ($method === 'GET') {
|
||||
if ($action === 'active') {
|
||||
$platform = trim((string) ($_GET['platform'] ?? ''));
|
||||
$rows = LiveCastRepository::activeSessions($actor, $platform !== '' ? $platform : null);
|
||||
json_out(['ok' => true, 'sessions' => $rows]);
|
||||
}
|
||||
if ($action === 'analytics') {
|
||||
$days = (int) ($_GET['days'] ?? 14);
|
||||
$payload = LiveCastRepository::analytics($days, $actor);
|
||||
json_out(['ok' => true, 'data' => $payload]);
|
||||
}
|
||||
if ($action === 'list') {
|
||||
$days = (int) ($_GET['days'] ?? 14);
|
||||
$limit = (int) ($_GET['limit'] ?? 200);
|
||||
$rows = LiveCastRepository::listSessions($days, $actor, $limit);
|
||||
json_out(['ok' => true, 'sessions' => $rows]);
|
||||
}
|
||||
if ($action === 'session') {
|
||||
$sessionId = trim((string) ($_GET['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
$row = LiveCastRepository::publicSession($sessionId);
|
||||
if ($row === null) {
|
||||
json_out(['ok' => false, 'error' => 'not_found'], 404);
|
||||
}
|
||||
json_out(['ok' => true, 'session' => $row]);
|
||||
}
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
if ($action === 'create_intent') {
|
||||
$row = LiveCastRepository::createIntent($body, $actor);
|
||||
if ($row === []) {
|
||||
json_out(['ok' => false, 'error' => 'create_failed'], 500);
|
||||
}
|
||||
json_out(['ok' => true, 'session' => $row]);
|
||||
}
|
||||
|
||||
if ($action === 'heartbeat') {
|
||||
$sessionId = trim((string) ($body['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
$row = LiveCastRepository::heartbeat($sessionId, $body, $actor);
|
||||
if ($row === null) {
|
||||
json_out(['ok' => false, 'error' => 'not_found'], 404);
|
||||
}
|
||||
json_out(['ok' => true, 'session' => $row]);
|
||||
}
|
||||
|
||||
if ($action === 'join_event') {
|
||||
$sessionId = trim((string) ($body['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
$ok = LiveCastRepository::registerJoin($sessionId, $body, $actor);
|
||||
if (!$ok) {
|
||||
json_out(['ok' => false, 'error' => 'not_found'], 404);
|
||||
}
|
||||
json_out(['ok' => true]);
|
||||
}
|
||||
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
84
public/api/live_cast_signaling.php
Normal file
84
public/api/live_cast_signaling.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$sessionId = trim((string) ($_GET['session_id'] ?? ''));
|
||||
$sinceId = (int) ($_GET['since_id'] ?? 0);
|
||||
$msgType = trim((string) ($_GET['msg_type'] ?? ''));
|
||||
$fromRole = trim((string) ($_GET['from_role'] ?? ''));
|
||||
$body = [];
|
||||
|
||||
if ($method === 'POST') {
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
$sessionId = trim((string) ($body['session_id'] ?? $sessionId));
|
||||
}
|
||||
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
|
||||
$session = LiveCastRepository::sessionById($sessionId);
|
||||
if ($session === null) {
|
||||
json_out(['ok' => false, 'error' => 'not_found'], 404);
|
||||
}
|
||||
|
||||
$platform = strtolower((string) ($session['platform'] ?? ''));
|
||||
$status = strtolower((string) ($session['status'] ?? ''));
|
||||
$isPublic = str_starts_with($platform, 'education')
|
||||
|| in_array($status, ['intent', 'live'], true);
|
||||
|
||||
if (!$isPublic) {
|
||||
Auth::check();
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
$action = trim((string) ($_GET['action'] ?? 'poll'));
|
||||
if ($action === 'latest') {
|
||||
$latest = LiveCastSignalingRepository::latest(
|
||||
$sessionId,
|
||||
$msgType !== '' ? $msgType : 'offer',
|
||||
$fromRole !== '' ? $fromRole : null
|
||||
);
|
||||
json_out(['ok' => true, 'message' => $latest]);
|
||||
}
|
||||
$messages = LiveCastSignalingRepository::poll(
|
||||
$sessionId,
|
||||
$sinceId,
|
||||
$msgType !== '' ? $msgType : null,
|
||||
$fromRole !== '' ? $fromRole : null
|
||||
);
|
||||
$lastId = $sinceId;
|
||||
foreach ($messages as $m) {
|
||||
$lastId = max($lastId, (int) ($m['id'] ?? 0));
|
||||
}
|
||||
json_out(['ok' => true, 'messages' => $messages, 'since_id' => $lastId]);
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$action = trim((string) ($body['action'] ?? 'post'));
|
||||
if ($action !== 'post') {
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
}
|
||||
|
||||
$peerRole = trim((string) ($body['peer_role'] ?? 'caster'));
|
||||
$type = trim((string) ($body['msg_type'] ?? ''));
|
||||
$payload = $body['payload'] ?? null;
|
||||
if (!is_array($payload)) {
|
||||
$payload = ['sdp' => (string) ($body['sdp'] ?? ''), 'type' => (string) ($body['type'] ?? '')];
|
||||
}
|
||||
if ($type === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_msg_type'], 400);
|
||||
}
|
||||
|
||||
$id = LiveCastSignalingRepository::post($sessionId, $peerRole, $type, $payload);
|
||||
json_out(['ok' => true, 'id' => $id]);
|
||||
73
public/api/report_tags.php
Normal file
73
public/api/report_tags.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
|
||||
if ($method === 'GET') {
|
||||
if ($id <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||
}
|
||||
$report = ReportRepository::getById($id);
|
||||
if (!$report) {
|
||||
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||
}
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'id' => $id,
|
||||
'tags' => $report['tags'] ?? [],
|
||||
'workflow' => TagCatalog::workflowPresets(),
|
||||
'presets' => TagCatalog::editorPresets(),
|
||||
'can_edit' => Auth::canEditTags((int) ($report['company_id'] ?? 0)),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($method !== 'PUT' && $method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
$id = (int) ($data['id'] ?? $id);
|
||||
if ($id <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||
}
|
||||
|
||||
$tagsIn = $data['tags'] ?? [];
|
||||
if (!is_array($tagsIn)) {
|
||||
json_out(['ok' => false, 'error' => 'tags must be array'], 400);
|
||||
}
|
||||
|
||||
$report = ReportRepository::getById($id);
|
||||
if (!$report) {
|
||||
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||
}
|
||||
|
||||
if (!Auth::canEditTags((int) ($report['company_id'] ?? 0))) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
ReportRepository::setCustomTags($id, $tagsIn);
|
||||
$updated = ReportRepository::getById($id);
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'id' => $id,
|
||||
'tags' => $updated['tags'] ?? [],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('report_tags: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'save failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
25
public/api/report_viewed.php
Normal file
25
public/api/report_viewed.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'POST required'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
||||
$id = (int) ($data['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
ReportRepository::markViewed($id, (int) Auth::user()['id']);
|
||||
json_out(['ok' => true, 'id' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('report_viewed: ' . $e->getMessage());
|
||||
json_out(['ok' => false, 'error' => 'failed'], 500);
|
||||
}
|
||||
87
public/api/reports.php
Normal file
87
public/api/reports.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
$grouped = isset($_GET['group']) && $_GET['group'] === '1';
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$perPage = (int) ($_GET['per_page'] ?? 50);
|
||||
$allowedPerPage = [25, 50, 75, 100, 200];
|
||||
if (!in_array($perPage, $allowedPerPage, true)) {
|
||||
$perPage = 50;
|
||||
}
|
||||
$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'priority'));
|
||||
$dir = (string) ($_GET['dir'] ?? 'desc');
|
||||
$sinceMs = max(0, (int) ($_GET['since_ms'] ?? 0));
|
||||
$similarTo = max(0, (int) ($_GET['similar_to'] ?? 0));
|
||||
$searchQ = trim((string) ($_GET['q'] ?? ''));
|
||||
$suggest = isset($_GET['suggest']) && $_GET['suggest'] === '1';
|
||||
|
||||
$tagIds = request_tag_filter_ids();
|
||||
$tagMode = request_tag_filter_mode();
|
||||
|
||||
$filters = [];
|
||||
if ($tagIds !== []) {
|
||||
$filters['tags'] = $tagIds;
|
||||
$filters['tag_mode'] = $tagMode;
|
||||
}
|
||||
$device = trim((string) ($_GET['filter_device'] ?? ''));
|
||||
if ($device !== '') {
|
||||
$filters['device'] = $device;
|
||||
}
|
||||
$abi = trim((string) ($_GET['filter_abi'] ?? ''));
|
||||
if ($abi !== '') {
|
||||
$filters['abi'] = $abi;
|
||||
}
|
||||
$osSdk = max(0, (int) ($_GET['filter_os_sdk'] ?? 0));
|
||||
if ($osSdk > 0) {
|
||||
$filters['os_sdk'] = $osSdk;
|
||||
}
|
||||
$osRelease = trim((string) ($_GET['filter_os_release'] ?? ''));
|
||||
if ($osRelease !== '') {
|
||||
$filters['os_release'] = $osRelease;
|
||||
}
|
||||
$appVersion = trim((string) ($_GET['filter_app_version'] ?? ''));
|
||||
if ($appVersion !== '') {
|
||||
$filters['app_version'] = $appVersion;
|
||||
}
|
||||
$build = trim((string) ($_GET['filter_build'] ?? ''));
|
||||
if ($build !== '') {
|
||||
$filters['build'] = $build;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($suggest) {
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'suggestions' => ReportRepository::searchSuggestions($searchQ),
|
||||
'q' => $searchQ,
|
||||
]);
|
||||
}
|
||||
if ($searchQ !== '') {
|
||||
$data = ReportRepository::search($searchQ, $page, $perPage, $sinceMs);
|
||||
} elseif ($similarTo > 0) {
|
||||
$data = ReportRepository::listSimilar($similarTo, $page, $perPage, $sinceMs);
|
||||
} elseif ($filters !== []) {
|
||||
if ($tagIds !== []) {
|
||||
$grouped = false;
|
||||
}
|
||||
$data = ReportRepository::listFiltered($filters, $page, $perPage, $sort, $dir, $sinceMs);
|
||||
} else {
|
||||
$data = ReportRepository::listPage($grouped, $page, $perPage, $sort, $dir, $sinceMs);
|
||||
}
|
||||
json_out(['ok' => true, 'tag_filter' => $tagIds, 'tag_mode' => $tagMode] + $data);
|
||||
} catch (Throwable $e) {
|
||||
error_log('reports api: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'list failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
if (str_contains($e->getMessage(), '2002')) {
|
||||
$out['hint'] .= ' — use db.mysql.socket (/run/mysqld/mysqld.sock on Alpine); TCP 3306 is closed';
|
||||
}
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
18
public/api/sfu_health.php
Normal file
18
public/api/sfu_health.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$sfu = cfg('sfu', []);
|
||||
$enabled = (bool) ($sfu['enabled'] ?? false);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'sfu' => [
|
||||
'enabled' => $enabled,
|
||||
'status' => $enabled ? 'preview' : 'disabled',
|
||||
'signaling_url' => $enabled ? (string) ($sfu['signaling_url'] ?? '') : '',
|
||||
'janus_url' => $enabled ? (string) ($sfu['janus_url'] ?? '') : '',
|
||||
],
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
15
public/api/tag_catalog.php
Normal file
15
public/api/tag_catalog.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'workflow' => TagCatalog::workflowPresets(),
|
||||
'presets' => TagCatalog::editorPresets(),
|
||||
'filter_mode_default' => 'and',
|
||||
'filter_mode_help' => 'AND = report has all listed tags; OR = any listed tag',
|
||||
]);
|
||||
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/
|
||||
24
sql/migrations/002_reports_company_columns.sql
Normal file
24
sql/migrations/002_reports_company_columns.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Add RBAC columns to existing reports table (requires ALTER — run as MySQL/MariaDB root).
|
||||
-- App user androidcast typically has SELECT,INSERT,UPDATE,DELETE only.
|
||||
--
|
||||
-- mysql -u root -p androidcast_crashes < sql/migrations/002_reports_company_columns.sql
|
||||
--
|
||||
-- If 001_rbac_companies.sql was not applied yet, run that first.
|
||||
|
||||
USE androidcast_crashes;
|
||||
|
||||
INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default');
|
||||
|
||||
ALTER TABLE reports
|
||||
ADD COLUMN IF NOT EXISTS company_id INT UNSIGNED NOT NULL DEFAULT 1 AFTER id,
|
||||
ADD COLUMN IF NOT EXISTS device_id BIGINT UNSIGNED NULL AFTER company_id;
|
||||
|
||||
UPDATE reports SET company_id = 1 WHERE company_id IS NULL OR company_id = 0;
|
||||
|
||||
ALTER TABLE reports ADD INDEX IF NOT EXISTS idx_reports_company (company_id);
|
||||
ALTER TABLE reports ADD INDEX IF NOT EXISTS idx_reports_device (device_id);
|
||||
|
||||
INSERT IGNORE INTO company_memberships (user_id, company_id, role)
|
||||
SELECT u.id, 1,
|
||||
CASE WHEN LOWER(u.role) IN ('root', 'admin', 'platform_admin') THEN 'owner' ELSE 'member' END
|
||||
FROM users u;
|
||||
607
src/LiveCastRepository.php
Normal file
607
src/LiveCastRepository.php
Normal file
@@ -0,0 +1,607 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class LiveCastRepository {
|
||||
private static bool $schemaEnsured = false;
|
||||
|
||||
public static function ensureSchema(): void {
|
||||
if (self::$schemaEnsured) {
|
||||
return;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
Database::withMigrationLock($pdo, 'live_cast_sessions', static function () use ($pdo): void {
|
||||
self::createTables($pdo);
|
||||
self::ensureIndexes($pdo);
|
||||
});
|
||||
self::$schemaEnsured = true;
|
||||
}
|
||||
|
||||
private static function createTables(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'live_cast_sessions')) {
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_sessions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(96) NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
owner_user_id INT UNSIGNED NULL,
|
||||
owner_username VARCHAR(64) NULL,
|
||||
device_id VARCHAR(128) NULL,
|
||||
platform VARCHAR(24) NOT NULL DEFAULT 'android',
|
||||
status ENUM('intent','live','closed','expired') NOT NULL DEFAULT 'intent',
|
||||
started_at_ms BIGINT NOT NULL,
|
||||
last_heartbeat_ms BIGINT NOT NULL,
|
||||
ended_at_ms BIGINT NULL,
|
||||
max_duration_s INT NOT NULL DEFAULT 300,
|
||||
join_short_url TEXT NULL,
|
||||
join_qr_url TEXT NULL,
|
||||
join_slug VARCHAR(32) NULL,
|
||||
direct_short_url TEXT NULL,
|
||||
direct_qr_url TEXT NULL,
|
||||
direct_slug VARCHAR(32) NULL,
|
||||
codec_video VARCHAR(32) NULL,
|
||||
codec_audio VARCHAR(32) NULL,
|
||||
bw_mode VARCHAR(24) NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_live_cast_session_id (session_id),
|
||||
KEY idx_live_cast_company_status (company_id, status),
|
||||
KEY idx_live_cast_last_hb (last_heartbeat_ms),
|
||||
KEY idx_live_cast_owner (owner_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
} else {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
company_id INTEGER NOT NULL DEFAULT 1,
|
||||
owner_user_id INTEGER NULL,
|
||||
owner_username TEXT NULL,
|
||||
device_id TEXT NULL,
|
||||
platform TEXT NOT NULL DEFAULT 'android',
|
||||
status TEXT NOT NULL DEFAULT 'intent',
|
||||
started_at_ms INTEGER NOT NULL,
|
||||
last_heartbeat_ms INTEGER NOT NULL,
|
||||
ended_at_ms INTEGER NULL,
|
||||
max_duration_s INTEGER NOT NULL DEFAULT 300,
|
||||
join_short_url TEXT NULL,
|
||||
join_qr_url TEXT NULL,
|
||||
join_slug TEXT NULL,
|
||||
direct_short_url TEXT NULL,
|
||||
direct_qr_url TEXT NULL,
|
||||
direct_slug TEXT NULL,
|
||||
codec_video TEXT NULL,
|
||||
codec_audio TEXT NULL,
|
||||
bw_mode TEXT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Database::tableExists($pdo, 'live_cast_join_events')) {
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_join_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(96) NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
event_type ENUM('open','join','leave') NOT NULL DEFAULT 'open',
|
||||
platform VARCHAR(24) NOT NULL DEFAULT 'unknown',
|
||||
channel VARCHAR(24) NOT NULL DEFAULT 'short_link',
|
||||
direct TINYINT(1) NOT NULL DEFAULT 0,
|
||||
user_id INT UNSIGNED NULL,
|
||||
anon_id VARCHAR(96) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
ip_hash CHAR(64) NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_live_join_session_time (session_id, created_at_ms),
|
||||
KEY idx_live_join_company_time (company_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
} else {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_join_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
company_id INTEGER NOT NULL DEFAULT 1,
|
||||
event_type TEXT NOT NULL DEFAULT 'open',
|
||||
platform TEXT NOT NULL DEFAULT 'unknown',
|
||||
channel TEXT NOT NULL DEFAULT 'short_link',
|
||||
direct INTEGER NOT NULL DEFAULT 0,
|
||||
user_id INTEGER NULL,
|
||||
anon_id TEXT NULL,
|
||||
user_agent TEXT NULL,
|
||||
ip_hash TEXT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureIndexes(PDO $pdo): void {
|
||||
if (Database::isMysql()) {
|
||||
return;
|
||||
}
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_company_status ON live_cast_sessions (company_id, status)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_last_hb ON live_cast_sessions (last_heartbeat_ms)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_session_time ON live_cast_join_events (session_id, created_at_ms)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_company_time ON live_cast_join_events (company_id, created_at_ms)');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function createIntent(array $input, array $actor): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$userId = (int) ($actor['id'] ?? 0);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$ownerUsername = trim((string) ($actor['username'] ?? ''));
|
||||
$platform = strtolower(trim((string) ($input['platform'] ?? 'android')));
|
||||
if ($platform === '') {
|
||||
$platform = 'android';
|
||||
}
|
||||
$deviceId = trim((string) ($input['device_id'] ?? ''));
|
||||
$maxDuration = (int) ($input['max_duration_s'] ?? 300);
|
||||
$maxDuration = max(60, min(1800, $maxDuration));
|
||||
if (str_starts_with($platform, 'education')) {
|
||||
$maxDuration = min($maxDuration, 300);
|
||||
}
|
||||
|
||||
$sessionId = self::newSessionId($now, $companyId, $userId);
|
||||
$joinLongUrl = self::joinLongUrl($sessionId, false);
|
||||
$directLongUrl = self::joinLongUrl($sessionId, true);
|
||||
|
||||
$links = self::makeShortLinks($joinLongUrl, $directLongUrl);
|
||||
$meta = is_array($input['meta'] ?? null) ? $input['meta'] : [];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO live_cast_sessions (
|
||||
session_id, company_id, owner_user_id, owner_username, device_id, platform, status,
|
||||
started_at_ms, last_heartbeat_ms, max_duration_s,
|
||||
join_short_url, join_qr_url, join_slug, direct_short_url, direct_qr_url, direct_slug,
|
||||
codec_video, codec_audio, bw_mode, metadata_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$sessionId,
|
||||
$companyId,
|
||||
$userId > 0 ? $userId : null,
|
||||
$ownerUsername !== '' ? $ownerUsername : null,
|
||||
$deviceId !== '' ? $deviceId : null,
|
||||
$platform,
|
||||
'intent',
|
||||
$now,
|
||||
$now,
|
||||
$maxDuration,
|
||||
$links['join_short_url'],
|
||||
$links['join_qr_url'],
|
||||
$links['join_slug'],
|
||||
$links['direct_short_url'],
|
||||
$links['direct_qr_url'],
|
||||
$links['direct_slug'],
|
||||
trim((string) ($input['codec_video'] ?? '')) ?: null,
|
||||
trim((string) ($input['codec_audio'] ?? '')) ?: null,
|
||||
trim((string) ($input['bw_mode'] ?? '')) ?: null,
|
||||
safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
return self::sessionById($sessionId) ?? [];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function heartbeat(string $sessionId, array $input, array $actor): ?array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$sessionId = trim($sessionId);
|
||||
if ($sessionId === '') {
|
||||
return null;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$status = strtolower(trim((string) ($input['status'] ?? 'active')));
|
||||
if ($status !== 'ended' && $status !== 'active') {
|
||||
$status = 'active';
|
||||
}
|
||||
$nextState = $status === 'ended' ? 'closed' : 'live';
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE live_cast_sessions
|
||||
SET status = ?, last_heartbeat_ms = ?, ended_at_ms = CASE WHEN ? = ? THEN ? ELSE ended_at_ms END,
|
||||
codec_video = COALESCE(NULLIF(?, \'\'), codec_video),
|
||||
codec_audio = COALESCE(NULLIF(?, \'\'), codec_audio),
|
||||
bw_mode = COALESCE(NULLIF(?, \'\'), bw_mode)
|
||||
WHERE session_id = ? AND company_id = ?'
|
||||
);
|
||||
$stmt->execute([
|
||||
$nextState,
|
||||
$now,
|
||||
$nextState,
|
||||
'closed',
|
||||
$now,
|
||||
trim((string) ($input['codec_video'] ?? '')),
|
||||
trim((string) ($input['codec_audio'] ?? '')),
|
||||
trim((string) ($input['bw_mode'] ?? '')),
|
||||
$sessionId,
|
||||
$companyId,
|
||||
]);
|
||||
if ($stmt->rowCount() < 1) {
|
||||
return null;
|
||||
}
|
||||
if (isset($input['stats']) && is_array($input['stats'])) {
|
||||
$row = self::sessionById($sessionId);
|
||||
$meta = [];
|
||||
if ($row !== null && !empty($row['metadata_json'])) {
|
||||
$decoded = json_decode((string) $row['metadata_json'], true);
|
||||
if (is_array($decoded)) {
|
||||
$meta = $decoded;
|
||||
}
|
||||
}
|
||||
$meta['last_stats'] = $input['stats'];
|
||||
$meta['last_stats_ms'] = $now;
|
||||
$upd = $pdo->prepare('UPDATE live_cast_sessions SET metadata_json = ? WHERE session_id = ?');
|
||||
$upd->execute([
|
||||
safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
$sessionId,
|
||||
]);
|
||||
}
|
||||
return self::sessionById($sessionId);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public static function activeSessions(array $actor, ?string $platform = null): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$pdo = Database::pdo();
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$where = ['company_id = ?', "status IN ('intent','live')"];
|
||||
$params = [$companyId];
|
||||
if ($platform !== null && trim($platform) !== '') {
|
||||
$where[] = 'platform = ?';
|
||||
$params[] = strtolower(trim($platform));
|
||||
}
|
||||
$sql = 'SELECT session_id, owner_user_id, owner_username, device_id, platform, status,
|
||||
started_at_ms, last_heartbeat_ms, max_duration_s, ended_at_ms,
|
||||
join_short_url, join_qr_url, direct_short_url, direct_qr_url,
|
||||
codec_video, codec_audio, bw_mode
|
||||
FROM live_cast_sessions
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
ORDER BY last_heartbeat_ms DESC LIMIT 200';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = self::normalizeSessionRow($row);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function analytics(int $days, array $actor): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$days = max(1, min(90, $days));
|
||||
$startMs = (int) ((time() - ($days * 86400)) * 1000);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$pdo = Database::pdo();
|
||||
|
||||
$castsPerDay = self::countByDay($pdo, 'live_cast_sessions', 'started_at_ms', $startMs, $companyId);
|
||||
$avgDurationPerDay = self::avgDurationByDay($pdo, $startMs, $companyId);
|
||||
$platformMix = self::breakdown($pdo, 'live_cast_sessions', 'platform', $startMs, $companyId);
|
||||
$videoCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_video', $startMs, $companyId);
|
||||
$audioCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_audio', $startMs, $companyId);
|
||||
$bwModeMix = self::breakdown($pdo, 'live_cast_sessions', 'bw_mode', $startMs, $companyId);
|
||||
$joinsPerPlatform = self::breakdown($pdo, 'live_cast_join_events', 'platform', $startMs, $companyId);
|
||||
|
||||
$topUsersStmt = $pdo->prepare(
|
||||
'SELECT COALESCE(NULLIF(owner_username, \'\'), \'unknown\') AS u, COUNT(*) AS c
|
||||
FROM live_cast_sessions
|
||||
WHERE company_id = ? AND started_at_ms >= ?
|
||||
GROUP BY u ORDER BY c DESC LIMIT 20'
|
||||
);
|
||||
$topUsersStmt->execute([$companyId, $startMs]);
|
||||
$topUsers = [];
|
||||
foreach ($topUsersStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$topUsers[] = ['label' => (string) ($row['u'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
|
||||
return [
|
||||
'window_days' => $days,
|
||||
'casts_per_day' => $castsPerDay,
|
||||
'avg_length_s_per_day' => $avgDurationPerDay,
|
||||
'platform_mix' => $platformMix,
|
||||
'joins_platform_mix' => $joinsPerPlatform,
|
||||
'video_codec_mix' => $videoCodecMix,
|
||||
'audio_codec_mix' => $audioCodecMix,
|
||||
'bw_mode_mix' => $bwModeMix,
|
||||
'top_users' => $topUsers,
|
||||
];
|
||||
}
|
||||
|
||||
public static function registerJoin(string $sessionId, array $payload, array $actor): bool {
|
||||
self::ensureSchema();
|
||||
$sessionId = trim($sessionId);
|
||||
if ($sessionId === '') {
|
||||
return false;
|
||||
}
|
||||
$session = self::sessionById($sessionId);
|
||||
if ($session === null) {
|
||||
return false;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$companyId = (int) ($session['company_id'] ?? Rbac::defaultCompanyId());
|
||||
$eventType = strtolower(trim((string) ($payload['event_type'] ?? 'open')));
|
||||
if (!in_array($eventType, ['open', 'join', 'leave'], true)) {
|
||||
$eventType = 'open';
|
||||
}
|
||||
$platform = strtolower(trim((string) ($payload['platform'] ?? 'unknown')));
|
||||
if ($platform === '') {
|
||||
$platform = 'unknown';
|
||||
}
|
||||
$channel = strtolower(trim((string) ($payload['channel'] ?? 'short_link')));
|
||||
if ($channel === '') {
|
||||
$channel = 'short_link';
|
||||
}
|
||||
$direct = !empty($payload['direct']) ? 1 : 0;
|
||||
$userId = (int) ($actor['id'] ?? 0);
|
||||
$anon = trim((string) ($payload['anon_id'] ?? ''));
|
||||
$ua = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
$ip = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
$ipHash = $ip !== '' ? hash('sha256', $ip . '|ac-live-cast') : null;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO live_cast_join_events
|
||||
(session_id, company_id, event_type, platform, channel, direct, user_id, anon_id, user_agent, ip_hash, created_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$sessionId,
|
||||
$companyId,
|
||||
$eventType,
|
||||
$platform,
|
||||
$channel,
|
||||
$direct,
|
||||
$userId > 0 ? $userId : null,
|
||||
$anon !== '' ? $anon : null,
|
||||
$ua !== '' ? substr($ua, 0, 255) : null,
|
||||
$ipHash,
|
||||
$now,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function sessionById(string $sessionId): ?array {
|
||||
self::ensureSchema();
|
||||
$stmt = Database::pdo()->prepare(
|
||||
'SELECT * FROM live_cast_sessions WHERE session_id = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$sessionId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
return self::normalizeSessionRow($row);
|
||||
}
|
||||
|
||||
private static function expireStale(): void {
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$staleBefore = $now - 60_000;
|
||||
$sql = "UPDATE live_cast_sessions
|
||||
SET status = 'expired', ended_at_ms = COALESCE(ended_at_ms, ?)
|
||||
WHERE status IN ('intent','live')
|
||||
AND last_heartbeat_ms < ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$now, $staleBefore]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function normalizeSessionRow(array $row): array {
|
||||
return [
|
||||
'session_id' => (string) ($row['session_id'] ?? ''),
|
||||
'company_id' => (int) ($row['company_id'] ?? 0),
|
||||
'owner_user_id' => isset($row['owner_user_id']) ? (int) $row['owner_user_id'] : null,
|
||||
'owner_username' => (string) ($row['owner_username'] ?? ''),
|
||||
'device_id' => (string) ($row['device_id'] ?? ''),
|
||||
'platform' => (string) ($row['platform'] ?? ''),
|
||||
'status' => (string) ($row['status'] ?? ''),
|
||||
'started_at_ms' => (int) ($row['started_at_ms'] ?? 0),
|
||||
'last_heartbeat_ms' => (int) ($row['last_heartbeat_ms'] ?? 0),
|
||||
'ended_at_ms' => isset($row['ended_at_ms']) ? (int) $row['ended_at_ms'] : null,
|
||||
'max_duration_s' => (int) ($row['max_duration_s'] ?? 0),
|
||||
'join_short_url' => (string) ($row['join_short_url'] ?? ''),
|
||||
'join_qr_url' => (string) ($row['join_qr_url'] ?? ''),
|
||||
'direct_short_url' => (string) ($row['direct_short_url'] ?? ''),
|
||||
'direct_qr_url' => (string) ($row['direct_qr_url'] ?? ''),
|
||||
'codec_video' => (string) ($row['codec_video'] ?? ''),
|
||||
'codec_audio' => (string) ($row['codec_audio'] ?? ''),
|
||||
'bw_mode' => (string) ($row['bw_mode'] ?? ''),
|
||||
'metadata_json' => (string) ($row['metadata_json'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private static function newSessionId(int $nowMs, int $companyId, int $userId): string {
|
||||
$seed = $nowMs . ':' . $companyId . ':' . $userId . ':' . bin2hex(random_bytes(6));
|
||||
return 'live-' . substr(hash('sha256', $seed), 0, 20);
|
||||
}
|
||||
|
||||
private static function joinLongUrl(string $sessionId, bool $direct): string {
|
||||
$base = rtrim((string) cfg('base_path', '/app/androidcast_project/issues'), '/');
|
||||
if (str_ends_with($base, '/crashes')) {
|
||||
$base = substr($base, 0, -strlen('/crashes')) . '/issues';
|
||||
}
|
||||
$qs = 'session_id=' . rawurlencode($sessionId);
|
||||
if ($direct) {
|
||||
$qs .= '&direct=1';
|
||||
}
|
||||
return 'https://apps.f0xx.org' . $base . '/live/join?' . $qs;
|
||||
}
|
||||
|
||||
/** @return array<string, string|null> */
|
||||
private static function makeShortLinks(string $joinLongUrl, string $directLongUrl): array {
|
||||
$out = [
|
||||
'join_short_url' => null,
|
||||
'join_qr_url' => null,
|
||||
'join_slug' => null,
|
||||
'direct_short_url' => null,
|
||||
'direct_qr_url' => null,
|
||||
'direct_slug' => null,
|
||||
];
|
||||
if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) {
|
||||
return $out;
|
||||
}
|
||||
$bearers = ShortLinksRepository::listBearers();
|
||||
if ($bearers === []) {
|
||||
return $out;
|
||||
}
|
||||
$bearerId = (int) ($bearers[0]['id'] ?? 0);
|
||||
if ($bearerId <= 0) {
|
||||
return $out;
|
||||
}
|
||||
$join = ShortLinksRepository::createLink($bearerId, $joinLongUrl, 3600);
|
||||
if (!empty($join['ok'])) {
|
||||
$out['join_short_url'] = (string) ($join['short_url'] ?? '');
|
||||
$out['join_qr_url'] = (string) ($join['qr_url'] ?? '');
|
||||
$out['join_slug'] = (string) ($join['slug'] ?? '');
|
||||
}
|
||||
$direct = ShortLinksRepository::createLink($bearerId, $directLongUrl, 3600);
|
||||
if (!empty($direct['ok'])) {
|
||||
$out['direct_short_url'] = (string) ($direct['short_url'] ?? '');
|
||||
$out['direct_qr_url'] = (string) ($direct['qr_url'] ?? '');
|
||||
$out['direct_slug'] = (string) ($direct['slug'] ?? '');
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return list<array{x:string,y:int}> */
|
||||
private static function countByDay(PDO $pdo, string $table, string $msColumn, int $startMs, int $companyId): array {
|
||||
$day = Database::isMysql()
|
||||
? 'DATE(FROM_UNIXTIME(FLOOR(' . $msColumn . ' / 1000)))'
|
||||
: "strftime('%Y-%m-%d', " . $msColumn . " / 1000, 'unixepoch')";
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT ' . $day . ' AS d, COUNT(*) AS c FROM ' . $table . '
|
||||
WHERE company_id = ? AND ' . $msColumn . ' >= ?
|
||||
GROUP BY d ORDER BY d'
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return list<array{x:string,y:float}> */
|
||||
private static function avgDurationByDay(PDO $pdo, int $startMs, int $companyId): array {
|
||||
$day = Database::isMysql()
|
||||
? 'DATE(FROM_UNIXTIME(FLOOR(started_at_ms / 1000)))'
|
||||
: "strftime('%Y-%m-%d', started_at_ms / 1000, 'unixepoch')";
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT ' . $day . ' AS d,
|
||||
AVG(CASE WHEN ended_at_ms IS NOT NULL AND ended_at_ms >= started_at_ms
|
||||
THEN (ended_at_ms - started_at_ms) / 1000.0 ELSE 0 END) AS v
|
||||
FROM live_cast_sessions
|
||||
WHERE company_id = ? AND started_at_ms >= ?
|
||||
GROUP BY d ORDER BY d'
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => round((float) ($row['v'] ?? 0), 1)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public static function listSessions(int $days, array $actor, int $limit = 200): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$days = max(1, min(90, $days));
|
||||
$startMs = (int) ((time() - ($days * 86400)) * 1000);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$limit = max(1, min(500, $limit));
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT s.session_id, s.owner_user_id, s.owner_username, s.device_id, s.platform, s.status,
|
||||
s.started_at_ms, s.last_heartbeat_ms, s.ended_at_ms, s.max_duration_s,
|
||||
s.join_short_url, s.direct_short_url, s.codec_video, s.codec_audio, s.bw_mode,
|
||||
(SELECT COUNT(*) FROM live_cast_join_events j
|
||||
WHERE j.session_id = s.session_id AND j.event_type IN (\'open\', \'join\')) AS join_opens
|
||||
FROM live_cast_sessions s
|
||||
WHERE s.company_id = ? AND s.started_at_ms >= ?
|
||||
ORDER BY s.last_heartbeat_ms DESC
|
||||
LIMIT ' . (int) $limit
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$normalized = self::normalizeSessionRow($row);
|
||||
$normalized['join_opens'] = (int) ($row['join_opens'] ?? 0);
|
||||
$out[] = $normalized;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function publicSession(string $sessionId): ?array {
|
||||
$row = self::sessionById($sessionId);
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'session_id' => $row['session_id'],
|
||||
'owner_username' => $row['owner_username'],
|
||||
'platform' => $row['platform'],
|
||||
'status' => $row['status'],
|
||||
'started_at_ms' => $row['started_at_ms'],
|
||||
'last_heartbeat_ms' => $row['last_heartbeat_ms'],
|
||||
'ended_at_ms' => $row['ended_at_ms'],
|
||||
'max_duration_s' => $row['max_duration_s'],
|
||||
'codec_video' => $row['codec_video'],
|
||||
'codec_audio' => $row['codec_audio'],
|
||||
'bw_mode' => $row['bw_mode'],
|
||||
'join_short_url' => $row['join_short_url'] ?? '',
|
||||
'join_qr_url' => $row['join_qr_url'] ?? '',
|
||||
'signaling_api' => Auth::basePath() . '/api/live_cast_signaling.php',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array{label:string,value:int}> */
|
||||
private static function breakdown(PDO $pdo, string $table, string $column, int $startMs, int $companyId): array {
|
||||
$allowedTables = ['live_cast_sessions', 'live_cast_join_events'];
|
||||
if (!in_array($table, $allowedTables, true)) {
|
||||
return [];
|
||||
}
|
||||
$allowedCols = ['platform', 'codec_video', 'codec_audio', 'bw_mode'];
|
||||
if ($table === 'live_cast_join_events') {
|
||||
$allowedCols = ['platform'];
|
||||
}
|
||||
if (!in_array($column, $allowedCols, true)) {
|
||||
return [];
|
||||
}
|
||||
$timeColumn = $table === 'live_cast_sessions' ? 'started_at_ms' : 'created_at_ms';
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COALESCE(NULLIF(' . $column . ', \'\'), \'unknown\') AS k, COUNT(*) AS c
|
||||
FROM ' . $table . '
|
||||
WHERE company_id = ? AND ' . $timeColumn . ' >= ?
|
||||
GROUP BY k ORDER BY c DESC'
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
134
src/LiveCastSignalingRepository.php
Normal file
134
src/LiveCastSignalingRepository.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** WebRTC signaling store (offer/answer/ICE) — REST polling, no MCU/SFU. */
|
||||
final class LiveCastSignalingRepository {
|
||||
private static bool $schemaEnsured = false;
|
||||
|
||||
public static function ensureSchema(): void {
|
||||
if (self::$schemaEnsured) {
|
||||
return;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
Database::withMigrationLock($pdo, 'live_cast_signal_messages', static function () use ($pdo): void {
|
||||
if (!Database::tableExists($pdo, 'live_cast_signal_messages')) {
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_signal_messages (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(96) NOT NULL,
|
||||
peer_role VARCHAR(16) NOT NULL DEFAULT 'caster',
|
||||
msg_type VARCHAR(24) NOT NULL,
|
||||
payload_json LONGTEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
KEY idx_live_signal_session_id (session_id, id),
|
||||
KEY idx_live_signal_session_type (session_id, msg_type, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
} else {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_signal_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
peer_role TEXT NOT NULL DEFAULT 'caster',
|
||||
msg_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
)"
|
||||
);
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_signal_session_id ON live_cast_signal_messages (session_id, id)');
|
||||
}
|
||||
}
|
||||
});
|
||||
self::$schemaEnsured = true;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function post(string $sessionId, string $peerRole, string $msgType, array $payload): int {
|
||||
self::ensureSchema();
|
||||
$sessionId = trim($sessionId);
|
||||
$peerRole = strtolower(trim($peerRole));
|
||||
$msgType = strtolower(trim($msgType));
|
||||
if ($sessionId === '' || $msgType === '') {
|
||||
return 0;
|
||||
}
|
||||
if (!in_array($peerRole, ['caster', 'viewer', 'mobile'], true)) {
|
||||
$peerRole = 'caster';
|
||||
}
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO live_cast_signal_messages (session_id, peer_role, msg_type, payload_json, created_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$sessionId,
|
||||
$peerRole,
|
||||
$msgType,
|
||||
safe_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
$now,
|
||||
]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public static function poll(string $sessionId, int $sinceId, ?string $msgType = null, ?string $fromRole = null): array {
|
||||
self::ensureSchema();
|
||||
$sessionId = trim($sessionId);
|
||||
if ($sessionId === '') {
|
||||
return [];
|
||||
}
|
||||
$sinceId = max(0, $sinceId);
|
||||
$where = ['session_id = ?', 'id > ?'];
|
||||
$params = [$sessionId, $sinceId];
|
||||
if ($msgType !== null && trim($msgType) !== '') {
|
||||
$where[] = 'msg_type = ?';
|
||||
$params[] = strtolower(trim($msgType));
|
||||
}
|
||||
if ($fromRole !== null && trim($fromRole) !== '') {
|
||||
$where[] = 'peer_role = ?';
|
||||
$params[] = strtolower(trim($fromRole));
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, session_id, peer_role, msg_type, payload_json, created_at_ms
|
||||
FROM live_cast_signal_messages
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
ORDER BY id ASC LIMIT 200'
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$payload = json_decode((string) ($row['payload_json'] ?? ''), true);
|
||||
$out[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'session_id' => (string) ($row['session_id'] ?? ''),
|
||||
'peer_role' => (string) ($row['peer_role'] ?? ''),
|
||||
'msg_type' => (string) ($row['msg_type'] ?? ''),
|
||||
'payload' => is_array($payload) ? $payload : [],
|
||||
'created_at_ms' => (int) ($row['created_at_ms'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function latest(string $sessionId, string $msgType, ?string $fromRole = null): ?array {
|
||||
self::ensureSchema();
|
||||
$rows = self::poll($sessionId, 0, $msgType, $fromRole);
|
||||
if ($rows === []) {
|
||||
return null;
|
||||
}
|
||||
return $rows[count($rows) - 1];
|
||||
}
|
||||
|
||||
public static function purgeSession(string $sessionId): void {
|
||||
self::ensureSchema();
|
||||
$sessionId = trim($sessionId);
|
||||
if ($sessionId === '') {
|
||||
return;
|
||||
}
|
||||
$stmt = Database::pdo()->prepare('DELETE FROM live_cast_signal_messages WHERE session_id = ?');
|
||||
$stmt->execute([$sessionId]);
|
||||
}
|
||||
}
|
||||
707
src/ReportRepository.php
Normal file
707
src/ReportRepository.php
Normal file
@@ -0,0 +1,707 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ReportRepository {
|
||||
private const LIST_SORT = [
|
||||
'priority',
|
||||
'generated_at_ms',
|
||||
'received_at_ms',
|
||||
'crash_type',
|
||||
'device_model',
|
||||
'app_version',
|
||||
'fingerprint',
|
||||
'fingerprint_cnt',
|
||||
'rating',
|
||||
];
|
||||
private const GROUP_SORT = [
|
||||
'cnt',
|
||||
'fingerprint',
|
||||
'crash_type',
|
||||
'last_generated_ms',
|
||||
'last_received_ms',
|
||||
];
|
||||
|
||||
public static function insert(array $payload): void {
|
||||
$pdo = Database::pdo();
|
||||
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
|
||||
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('payload json_encode failed: ' . json_last_error_msg());
|
||||
}
|
||||
$tags = json_encode($payload['tags'] ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$companyId = Rbac::defaultCompanyId();
|
||||
$deviceId = DeviceRepository::upsertFromPayload($companyId, $device, 'auto');
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO reports (company_id, device_id, report_id, fingerprint, crash_type, generated_at_ms,
|
||||
received_at_ms, device_model, app_version, payload_json, tags_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$companyId,
|
||||
$deviceId > 0 ? $deviceId : null,
|
||||
$payload['report_id'] ?? uniqid('rpt_', true),
|
||||
$payload['fingerprint'] ?? 'unknown',
|
||||
$payload['crash_type'] ?? 'java',
|
||||
(int) ($payload['generated_at_epoch_ms'] ?? 0),
|
||||
(int) round(microtime(true) * 1000),
|
||||
$device['model'] ?? null,
|
||||
$app['version_name'] ?? null,
|
||||
$json,
|
||||
$tags ?: '[]',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function listReports(bool $grouped): array {
|
||||
return self::listPage($grouped, 1, 500, 'generated_at_ms', 'desc')['items'];
|
||||
}
|
||||
|
||||
public static function listPage(
|
||||
bool $grouped,
|
||||
int $page,
|
||||
int $perPage,
|
||||
string $sort,
|
||||
string $dir,
|
||||
int $sinceMs = 0,
|
||||
array $tagFilters = [],
|
||||
string $tagMode = 'and'
|
||||
): array {
|
||||
if ($tagFilters !== []) {
|
||||
return self::listFiltered(
|
||||
['tags' => $tagFilters, 'tag_mode' => $tagMode],
|
||||
$page,
|
||||
$perPage,
|
||||
$sort,
|
||||
$dir,
|
||||
$sinceMs
|
||||
);
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$page = max(1, $page);
|
||||
$perPage = max(1, min(200, $perPage));
|
||||
$dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
|
||||
if ($grouped) {
|
||||
$sortCol = in_array($sort, self::GROUP_SORT, true) ? $sort : 'cnt';
|
||||
$gWhere = ['1=1'];
|
||||
$gParams = [];
|
||||
self::appendCompanyScope('company_id', $gWhere, $gParams);
|
||||
$gWhereSql = implode(' AND ', $gWhere);
|
||||
$countStmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM reports WHERE $gWhereSql GROUP BY fingerprint, crash_type
|
||||
) t"
|
||||
);
|
||||
$countStmt->execute($gParams);
|
||||
$total = (int) $countStmt->fetchColumn();
|
||||
$sql = "SELECT fingerprint, crash_type,
|
||||
MIN(device_model) AS device_model,
|
||||
MAX(app_version) AS app_version,
|
||||
COUNT(*) AS cnt,
|
||||
MAX(generated_at_ms) AS last_generated_ms,
|
||||
MAX(received_at_ms) AS last_received_ms
|
||||
FROM reports
|
||||
WHERE $gWhereSql
|
||||
GROUP BY fingerprint, crash_type
|
||||
ORDER BY $sortCol $dir
|
||||
LIMIT $perPage OFFSET $offset";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($gParams);
|
||||
$items = $stmt->fetchAll();
|
||||
foreach ($items as &$row) {
|
||||
$row['fingerprint_cnt'] = (int) ($row['cnt'] ?? 0);
|
||||
$row['fingerprint_recent_cnt'] = $sinceMs > 0 && (int) ($row['last_received_ms'] ?? 0) > $sinceMs
|
||||
? (int) $row['cnt'] : 0;
|
||||
$row['rating'] = report_rating_score((int) $row['cnt'], (int) $row['fingerprint_recent_cnt'], (int) $row['cnt']);
|
||||
$row['os_name'] = '';
|
||||
$row['os_version'] = '';
|
||||
$row['brief'] = report_brief_lines($row, true);
|
||||
}
|
||||
unset($row);
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'sort' => $sortCol,
|
||||
'dir' => strtolower($dir),
|
||||
'grouped' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority';
|
||||
if ($sortCol === 'rating') {
|
||||
$sortCol = 'fingerprint_cnt';
|
||||
}
|
||||
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
|
||||
$where = ['1=1'];
|
||||
$scopeParams = [];
|
||||
self::appendCompanyScope('r.company_id', $where, $scopeParams);
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
|
||||
$countStmt->execute($scopeParams);
|
||||
$total = (int) $countStmt->fetchColumn();
|
||||
$sql = 'SELECT ' . self::listSelectColumns() . '
|
||||
FROM reports r
|
||||
' . self::fingerprintStatsJoin() . '
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE ' . $whereSql . '
|
||||
ORDER BY ' . $orderBy . '
|
||||
LIMIT ' . $perPage . ' OFFSET ' . $offset;
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$sinceMs, $userId], $scopeParams));
|
||||
$items = $stmt->fetchAll();
|
||||
self::finishListRows($items, $sinceMs, false);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'sort' => $sort === 'priority' ? 'priority' : $sortCol,
|
||||
'dir' => strtolower($dir),
|
||||
'grouped' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{similar_to?:int,device?:string,abi?:string,os_sdk?:int,os_release?:string} $filters
|
||||
*/
|
||||
public static function listFiltered(
|
||||
array $filters,
|
||||
int $page,
|
||||
int $perPage,
|
||||
string $sort,
|
||||
string $dir,
|
||||
int $sinceMs = 0
|
||||
): array {
|
||||
$pdo = Database::pdo();
|
||||
$page = max(1, $page);
|
||||
$perPage = max(1, min(200, $perPage));
|
||||
$dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
self::appendCompanyScope('r.company_id', $where, $params);
|
||||
$device = trim((string) ($filters['device'] ?? ''));
|
||||
if ($device !== '') {
|
||||
$like = '%' . $device . '%';
|
||||
$where[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
$abi = trim((string) ($filters['abi'] ?? ''));
|
||||
if ($abi !== '') {
|
||||
$where[] = 'r.payload_json LIKE ?';
|
||||
$params[] = '%' . $abi . '%';
|
||||
}
|
||||
$sdk = (int) ($filters['os_sdk'] ?? 0);
|
||||
if ($sdk > 0) {
|
||||
$where[] = 'r.payload_json LIKE ?';
|
||||
$params[] = '%"sdk_int":' . $sdk . '%';
|
||||
}
|
||||
$release = trim((string) ($filters['os_release'] ?? ''));
|
||||
if ($release !== '') {
|
||||
$where[] = '(r.payload_json LIKE ? OR r.payload_json LIKE ?)';
|
||||
$params[] = '%"release":"' . $release . '"%';
|
||||
$params[] = '%"release": "' . $release . '"%';
|
||||
}
|
||||
$appVersion = trim((string) ($filters['app_version'] ?? ''));
|
||||
if ($appVersion !== '') {
|
||||
$like = '%' . $appVersion . '%';
|
||||
$where[] = '(r.app_version = ? OR r.app_version LIKE ? OR r.payload_json LIKE ?)';
|
||||
$params[] = $appVersion;
|
||||
$params[] = $like;
|
||||
$params[] = '%"version_name":"' . $appVersion . '"%';
|
||||
}
|
||||
$build = trim((string) ($filters['build'] ?? ''));
|
||||
if ($build !== '') {
|
||||
$where[] = 'r.payload_json LIKE ?';
|
||||
$params[] = '%' . $build . '%';
|
||||
}
|
||||
self::appendTagFilters($where, $params, $filters);
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
|
||||
$countStmt->execute($params);
|
||||
$total = (int) $countStmt->fetchColumn();
|
||||
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority';
|
||||
if ($sortCol === 'rating') {
|
||||
$sortCol = 'fingerprint_cnt';
|
||||
}
|
||||
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$sql = 'SELECT ' . self::listSelectColumns() . '
|
||||
FROM reports r
|
||||
' . self::fingerprintStatsJoin() . '
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE ' . $whereSql . '
|
||||
ORDER BY ' . $orderBy . '
|
||||
LIMIT ' . $perPage . ' OFFSET ' . $offset;
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$sinceMs, $userId], $params));
|
||||
$items = $stmt->fetchAll();
|
||||
self::finishListRows($items, $sinceMs, false);
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'sort' => $sort === 'priority' ? 'priority' : $sortCol,
|
||||
'dir' => strtolower($dir),
|
||||
'grouped' => false,
|
||||
'filter' => $filters,
|
||||
];
|
||||
}
|
||||
|
||||
public static function listSimilar(
|
||||
int $anchorId,
|
||||
int $page,
|
||||
int $perPage,
|
||||
int $sinceMs = 0
|
||||
): array {
|
||||
$anchor = self::getById($anchorId);
|
||||
if ($anchor === null) {
|
||||
return [
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
'page' => 1,
|
||||
'per_page' => $perPage,
|
||||
'sort' => 'similarity',
|
||||
'dir' => 'desc',
|
||||
'grouped' => false,
|
||||
'similar_to' => $anchorId,
|
||||
];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$page = max(1, $page);
|
||||
$perPage = max(1, min(200, $perPage));
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
$payload = is_array($anchor['payload'] ?? null) ? $anchor['payload'] : [];
|
||||
$keys = report_similarity_keys($payload);
|
||||
$fp = $keys['fingerprint'];
|
||||
$ct = $keys['crash_type'];
|
||||
$familyLike = $keys['device_family'] !== '' ? '%' . $keys['device_family'] . '%' : null;
|
||||
$excLike = $keys['exception'] !== '' ? '%' . $keys['exception'] . '%' : null;
|
||||
$sigLike = $keys['signal'] !== '' ? '%' . $keys['signal'] . '%' : null;
|
||||
|
||||
$where = ['r.id != ?'];
|
||||
$params = [$anchorId];
|
||||
self::appendCompanyScope('r.company_id', $where, $params);
|
||||
$or = [];
|
||||
if ($fp !== '') {
|
||||
$or[] = 'r.fingerprint = ?';
|
||||
$params[] = $fp;
|
||||
}
|
||||
if ($ct !== '') {
|
||||
$or[] = 'r.crash_type = ?';
|
||||
$params[] = $ct;
|
||||
}
|
||||
if ($familyLike !== null) {
|
||||
$or[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)';
|
||||
$params[] = $familyLike;
|
||||
$params[] = $familyLike;
|
||||
}
|
||||
if ($excLike !== null) {
|
||||
$or[] = 'r.payload_json LIKE ?';
|
||||
$params[] = $excLike;
|
||||
}
|
||||
if ($sigLike !== null) {
|
||||
$or[] = 'r.payload_json LIKE ?';
|
||||
$params[] = $sigLike;
|
||||
}
|
||||
if ($or === []) {
|
||||
$or[] = '1=1';
|
||||
}
|
||||
$where[] = '(' . implode(' OR ', $or) . ')';
|
||||
$whereSql = implode(' AND ', $where);
|
||||
|
||||
$sql = 'SELECT ' . self::listSelectColumns() . '
|
||||
FROM reports r
|
||||
' . self::fingerprintStatsJoin() . '
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE ' . $whereSql . '
|
||||
ORDER BY r.received_at_ms DESC
|
||||
LIMIT 1200';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$sinceMs, $userId], $params));
|
||||
$rows = $stmt->fetchAll();
|
||||
$scored = [];
|
||||
foreach ($rows as $row) {
|
||||
$p = json_decode($row['payload_json'] ?? '', true);
|
||||
if (!is_array($p)) {
|
||||
$p = [];
|
||||
}
|
||||
$score = report_similarity_score($keys, $row, $p);
|
||||
if ($score <= 0) {
|
||||
continue;
|
||||
}
|
||||
$row['similarity_score'] = $score;
|
||||
$row['similarity_tier'] = $score >= 1000 ? 'exact' : ($score >= 200 ? 'strong' : ($score >= 80 ? 'likely' : 'guess'));
|
||||
$scored[] = $row;
|
||||
}
|
||||
usort($scored, static function (array $a, array $b): int {
|
||||
$sa = (int) ($a['similarity_score'] ?? 0);
|
||||
$sb = (int) ($b['similarity_score'] ?? 0);
|
||||
if ($sa !== $sb) {
|
||||
return $sb <=> $sa;
|
||||
}
|
||||
return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0));
|
||||
});
|
||||
$total = count($scored);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$slice = array_values(array_slice($scored, $offset, $perPage));
|
||||
if ($page === 1) {
|
||||
$anchorRow = null;
|
||||
foreach ($scored as $row) {
|
||||
if ((int) $row['id'] === $anchorId) {
|
||||
$anchorRow = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($anchorRow === null) {
|
||||
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
|
||||
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
|
||||
$anchorRow = [
|
||||
'id' => $anchorId,
|
||||
'report_id' => $anchor['report_id'] ?? '',
|
||||
'fingerprint' => $anchor['fingerprint'] ?? ($payload['fingerprint'] ?? ''),
|
||||
'crash_type' => $anchor['crash_type'] ?? ($payload['crash_type'] ?? ''),
|
||||
'generated_at_ms' => (int) ($anchor['generated_at_ms'] ?? 0),
|
||||
'received_at_ms' => (int) ($anchor['received_at_ms'] ?? 0),
|
||||
'device_model' => $anchor['device_model'] ?? ($device['model'] ?? null),
|
||||
'app_version' => $anchor['app_version'] ?? ($app['version_name'] ?? null),
|
||||
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
|
||||
'tags_json' => json_encode($anchor['tags'] ?? [], JSON_UNESCAPED_UNICODE) ?: '[]',
|
||||
'viewed' => !empty($anchor['viewed']) ? 1 : 0,
|
||||
'fingerprint_cnt' => 1,
|
||||
'fingerprint_recent_cnt' => 0,
|
||||
];
|
||||
}
|
||||
$anchorRow['similarity_score'] = 10000;
|
||||
$anchorRow['similarity_tier'] = 'anchor';
|
||||
$slice = array_values(array_filter(
|
||||
$slice,
|
||||
static fn (array $r): bool => (int) ($r['id'] ?? 0) !== $anchorId
|
||||
));
|
||||
array_unshift($slice, $anchorRow);
|
||||
if (count($slice) > $perPage) {
|
||||
$slice = array_slice($slice, 0, $perPage);
|
||||
}
|
||||
}
|
||||
self::finishListRows($slice, $sinceMs, false, true);
|
||||
return [
|
||||
'items' => $slice,
|
||||
'total' => $total + 1,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'sort' => 'similarity',
|
||||
'dir' => 'desc',
|
||||
'grouped' => false,
|
||||
'similar_to' => $anchorId,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function searchSuggestions(string $q, int $limit = 10): array {
|
||||
$tokens = search_query_tokens($q);
|
||||
$prefix = $tokens[0] ?? mb_strtolower(trim($q));
|
||||
if (strlen($prefix) < 2) {
|
||||
return [];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
self::appendCompanyScope('company_id', $where, $params);
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT payload_json, device_model, app_version, crash_type FROM reports
|
||||
WHERE $whereSql ORDER BY received_at_ms DESC LIMIT 500"
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$freq = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$payload = json_decode($row['payload_json'] ?? '', true);
|
||||
if (!is_array($payload)) {
|
||||
$payload = [];
|
||||
}
|
||||
$java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
|
||||
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
|
||||
$candidates = array_filter([
|
||||
$java['exception'] ?? null,
|
||||
$row['crash_type'] ?? null,
|
||||
$row['device_model'] ?? null,
|
||||
$device['manufacturer'] ?? null,
|
||||
$device['model'] ?? null,
|
||||
$row['app_version'] ?? null,
|
||||
]);
|
||||
foreach ($candidates as $term) {
|
||||
$term = trim((string) $term);
|
||||
if ($term === '' || strlen($term) < 3) {
|
||||
continue;
|
||||
}
|
||||
$low = mb_strtolower($term);
|
||||
if (!str_contains($low, $prefix)) {
|
||||
continue;
|
||||
}
|
||||
$freq[$term] = ($freq[$term] ?? 0) + 1;
|
||||
}
|
||||
if (!empty($java['message'])) {
|
||||
if (preg_match_all('/[A-Za-z][A-Za-z0-9_]{3,}(?:Exception|Error)/', (string) $java['message'], $m)) {
|
||||
foreach ($m[0] as $term) {
|
||||
$low = mb_strtolower($term);
|
||||
if (str_contains($low, $prefix)) {
|
||||
$freq[$term] = ($freq[$term] ?? 0) + 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
arsort($freq);
|
||||
return array_slice(array_keys($freq), 0, max(1, min(20, $limit)));
|
||||
}
|
||||
|
||||
public static function search(string $q, int $page, int $perPage, int $sinceMs = 0): array {
|
||||
$tokens = search_query_tokens($q);
|
||||
if ($tokens === []) {
|
||||
return [
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
'page' => 1,
|
||||
'per_page' => $perPage,
|
||||
'sort' => 'relevance',
|
||||
'dir' => 'desc',
|
||||
'grouped' => false,
|
||||
'search' => $q,
|
||||
];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$page = max(1, $page);
|
||||
$perPage = max(1, min(200, $perPage));
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
self::appendCompanyScope('r.company_id', $where, $params);
|
||||
$orParts = [];
|
||||
foreach ($tokens as $token) {
|
||||
$like = '%' . $token . '%';
|
||||
$orParts[] = '(r.payload_json LIKE ? OR r.device_model LIKE ? OR r.app_version LIKE ?
|
||||
OR r.fingerprint LIKE ? OR r.crash_type LIKE ?)';
|
||||
array_push($params, $like, $like, $like, $like, $like);
|
||||
}
|
||||
$where[] = '(' . implode(' OR ', $orParts) . ')';
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$sql = 'SELECT ' . self::listSelectColumns() . '
|
||||
FROM reports r
|
||||
' . self::fingerprintStatsJoin() . '
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE ' . $whereSql . '
|
||||
ORDER BY r.received_at_ms DESC
|
||||
LIMIT 1500';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$sinceMs, $userId], $params));
|
||||
$scored = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$payload = json_decode($row['payload_json'] ?? '', true);
|
||||
if (!is_array($payload)) {
|
||||
$payload = [];
|
||||
}
|
||||
report_enrich_list_row($row, $sinceMs);
|
||||
$score = report_search_score($tokens, $row, $payload);
|
||||
if ($score <= 0) {
|
||||
continue;
|
||||
}
|
||||
$row['search_score'] = $score;
|
||||
$scored[] = $row;
|
||||
}
|
||||
usort($scored, static function (array $a, array $b): int {
|
||||
$sa = (int) ($a['search_score'] ?? 0);
|
||||
$sb = (int) ($b['search_score'] ?? 0);
|
||||
if ($sa !== $sb) {
|
||||
return $sb <=> $sa;
|
||||
}
|
||||
return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0));
|
||||
});
|
||||
$total = count($scored);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$slice = array_slice($scored, $offset, $perPage);
|
||||
self::finishListRows($slice, $sinceMs, false, false, true);
|
||||
return [
|
||||
'items' => $slice,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'sort' => 'relevance',
|
||||
'dir' => 'desc',
|
||||
'grouped' => false,
|
||||
'search' => $q,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items */
|
||||
private static function finishListRows(
|
||||
array &$items,
|
||||
int $sinceMs,
|
||||
bool $grouped,
|
||||
bool $similar = false,
|
||||
bool $search = false
|
||||
): void {
|
||||
foreach ($items as &$row) {
|
||||
$row['viewed'] = (bool) ($row['viewed'] ?? false);
|
||||
$row['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
|
||||
report_enrich_list_row($row, $sinceMs);
|
||||
$brief = report_brief_lines($row, $grouped);
|
||||
if ($similar && !empty($row['similarity_tier'])) {
|
||||
$tier = (string) $row['similarity_tier'];
|
||||
$labels = [
|
||||
'anchor' => 'This report',
|
||||
'exact' => 'Same fingerprint',
|
||||
'strong' => 'Strong match',
|
||||
'likely' => 'Likely similar',
|
||||
'guess' => 'Possible match',
|
||||
];
|
||||
array_unshift($brief, ($labels[$tier] ?? $tier) . ' · score ' . (int) ($row['similarity_score'] ?? 0));
|
||||
}
|
||||
if ($search && !empty($row['search_score'])) {
|
||||
array_unshift($brief, 'Search match · relevance ' . (int) $row['search_score']);
|
||||
}
|
||||
$row['brief'] = $brief;
|
||||
unset($row['payload_json'], $row['tags_json']);
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
|
||||
private static function buildListOrder(string $sort, string $dir): string {
|
||||
$dir = strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC';
|
||||
$fpCnt = 'COALESCE(fp.fingerprint_cnt, 1)';
|
||||
if ($sort === 'priority') {
|
||||
return "$fpCnt DESC, r.received_at_ms DESC, r.app_version DESC, r.device_model ASC, r.crash_type ASC";
|
||||
}
|
||||
if ($sort === 'fingerprint_cnt') {
|
||||
return "$fpCnt $dir, r.received_at_ms DESC";
|
||||
}
|
||||
return "r.$sort $dir";
|
||||
}
|
||||
|
||||
private static function listSelectColumns(): string {
|
||||
return 'r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
|
||||
r.device_model, r.app_version, r.payload_json, r.tags_json,
|
||||
CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed,
|
||||
COALESCE(fp.fingerprint_cnt, 1) AS fingerprint_cnt,
|
||||
COALESCE(fp.fingerprint_recent_cnt, 0) AS fingerprint_recent_cnt';
|
||||
}
|
||||
|
||||
/** One grouped scan for fingerprint counts (replaces per-row correlated subqueries). */
|
||||
private static function fingerprintStatsJoin(): string {
|
||||
return 'LEFT JOIN (
|
||||
SELECT company_id, fingerprint,
|
||||
COUNT(*) AS fingerprint_cnt,
|
||||
SUM(CASE WHEN received_at_ms > ? THEN 1 ELSE 0 END) AS fingerprint_recent_cnt
|
||||
FROM reports
|
||||
GROUP BY company_id, fingerprint
|
||||
) fp ON fp.company_id = r.company_id AND fp.fingerprint = r.fingerprint';
|
||||
}
|
||||
|
||||
public static function setCustomTags(int $reportId, array $tags): bool {
|
||||
if (self::getById($reportId) === null) {
|
||||
return false;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$normalized = normalize_report_tags($tags);
|
||||
$json = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$stmt = $pdo->prepare('UPDATE reports SET tags_json = ? WHERE id = ?');
|
||||
$stmt->execute([$json ?: '[]', $reportId]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Distinct custom tags used across reports (for suggestions). */
|
||||
public static function listTagPresets(int $limit = 40): array {
|
||||
$pdo = Database::pdo();
|
||||
$where = ['tags_json IS NOT NULL', "tags_json != '[]'"];
|
||||
$params = [];
|
||||
self::appendCompanyScope('company_id', $where, $params);
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$stmt = $pdo->prepare("SELECT tags_json FROM reports WHERE $whereSql LIMIT 500");
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
foreach (parse_report_tags($row['tags_json'] ?? '[]') as $tag) {
|
||||
$map[$tag['id']] = $tag;
|
||||
}
|
||||
}
|
||||
return array_values($map);
|
||||
}
|
||||
|
||||
public static function markViewed(int $reportId, int $userId): void {
|
||||
Database::upsertReportView(
|
||||
$userId,
|
||||
$reportId,
|
||||
(int) round(microtime(true) * 1000)
|
||||
);
|
||||
}
|
||||
|
||||
public static function getById(int $id): ?array {
|
||||
$pdo = Database::pdo();
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.*, CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed
|
||||
FROM reports r
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE r.id = ?'
|
||||
);
|
||||
$stmt->execute([$userId, $id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if (!Rbac::canAccessReport($row)) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($row['payload_json'], true);
|
||||
$row['payload'] = is_array($decoded) ? $decoded : [];
|
||||
$row['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
|
||||
$row['viewed'] = (bool) ($row['viewed'] ?? false);
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $where
|
||||
* @param list<mixed> $params
|
||||
*/
|
||||
private static function appendCompanyScope(string $column, array &$where, array &$params): void {
|
||||
Rbac::appendCompanyScope($column, $where, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $where
|
||||
* @param list<mixed> $params
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private static function appendTagFilters(array &$where, array &$params, array $filters): void {
|
||||
$tags = $filters['tags'] ?? [];
|
||||
if (!is_array($tags) || $tags === []) {
|
||||
return;
|
||||
}
|
||||
$ids = TagCatalog::normalizeFilterIds($tags);
|
||||
if ($ids === []) {
|
||||
return;
|
||||
}
|
||||
$mode = strtolower((string) ($filters['tag_mode'] ?? 'and')) === 'or' ? 'or' : 'and';
|
||||
$clauses = [];
|
||||
foreach ($ids as $id) {
|
||||
$clauses[] = '(r.tags_json LIKE ? OR r.tags_json LIKE ?)';
|
||||
$params[] = '%"id":"' . $id . '"%';
|
||||
$params[] = '%"id": "' . $id . '"%';
|
||||
}
|
||||
if ($mode === 'or') {
|
||||
$where[] = '(' . implode(' OR ', $clauses) . ')';
|
||||
} else {
|
||||
foreach ($clauses as $clause) {
|
||||
$where[] = $clause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
84
src/TagCatalog.php
Normal file
84
src/TagCatalog.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Workflow triage tags — multiple labels per report allowed (e.g. duplicate + fixed). */
|
||||
final class TagCatalog {
|
||||
/** @var list<array{id:string,label:string,bg:string}>|null */
|
||||
private static ?array $workflow = null;
|
||||
|
||||
/** @return list<array{id:string,label:string,bg:string}> */
|
||||
public static function workflowPresets(): array {
|
||||
if (self::$workflow !== null) {
|
||||
return self::$workflow;
|
||||
}
|
||||
self::$workflow = [
|
||||
['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3d8bfd'],
|
||||
['id' => 'fixed', 'label' => 'fixed', 'bg' => '#047857'],
|
||||
['id' => 'rejected', 'label' => 'rejected', 'bg' => '#dc2626'],
|
||||
['id' => 'done', 'label' => 'done', 'bg' => '#5c6b82'],
|
||||
['id' => 'not-reproducible', 'label' => 'not reproducible', 'bg' => '#d97706'],
|
||||
['id' => 'not-a-bug', 'label' => 'not a bug', 'bg' => '#6b7280'],
|
||||
['id' => 'duplicate', 'label' => 'duplicate', 'bg' => '#6d28d9'],
|
||||
];
|
||||
return self::$workflow;
|
||||
}
|
||||
|
||||
/** @return array<string, true> */
|
||||
public static function workflowIds(): array {
|
||||
$map = [];
|
||||
foreach (self::workflowPresets() as $tag) {
|
||||
$map[$tag['id']] = true;
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
public static function isWorkflowId(string $id): bool {
|
||||
return isset(self::workflowIds()[$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $raw from query (?tag=fixed&tag=duplicate)
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function normalizeFilterIds(array $raw): array {
|
||||
$out = [];
|
||||
$seen = [];
|
||||
foreach ($raw as $item) {
|
||||
$id = strtolower(trim((string) $item));
|
||||
$id = preg_replace('/[^a-z0-9_-]+/i', '-', $id) ?? '';
|
||||
$id = trim($id, '-');
|
||||
if ($id === '' || strlen($id) > 32 || isset($seen[$id])) {
|
||||
continue;
|
||||
}
|
||||
if (isset(array_flip(report_reserved_tag_ids())[$id])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$id] = true;
|
||||
$out[] = $id;
|
||||
if (count($out) >= 8) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow presets first, then distinct custom tags from DB (excluding workflow ids).
|
||||
*
|
||||
* @return list<array{id:string,label:string,bg:string}>
|
||||
*/
|
||||
public static function editorPresets(int $historicalLimit = 32): array {
|
||||
$map = [];
|
||||
foreach (self::workflowPresets() as $tag) {
|
||||
$map[$tag['id']] = $tag;
|
||||
}
|
||||
foreach (ReportRepository::listTagPresets($historicalLimit + 20) as $tag) {
|
||||
$id = (string) ($tag['id'] ?? '');
|
||||
if ($id === '' || isset($map[$id])) {
|
||||
continue;
|
||||
}
|
||||
$map[$id] = $tag;
|
||||
}
|
||||
return array_values($map);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user