1
0
mirror of git://f0xx.org/ac/ac-ms-issues synced 2026-07-29 00:58:52 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:32 +02:00
commit 892f6e3196
18 changed files with 2134 additions and 0 deletions

3
README.md Normal file
View File

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

25
composer.json Normal file
View 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/"
]
}
}

View File

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

77
public/api/diag.php Normal file
View 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
View 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);

View 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]);

View 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);
}

View 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
View 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
View 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);

View 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
View File

@@ -0,0 +1,53 @@
<?php
/*
* package examples/crash_reporter/backend/public/api/upload.php
* upload.php
* Created at: Wed 20 May 2026 14:31:55 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 25 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 edbab2563cb9d5ba1c9a6a6272071ac12efa782274c7d1626f088a951033d5a6
*/
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
$raw = read_crash_upload_body();
if ($raw === null) {
json_out(['ok' => false, 'error' => 'empty body'], 400);
}
if ($raw === false) {
json_out(['ok' => false, 'error' => 'invalid gzip body'], 400);
}
$data = json_decode($raw, true);
if (!is_array($data)) {
json_out(['ok' => false, 'error' => 'invalid json'], 400);
}
if ((int) ($data['schema_version'] ?? 0) !== 1) {
json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400);
}
try {
ReportRepository::insert($data);
json_out(['ok' => true, 'report_id' => $data['report_id'] ?? null]);
} catch (PDOException $e) {
$msg = $e->getMessage();
if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) {
json_out(['ok' => false, 'error' => 'duplicate report_id'], 409);
}
error_log('crash upload PDO: ' . $msg);
log_crash_event('upload', 'PDO: ' . $msg);
$out = ['ok' => false, 'error' => 'store failed'];
if (cfg('debug')) {
$out['hint'] = $msg;
}
json_out($out, 500);
} catch (Throwable $e) {
error_log('crash upload: ' . $e->getMessage());
log_crash_event('upload', $e->getMessage());
$out = ['ok' => false, 'error' => 'store failed'];
if (cfg('debug')) {
$out['hint'] = $e->getMessage();
}
json_out($out, 500);
}

2
public/index.php Normal file
View File

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

View File

@@ -0,0 +1,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
View 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;
}
}

View 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
View 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
View 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);
}
}